feat: 단가관리 페이지 마이그레이션 및 HR 관리 기능 추가
## 단가관리 (Pricing Management) - 단가 목록 페이지 (IntegratedListTemplateV2 공통 템플릿 적용) - 단가 등록/수정 폼 (원가/마진 자동 계산) - 이력 조회, 수정 이력, 최종 확정 다이얼로그 - 판매관리 > 단가관리 네비게이션 메뉴 추가 ## HR 관리 (Human Resources) - 사원관리 (목록, 등록, 수정, 상세, CSV 업로드) - 부서관리 (트리 구조) - 근태관리 (기본 구조) ## 품목관리 개선 - Radix UI Select controlled mode 버그 수정 (key prop 적용) - DynamicItemForm 파일 업로드 지원 - 수정 페이지 데이터 로딩 개선 ## 문서화 - 단가관리 마이그레이션 체크리스트 - HR 관리 구현 체크리스트 - Radix UI Select 버그 수정 가이드 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
94
src/components/pricing/PricingFinalizeDialog.tsx
Normal file
94
src/components/pricing/PricingFinalizeDialog.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 단가 최종 확정 다이얼로그
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Lock, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface PricingFinalizeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
itemName: string;
|
||||
purchasePrice?: number;
|
||||
salesPrice?: number;
|
||||
marginRate?: number;
|
||||
}
|
||||
|
||||
export function PricingFinalizeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
itemName,
|
||||
purchasePrice,
|
||||
salesPrice,
|
||||
marginRate,
|
||||
}: PricingFinalizeDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Lock className="h-5 w-5 text-purple-600" />
|
||||
최종 확정
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
단가를 최종 확정하시겠습니까? 확정 후에는 수정할 수 없습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">품목:</span>
|
||||
<span className="font-semibold">{itemName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">매입단가:</span>
|
||||
<span className="font-semibold">
|
||||
{purchasePrice?.toLocaleString() || '-'}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">판매단가:</span>
|
||||
<span className="font-semibold">
|
||||
{salesPrice?.toLocaleString() || '-'}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">마진율:</span>
|
||||
<span className="font-semibold">
|
||||
{marginRate?.toFixed(1) || '-'}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
최종 확정
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default PricingFinalizeDialog;
|
||||
769
src/components/pricing/PricingFormClient.tsx
Normal file
769
src/components/pricing/PricingFormClient.tsx
Normal file
@@ -0,0 +1,769 @@
|
||||
/**
|
||||
* 단가 등록/수정 폼 클라이언트 컴포넌트
|
||||
*
|
||||
* 기능:
|
||||
* - 품목 정보 표시 (읽기전용)
|
||||
* - 단가 정보 입력
|
||||
* - 원가/마진 자동 계산
|
||||
* - 반올림 규칙 적용
|
||||
* - 수정 이력 관리
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
DollarSign,
|
||||
Package,
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Calculator,
|
||||
TrendingUp,
|
||||
AlertCircle,
|
||||
History,
|
||||
CheckCircle2,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type {
|
||||
PricingData,
|
||||
PricingFormData,
|
||||
ItemInfo,
|
||||
RoundingRule,
|
||||
ItemType,
|
||||
} from './types';
|
||||
import {
|
||||
ITEM_TYPE_LABELS,
|
||||
UNIT_OPTIONS,
|
||||
ROUNDING_RULE_OPTIONS,
|
||||
ROUNDING_UNIT_OPTIONS,
|
||||
} from './types';
|
||||
|
||||
// 다이얼로그 컴포넌트들 (추후 분리)
|
||||
import { PricingHistoryDialog } from './PricingHistoryDialog';
|
||||
import { PricingRevisionDialog } from './PricingRevisionDialog';
|
||||
import { PricingFinalizeDialog } from './PricingFinalizeDialog';
|
||||
|
||||
interface PricingFormClientProps {
|
||||
mode: 'create' | 'edit';
|
||||
itemInfo?: ItemInfo;
|
||||
initialData?: PricingData;
|
||||
onSave?: (data: PricingData, isRevision?: boolean, revisionReason?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function PricingFormClient({
|
||||
mode,
|
||||
itemInfo,
|
||||
initialData,
|
||||
onSave,
|
||||
}: PricingFormClientProps) {
|
||||
const router = useRouter();
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
// 품목 정보 (신규: itemInfo, 수정: initialData)
|
||||
const displayItemCode = initialData?.itemCode || itemInfo?.itemCode || '';
|
||||
const displayItemName = initialData?.itemName || itemInfo?.itemName || '';
|
||||
const displayItemType = initialData?.itemType || itemInfo?.itemType || '';
|
||||
const displaySpecification = initialData?.specification || itemInfo?.specification || '';
|
||||
const displayUnit = initialData?.unit || itemInfo?.unit || 'EA';
|
||||
|
||||
// 폼 상태
|
||||
const [effectiveDate, setEffectiveDate] = useState(
|
||||
initialData?.effectiveDate || new Date().toISOString().split('T')[0]
|
||||
);
|
||||
const [receiveDate, setReceiveDate] = useState(initialData?.receiveDate || '');
|
||||
const [author, setAuthor] = useState(initialData?.author || '');
|
||||
const [purchasePrice, setPurchasePrice] = useState(initialData?.purchasePrice || 0);
|
||||
const [processingCost, setProcessingCost] = useState(initialData?.processingCost || 0);
|
||||
const [loss, setLoss] = useState(initialData?.loss || 0);
|
||||
const [roundingRule, setRoundingRule] = useState<RoundingRule>(
|
||||
initialData?.roundingRule || 'round'
|
||||
);
|
||||
const [roundingUnit, setRoundingUnit] = useState(initialData?.roundingUnit || 1);
|
||||
const [marginRate, setMarginRate] = useState(initialData?.marginRate || 0);
|
||||
const [salesPrice, setSalesPrice] = useState(initialData?.salesPrice || 0);
|
||||
const [supplier, setSupplier] = useState(initialData?.supplier || '');
|
||||
const [note, setNote] = useState(initialData?.note || '');
|
||||
const [unit, setUnit] = useState(displayUnit);
|
||||
|
||||
// 에러 상태
|
||||
const [errors, setErrors] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [showHistoryDialog, setShowHistoryDialog] = useState(false);
|
||||
const [showRevisionDialog, setShowRevisionDialog] = useState(false);
|
||||
const [showFinalizeDialog, setShowFinalizeDialog] = useState(false);
|
||||
|
||||
// 로딩 상태
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 반올림 적용 함수
|
||||
const applyRounding = useCallback(
|
||||
(value: number, rule: RoundingRule, unit: number): number => {
|
||||
if (unit <= 0) return Math.round(value);
|
||||
switch (rule) {
|
||||
case 'ceil':
|
||||
return Math.ceil(value / unit) * unit;
|
||||
case 'floor':
|
||||
return Math.floor(value / unit) * unit;
|
||||
default:
|
||||
return Math.round(value / unit) * unit;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// LOSS 적용 원가 계산
|
||||
const costWithLoss = useMemo(() => {
|
||||
const basePrice = (purchasePrice || 0) + (processingCost || 0);
|
||||
const lossRate = (loss || 0) / 100;
|
||||
return Math.round(basePrice * (1 + lossRate));
|
||||
}, [purchasePrice, processingCost, loss]);
|
||||
|
||||
// 마진율 → 판매단가 계산
|
||||
const handleMarginRateChange = useCallback(
|
||||
(rate: number) => {
|
||||
setMarginRate(rate);
|
||||
if (costWithLoss > 0) {
|
||||
const calculatedPrice = costWithLoss * (1 + rate / 100);
|
||||
const roundedPrice = applyRounding(calculatedPrice, roundingRule, roundingUnit);
|
||||
setSalesPrice(Math.round(roundedPrice));
|
||||
} else {
|
||||
setSalesPrice(0);
|
||||
}
|
||||
},
|
||||
[costWithLoss, roundingRule, roundingUnit, applyRounding]
|
||||
);
|
||||
|
||||
// 판매단가 → 마진율 계산
|
||||
const handleSalesPriceChange = useCallback(
|
||||
(price: number) => {
|
||||
setSalesPrice(price);
|
||||
if (costWithLoss > 0) {
|
||||
const calculatedMarginRate = ((price - costWithLoss) / costWithLoss) * 100;
|
||||
setMarginRate(parseFloat(calculatedMarginRate.toFixed(1)));
|
||||
} else {
|
||||
setMarginRate(0);
|
||||
}
|
||||
},
|
||||
[costWithLoss]
|
||||
);
|
||||
|
||||
// 원가 변경 시 판매가 자동 재계산
|
||||
useEffect(() => {
|
||||
if (marginRate > 0 && (purchasePrice > 0 || processingCost > 0)) {
|
||||
const calculatedPrice = costWithLoss * (1 + marginRate / 100);
|
||||
const roundedPrice = applyRounding(calculatedPrice, roundingRule, roundingUnit);
|
||||
const finalPrice = Math.round(roundedPrice);
|
||||
if (finalPrice !== salesPrice) {
|
||||
setSalesPrice(finalPrice);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [purchasePrice, processingCost, loss, roundingRule, roundingUnit]);
|
||||
|
||||
// 마진 금액 계산
|
||||
const marginAmount = useMemo(() => {
|
||||
return salesPrice - costWithLoss;
|
||||
}, [salesPrice, costWithLoss]);
|
||||
|
||||
// 유효성 검사
|
||||
const validateForm = useCallback(() => {
|
||||
const newErrors: Record<string, boolean> = {};
|
||||
if (!effectiveDate) newErrors.effectiveDate = true;
|
||||
if (purchasePrice <= 0 && salesPrice <= 0) {
|
||||
newErrors.purchasePrice = true;
|
||||
newErrors.salesPrice = true;
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}, [effectiveDate, purchasePrice, salesPrice]);
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = async (isRevision = false, revisionReason = '') => {
|
||||
if (!validateForm()) {
|
||||
toast.error('필수 항목을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 수정 모드이고 리비전 있으면 수정 이력 다이얼로그
|
||||
if (isEditMode && initialData && !isRevision &&
|
||||
(initialData.currentRevision > 0 || initialData.isFinal)) {
|
||||
setShowRevisionDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const pricingData: PricingData = {
|
||||
id: initialData?.id || `PR-${Date.now()}`,
|
||||
itemId: initialData?.itemId || itemInfo?.id || '',
|
||||
itemCode: displayItemCode,
|
||||
itemName: displayItemName,
|
||||
itemType: displayItemType,
|
||||
specification: displaySpecification,
|
||||
unit: unit,
|
||||
effectiveDate,
|
||||
receiveDate: receiveDate || undefined,
|
||||
author: author || undefined,
|
||||
purchasePrice: purchasePrice || undefined,
|
||||
processingCost: processingCost || undefined,
|
||||
loss: loss || undefined,
|
||||
roundingRule: roundingRule || undefined,
|
||||
roundingUnit: roundingUnit || undefined,
|
||||
marginRate: marginRate || undefined,
|
||||
salesPrice: salesPrice || undefined,
|
||||
supplier: supplier || undefined,
|
||||
note: note || undefined,
|
||||
currentRevision: isRevision
|
||||
? (initialData?.currentRevision || 0) + 1
|
||||
: initialData?.currentRevision || 0,
|
||||
isFinal: initialData?.isFinal || false,
|
||||
revisions: initialData?.revisions || [],
|
||||
status: isRevision ? 'active' : initialData?.status || 'draft',
|
||||
createdAt: initialData?.createdAt || new Date().toISOString(),
|
||||
createdBy: initialData?.createdBy || '관리자',
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: '관리자',
|
||||
};
|
||||
|
||||
if (onSave) {
|
||||
await onSave(pricingData, isRevision, revisionReason);
|
||||
}
|
||||
|
||||
toast.success(isEditMode ? '단가가 수정되었습니다.' : '단가가 등록되었습니다.');
|
||||
router.push('/sales/pricing-management');
|
||||
} catch (error) {
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 최종 확정 처리
|
||||
const handleFinalize = async () => {
|
||||
if (!initialData) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const finalizedData: PricingData = {
|
||||
...initialData,
|
||||
effectiveDate,
|
||||
receiveDate: receiveDate || undefined,
|
||||
author: author || undefined,
|
||||
purchasePrice: purchasePrice || undefined,
|
||||
processingCost: processingCost || undefined,
|
||||
loss: loss || undefined,
|
||||
roundingRule: roundingRule || undefined,
|
||||
roundingUnit: roundingUnit || undefined,
|
||||
marginRate: marginRate || undefined,
|
||||
salesPrice: salesPrice || undefined,
|
||||
supplier: supplier || undefined,
|
||||
note: note || undefined,
|
||||
isFinal: true,
|
||||
finalizedDate: new Date().toISOString(),
|
||||
finalizedBy: '관리자',
|
||||
status: 'finalized',
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: '관리자',
|
||||
};
|
||||
|
||||
if (onSave) {
|
||||
await onSave(finalizedData);
|
||||
}
|
||||
|
||||
toast.success('단가가 최종 확정되었습니다.');
|
||||
setShowFinalizeDialog(false);
|
||||
router.push('/sales/pricing-management');
|
||||
} catch (error) {
|
||||
toast.error('확정 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 max-w-5xl">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<DollarSign className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
단가 {isEditMode ? '수정' : '등록'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isEditMode
|
||||
? '품목의 단가 정보를 수정합니다'
|
||||
: '새로운 품목의 단가 정보를 등록합니다'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 표시 (수정 모드) */}
|
||||
{isEditMode && initialData && (
|
||||
<div className="mb-4 flex gap-2 justify-end">
|
||||
{initialData.isFinal && (
|
||||
<Badge className="bg-purple-600">
|
||||
<Lock className="h-3 w-3 mr-1" />
|
||||
최종 확정됨
|
||||
</Badge>
|
||||
)}
|
||||
{initialData.currentRevision > 0 && (
|
||||
<Badge variant="outline" className="bg-blue-50 text-blue-700">
|
||||
<History className="h-3 w-3 mr-1" />
|
||||
수정 {initialData.currentRevision}차
|
||||
</Badge>
|
||||
)}
|
||||
{initialData.status === 'active' && !initialData.isFinal && (
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
|
||||
활성
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 품목 정보 카드 */}
|
||||
<Card className="mb-6 border-2 border-blue-200">
|
||||
<CardHeader className="bg-blue-50">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5 text-blue-600" />
|
||||
품목 정보
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">품목 코드</Label>
|
||||
<div className="mt-1 font-semibold">{displayItemCode}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">품목명</Label>
|
||||
<div className="mt-1 font-semibold">{displayItemName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">품목 유형</Label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline">
|
||||
{ITEM_TYPE_LABELS[displayItemType as ItemType] || displayItemType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">단위</Label>
|
||||
<div className="mt-1 font-semibold">{displayUnit}</div>
|
||||
</div>
|
||||
{displaySpecification && (
|
||||
<div className="col-span-2 md:col-span-4">
|
||||
<Label className="text-sm text-muted-foreground">규격</Label>
|
||||
<div className="mt-1">{displaySpecification}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 단가 정보 카드 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5" />
|
||||
단가 정보
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 적용일 */}
|
||||
<div>
|
||||
<Label>
|
||||
적용일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={effectiveDate}
|
||||
onChange={(e) => {
|
||||
setEffectiveDate(e.target.value);
|
||||
setErrors((prev) => ({ ...prev, effectiveDate: false }));
|
||||
}}
|
||||
className={errors.effectiveDate ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.effectiveDate && (
|
||||
<p className="text-sm text-red-500 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
적용일을 선택해주세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 공급업체 및 기본 정보 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>공급업체</Label>
|
||||
<Input
|
||||
value={supplier}
|
||||
onChange={(e) => setSupplier(e.target.value)}
|
||||
placeholder="공급업체명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>입고일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={receiveDate}
|
||||
onChange={(e) => setReceiveDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>작성자</Label>
|
||||
<Input
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
placeholder="작성자명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 입고가 및 단위 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>
|
||||
입고가 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
value={purchasePrice || ''}
|
||||
onChange={(e) => {
|
||||
setPurchasePrice(parseInt(e.target.value) || 0);
|
||||
setErrors((prev) => ({ ...prev, purchasePrice: false, salesPrice: false }));
|
||||
}}
|
||||
placeholder="0"
|
||||
className={errors.purchasePrice ? 'border-red-500 pr-12' : 'pr-12'}
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>단위</Label>
|
||||
<Select value={unit} onValueChange={setUnit}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="단위 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UNIT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
단가 적용 시 사용될 단위
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>LOSS (%)</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={loss || ''}
|
||||
onChange={(e) => setLoss(parseFloat(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
className="pr-12"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
제조 과정에서 발생하는 손실율
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>가공비</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
value={processingCost || ''}
|
||||
onChange={(e) => setProcessingCost(parseInt(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
className="pr-12"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 원가 계산 섹션 */}
|
||||
{(purchasePrice > 0 || processingCost > 0) && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Calculator className="h-4 w-4 text-blue-600" />
|
||||
<Label className="text-blue-900">원가 계산</Label>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">입고가:</span>
|
||||
<span>{(purchasePrice || 0).toLocaleString()}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">가공비:</span>
|
||||
<span>{(processingCost || 0).toLocaleString()}원</span>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">소계:</span>
|
||||
<span>{((purchasePrice || 0) + (processingCost || 0)).toLocaleString()}원</span>
|
||||
</div>
|
||||
{loss > 0 && (
|
||||
<div className="flex justify-between text-orange-600">
|
||||
<span>LOSS ({loss}%):</span>
|
||||
<span>
|
||||
+{(((purchasePrice || 0) + (processingCost || 0)) * (loss / 100)).toLocaleString()}원
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator className="my-2" />
|
||||
<div className="flex justify-between font-semibold text-base">
|
||||
<span className="text-blue-900">LOSS 적용 원가:</span>
|
||||
<span className="text-blue-600">{costWithLoss.toLocaleString()}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 반올림 설정 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>반올림 규칙</Label>
|
||||
<Select
|
||||
value={roundingRule}
|
||||
onValueChange={(value) => setRoundingRule(value as RoundingRule)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROUNDING_RULE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>반올림 단위</Label>
|
||||
<Select
|
||||
value={roundingUnit.toString()}
|
||||
onValueChange={(value) => setRoundingUnit(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROUNDING_UNIT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value.toString()}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
단가 계산 시 적용될 반올림 단위
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 마진율/판매단가 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>마진율 (%)</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={marginRate || ''}
|
||||
onChange={(e) => handleMarginRateChange(parseFloat(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
className="pr-12"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
마진율을 입력하면 판매단가가 자동 계산됩니다
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
판매단가 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
value={salesPrice || ''}
|
||||
onChange={(e) => {
|
||||
handleSalesPriceChange(parseInt(e.target.value) || 0);
|
||||
setErrors((prev) => ({ ...prev, purchasePrice: false, salesPrice: false }));
|
||||
}}
|
||||
placeholder="0"
|
||||
className={errors.salesPrice ? 'border-red-500 pr-12' : 'pr-12'}
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
원
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
판매단가를 직접 입력하면 마진율이 자동 계산됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(errors.purchasePrice || errors.salesPrice) && (
|
||||
<p className="text-sm text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
입고가 또는 판매단가 중 최소 하나를 입력해주세요
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 마진 계산 섹션 */}
|
||||
{salesPrice > 0 && (purchasePrice > 0 || processingCost > 0) && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||
<Label className="text-green-900">마진 계산</Label>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">LOSS 적용 원가:</span>
|
||||
<span>{costWithLoss.toLocaleString()}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">판매단가:</span>
|
||||
<span>{salesPrice.toLocaleString()}원</span>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="flex justify-between font-semibold text-base">
|
||||
<span className="text-green-900">마진:</span>
|
||||
<span className="text-green-600">
|
||||
{marginAmount.toLocaleString()}원 ({marginRate.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 비고 */}
|
||||
<div>
|
||||
<Label>비고</Label>
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="비고사항을 입력하세요"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex gap-2 justify-between">
|
||||
<div className="flex gap-2">
|
||||
{isEditMode && initialData?.revisions && initialData.revisions.length > 0 && (
|
||||
<Button variant="outline" onClick={() => setShowHistoryDialog(true)}>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
이력 조회 ({initialData.currentRevision}차)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push('/sales/pricing-management')}
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
{isEditMode && initialData && !initialData.isFinal && (
|
||||
<Button
|
||||
onClick={() => setShowFinalizeDialog(true)}
|
||||
className="min-w-[100px] bg-purple-600 hover:bg-purple-700"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
최종 확정
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleSave()}
|
||||
className="min-w-[100px] bg-blue-600 hover:bg-blue-700"
|
||||
disabled={initialData?.isFinal || isSaving}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{initialData?.isFinal ? '확정됨' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 다이얼로그들 */}
|
||||
<PricingHistoryDialog
|
||||
open={showHistoryDialog}
|
||||
onOpenChange={setShowHistoryDialog}
|
||||
pricingData={initialData}
|
||||
/>
|
||||
|
||||
<PricingRevisionDialog
|
||||
open={showRevisionDialog}
|
||||
onOpenChange={setShowRevisionDialog}
|
||||
onConfirm={(reason) => handleSave(true, reason)}
|
||||
/>
|
||||
|
||||
<PricingFinalizeDialog
|
||||
open={showFinalizeDialog}
|
||||
onOpenChange={setShowFinalizeDialog}
|
||||
onConfirm={handleFinalize}
|
||||
itemName={displayItemName}
|
||||
purchasePrice={purchasePrice}
|
||||
salesPrice={salesPrice}
|
||||
marginRate={marginRate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PricingFormClient;
|
||||
168
src/components/pricing/PricingHistoryDialog.tsx
Normal file
168
src/components/pricing/PricingHistoryDialog.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 단가 이력 조회 다이얼로그
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { History } from 'lucide-react';
|
||||
import type { PricingData } from './types';
|
||||
|
||||
interface PricingHistoryDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
pricingData?: PricingData | null;
|
||||
}
|
||||
|
||||
export function PricingHistoryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
pricingData,
|
||||
}: PricingHistoryDialogProps) {
|
||||
if (!pricingData) return null;
|
||||
|
||||
const hasRevisions = pricingData.revisions && pricingData.revisions.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
단가 수정 이력
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pricingData.itemName} ({pricingData.itemCode})의 단가 변경 이력입니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{hasRevisions ? (
|
||||
<div className="space-y-4">
|
||||
{/* 현재 버전 */}
|
||||
<div className="border-2 border-blue-200 rounded-lg p-4 bg-blue-50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-blue-600">현재 버전</Badge>
|
||||
<span className="font-semibold">
|
||||
수정 {pricingData.currentRevision}차
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(
|
||||
pricingData.updatedAt || pricingData.createdAt
|
||||
).toLocaleString('ko-KR')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">매입단가:</span>
|
||||
<div className="font-semibold">
|
||||
{pricingData.purchasePrice?.toLocaleString() || '-'}원
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">가공비:</span>
|
||||
<div className="font-semibold">
|
||||
{pricingData.processingCost?.toLocaleString() || '-'}원
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">판매단가:</span>
|
||||
<div className="font-semibold">
|
||||
{pricingData.salesPrice?.toLocaleString() || '-'}원
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">마진율:</span>
|
||||
<div className="font-semibold">
|
||||
{pricingData.marginRate?.toFixed(1) || '-'}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 이전 버전들 */}
|
||||
{[...pricingData.revisions!].reverse().map((revision) => (
|
||||
<div key={revision.revisionNumber} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">이전 버전</Badge>
|
||||
<span className="font-semibold">
|
||||
수정 {revision.revisionNumber}차
|
||||
</span>
|
||||
{revision.revisionReason && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({revision.revisionReason})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date(revision.revisionDate).toLocaleString('ko-KR')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
by {revision.revisionBy}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">매입단가:</span>
|
||||
<div>
|
||||
{revision.previousData.purchasePrice?.toLocaleString() || '-'}원
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">가공비:</span>
|
||||
<div>
|
||||
{revision.previousData.processingCost?.toLocaleString() || '-'}원
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">판매단가:</span>
|
||||
<div>
|
||||
{revision.previousData.salesPrice?.toLocaleString() || '-'}원
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">마진율:</span>
|
||||
<div>
|
||||
{revision.previousData.marginRate?.toFixed(1) || '-'}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 최초 버전 */}
|
||||
<div className="border rounded-lg p-4 bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">최초 버전</Badge>
|
||||
<span className="font-semibold">초기 등록</span>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(pricingData.createdAt).toLocaleString('ko-KR')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
수정 이력이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default PricingHistoryDialog;
|
||||
449
src/components/pricing/PricingListClient.tsx
Normal file
449
src/components/pricing/PricingListClient.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* 단가 목록 클라이언트 컴포넌트
|
||||
*
|
||||
* IntegratedListTemplateV2 공통 템플릿 활용
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
DollarSign,
|
||||
Package,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Plus,
|
||||
Edit,
|
||||
History,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
type TabOption,
|
||||
type TableColumn,
|
||||
type StatCard,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import type { PricingListItem, ItemType } from './types';
|
||||
import { ITEM_TYPE_LABELS, ITEM_TYPE_COLORS } from './types';
|
||||
|
||||
interface PricingListClientProps {
|
||||
initialData: PricingListItem[];
|
||||
}
|
||||
|
||||
export function PricingListClient({
|
||||
initialData,
|
||||
}: PricingListClientProps) {
|
||||
const router = useRouter();
|
||||
const [data] = useState<PricingListItem[]>(initialData);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredData = useMemo(() => {
|
||||
let result = [...data];
|
||||
|
||||
// 탭 필터
|
||||
if (activeTab !== 'all') {
|
||||
result = result.filter(item => item.itemType === activeTab);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const search = searchTerm.toLowerCase();
|
||||
result = result.filter(item =>
|
||||
item.itemCode.toLowerCase().includes(search) ||
|
||||
item.itemName.toLowerCase().includes(search) ||
|
||||
(item.specification?.toLowerCase().includes(search) ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, activeTab, searchTerm]);
|
||||
|
||||
// 페이지네이션된 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filteredData.slice(start, start + pageSize);
|
||||
}, [filteredData, currentPage, pageSize]);
|
||||
|
||||
// 통계 계산
|
||||
const totalStats = useMemo(() => {
|
||||
const totalAll = data.length;
|
||||
const totalFG = data.filter(d => d.itemType === 'FG').length;
|
||||
const totalPT = data.filter(d => d.itemType === 'PT').length;
|
||||
const totalSM = data.filter(d => d.itemType === 'SM').length;
|
||||
const totalRM = data.filter(d => d.itemType === 'RM').length;
|
||||
const totalCS = data.filter(d => d.itemType === 'CS').length;
|
||||
const registered = data.filter(d => d.status !== 'not_registered').length;
|
||||
const notRegistered = totalAll - registered;
|
||||
const finalized = data.filter(d => d.isFinal).length;
|
||||
|
||||
return { totalAll, totalFG, totalPT, totalSM, totalRM, totalCS, registered, notRegistered, finalized };
|
||||
}, [data]);
|
||||
|
||||
// 금액 포맷팅
|
||||
const formatPrice = (price?: number) => {
|
||||
if (price === undefined || price === null) return '-';
|
||||
return `${price.toLocaleString()}원`;
|
||||
};
|
||||
|
||||
// 품목 유형 Badge 렌더링
|
||||
const renderItemTypeBadge = (type: string) => {
|
||||
const colors = ITEM_TYPE_COLORS[type as ItemType];
|
||||
const label = ITEM_TYPE_LABELS[type as ItemType] || type;
|
||||
|
||||
if (!colors) {
|
||||
return <Badge variant="outline">{label}</Badge>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${colors.bg} ${colors.text} ${colors.border}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// 상태 Badge 렌더링
|
||||
const renderStatusBadge = (item: PricingListItem) => {
|
||||
if (item.status === 'not_registered') {
|
||||
return <Badge variant="outline" className="bg-gray-50 text-gray-700">미등록</Badge>;
|
||||
}
|
||||
if (item.isFinal) {
|
||||
return <Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-200">확정</Badge>;
|
||||
}
|
||||
if (item.status === 'active') {
|
||||
return <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">활성</Badge>;
|
||||
}
|
||||
if (item.status === 'inactive') {
|
||||
return <Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">비활성</Badge>;
|
||||
}
|
||||
return <Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">초안</Badge>;
|
||||
};
|
||||
|
||||
// 마진율 Badge 렌더링
|
||||
const renderMarginBadge = (marginRate?: number) => {
|
||||
if (marginRate === undefined || marginRate === null || marginRate === 0) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
const colorClass =
|
||||
marginRate >= 30 ? 'bg-green-50 text-green-700 border-green-200' :
|
||||
marginRate >= 20 ? 'bg-blue-50 text-blue-700 border-blue-200' :
|
||||
marginRate >= 10 ? 'bg-orange-50 text-orange-700 border-orange-200' :
|
||||
'bg-red-50 text-red-700 border-red-200';
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={colorClass}>
|
||||
{marginRate.toFixed(1)}%
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// 네비게이션 핸들러
|
||||
const handleRegister = (item: PricingListItem) => {
|
||||
router.push(`/sales/pricing-management/create?itemId=${item.itemId}&itemCode=${item.itemCode}`);
|
||||
};
|
||||
|
||||
const handleEdit = (item: PricingListItem) => {
|
||||
router.push(`/sales/pricing-management/${item.id}/edit`);
|
||||
};
|
||||
|
||||
const handleHistory = (item: PricingListItem) => {
|
||||
// TODO: 이력 다이얼로그 열기
|
||||
console.log('이력 조회:', item.id);
|
||||
};
|
||||
|
||||
// 체크박스 전체 선택/해제
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedItems.size === paginatedData.length && paginatedData.length > 0) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
const allIds = new Set(paginatedData.map((item) => item.id));
|
||||
setSelectedItems(allIds);
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 체크박스 선택/해제
|
||||
const toggleSelection = (itemId: string) => {
|
||||
const newSelected = new Set(selectedItems);
|
||||
if (newSelected.has(itemId)) {
|
||||
newSelected.delete(itemId);
|
||||
} else {
|
||||
newSelected.add(itemId);
|
||||
}
|
||||
setSelectedItems(newSelected);
|
||||
};
|
||||
|
||||
// 탭 옵션
|
||||
const tabs: TabOption[] = [
|
||||
{ value: 'all', label: '전체', count: totalStats.totalAll, color: 'gray' },
|
||||
{ value: 'FG', label: '제품', count: totalStats.totalFG, color: 'purple' },
|
||||
{ value: 'PT', label: '부품', count: totalStats.totalPT, color: 'orange' },
|
||||
{ value: 'SM', label: '부자재', count: totalStats.totalSM, color: 'green' },
|
||||
{ value: 'RM', label: '원자재', count: totalStats.totalRM, color: 'blue' },
|
||||
{ value: 'CS', label: '소모품', count: totalStats.totalCS, color: 'gray' },
|
||||
];
|
||||
|
||||
// 통계 카드
|
||||
const stats: StatCard[] = [
|
||||
{ label: '전체 품목', value: totalStats.totalAll, icon: Package, iconColor: 'text-blue-600' },
|
||||
{ label: '단가 등록', value: totalStats.registered, icon: DollarSign, iconColor: 'text-green-600' },
|
||||
{ label: '미등록', value: totalStats.notRegistered, icon: AlertCircle, iconColor: 'text-orange-600' },
|
||||
{ label: '확정', value: totalStats.finalized, icon: CheckCircle2, iconColor: 'text-purple-600' },
|
||||
];
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'itemType', label: '품목유형', className: 'min-w-[100px]' },
|
||||
{ key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' },
|
||||
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
|
||||
{ key: 'specification', label: '규격', className: 'min-w-[100px]', hideOnMobile: true },
|
||||
{ key: 'unit', label: '단위', className: 'min-w-[60px]', hideOnMobile: true },
|
||||
{ key: 'purchasePrice', label: '매입단가', className: 'min-w-[100px] text-right', hideOnTablet: true },
|
||||
{ key: 'processingCost', label: '가공비', className: 'min-w-[80px] text-right', hideOnTablet: true },
|
||||
{ key: 'salesPrice', label: '판매단가', className: 'min-w-[100px] text-right' },
|
||||
{ key: 'marginRate', label: '마진율', className: 'min-w-[80px] text-right', hideOnMobile: true },
|
||||
{ key: 'effectiveDate', label: '적용일', className: 'min-w-[100px]', hideOnMobile: true },
|
||||
{ key: 'status', label: '상태', className: 'min-w-[80px]' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[120px] text-right' },
|
||||
];
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (item: PricingListItem, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
|
||||
return (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="text-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelection(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-center">
|
||||
{globalIndex}
|
||||
</TableCell>
|
||||
<TableCell>{renderItemTypeBadge(item.itemType)}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium truncate max-w-[200px] block">
|
||||
{item.itemName}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground hidden md:table-cell">
|
||||
{item.specification || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<Badge variant="secondary">{item.unit || '-'}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono hidden lg:table-cell">
|
||||
{formatPrice(item.purchasePrice)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono hidden lg:table-cell">
|
||||
{formatPrice(item.processingCost)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono font-semibold">
|
||||
{formatPrice(item.salesPrice)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right hidden md:table-cell">
|
||||
{renderMarginBadge(item.marginRate)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{item.effectiveDate
|
||||
? new Date(item.effectiveDate).toLocaleDateString('ko-KR')
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>{renderStatusBadge(item)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{item.status === 'not_registered' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleRegister(item); }}
|
||||
title="단가 등록"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
|
||||
title="수정"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
{item.currentRevision > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleHistory(item); }}
|
||||
title="이력"
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
item: PricingListItem,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
title={item.itemName}
|
||||
headerBadges={
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
{renderItemTypeBadge(item.itemType)}
|
||||
</div>
|
||||
}
|
||||
statusBadge={renderStatusBadge(item)}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onCardClick={() => item.status !== 'not_registered' ? handleEdit(item) : handleRegister(item)}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
{item.specification && (
|
||||
<InfoField label="규격" value={item.specification} />
|
||||
)}
|
||||
{item.unit && (
|
||||
<InfoField label="단위" value={item.unit} />
|
||||
)}
|
||||
<InfoField label="판매단가" value={formatPrice(item.salesPrice)} />
|
||||
<InfoField
|
||||
label="마진율"
|
||||
value={item.marginRate ? `${item.marginRate.toFixed(1)}%` : '-'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
isSelected ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{item.status === 'not_registered' ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => { e.stopPropagation(); handleRegister(item); }}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
등록
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{item.currentRevision > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => { e.stopPropagation(); handleHistory(item); }}
|
||||
>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
이력
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 헤더 액션
|
||||
const headerActions = (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// TODO: API 연동 시 품목 마스터 동기화 로직 구현
|
||||
console.log('품목 마스터 동기화');
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
품목 마스터 동기화
|
||||
</Button>
|
||||
);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2<PricingListItem>
|
||||
title="단가 관리"
|
||||
description="품목별 매입단가, 판매단가 및 마진을 관리합니다"
|
||||
icon={DollarSign}
|
||||
headerActions={headerActions}
|
||||
stats={stats}
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
searchPlaceholder="품목코드, 품목명, 규격 검색..."
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
totalCount={filteredData.length}
|
||||
allData={filteredData}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredData.length,
|
||||
itemsPerPage: pageSize,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PricingListClient;
|
||||
94
src/components/pricing/PricingRevisionDialog.tsx
Normal file
94
src/components/pricing/PricingRevisionDialog.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 단가 수정 이력 생성 다이얼로그
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Edit2, Save } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface PricingRevisionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (reason: string) => void;
|
||||
}
|
||||
|
||||
export function PricingRevisionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: PricingRevisionDialogProps) {
|
||||
const [revisionReason, setRevisionReason] = useState('');
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!revisionReason.trim()) {
|
||||
toast.error('수정 사유를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
onConfirm(revisionReason);
|
||||
setRevisionReason('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setRevisionReason('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Edit2 className="h-5 w-5" />
|
||||
수정 이력 생성
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
단가 정보를 수정하시겠습니까? 수정 사유를 입력해주세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label>
|
||||
수정 사유 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
value={revisionReason}
|
||||
onChange={(e) => setRevisionReason(e.target.value)}
|
||||
placeholder="예: 공급업체 단가 인상으로 인한 조정"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
수정 이력 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default PricingRevisionDialog;
|
||||
10
src/components/pricing/index.ts
Normal file
10
src/components/pricing/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 단가관리 컴포넌트 인덱스
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export { PricingListClient } from './PricingListClient';
|
||||
export { PricingFormClient } from './PricingFormClient';
|
||||
export { PricingHistoryDialog } from './PricingHistoryDialog';
|
||||
export { PricingRevisionDialog } from './PricingRevisionDialog';
|
||||
export { PricingFinalizeDialog } from './PricingFinalizeDialog';
|
||||
182
src/components/pricing/types.ts
Normal file
182
src/components/pricing/types.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 단가관리 타입 정의
|
||||
*/
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
// ===== 단가 리비전 =====
|
||||
|
||||
/** 단가 수정 이력 */
|
||||
export interface PricingRevision {
|
||||
revisionNumber: number;
|
||||
revisionDate: string;
|
||||
revisionBy: string;
|
||||
revisionReason?: string;
|
||||
previousData: PricingData;
|
||||
}
|
||||
|
||||
// ===== 단가 데이터 =====
|
||||
|
||||
/** 단가 상태 */
|
||||
export type PricingStatus = 'draft' | 'active' | 'inactive' | 'finalized';
|
||||
|
||||
/** 반올림 규칙 */
|
||||
export type RoundingRule = 'round' | 'ceil' | 'floor';
|
||||
|
||||
/** 단가 데이터 인터페이스 */
|
||||
export interface PricingData {
|
||||
id: string;
|
||||
itemId: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
itemType: string;
|
||||
specification?: string;
|
||||
unit: string;
|
||||
|
||||
// 단가 정보
|
||||
effectiveDate: string; // 적용일
|
||||
receiveDate?: string; // 입고일
|
||||
author?: string; // 작성자
|
||||
purchasePrice?: number; // 매입단가 (입고가)
|
||||
processingCost?: number; // 가공비
|
||||
loss?: number; // LOSS(%)
|
||||
roundingRule?: RoundingRule; // 반올림 규칙
|
||||
roundingUnit?: number; // 반올림 단위
|
||||
marginRate?: number; // 마진율(%)
|
||||
salesPrice?: number; // 판매단가
|
||||
supplier?: string; // 공급업체
|
||||
note?: string; // 비고
|
||||
|
||||
// 리비전 관리
|
||||
currentRevision: number;
|
||||
isFinal: boolean;
|
||||
revisions?: PricingRevision[];
|
||||
finalizedDate?: string;
|
||||
finalizedBy?: string;
|
||||
status: PricingStatus;
|
||||
|
||||
// 메타데이터
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
updatedAt?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
// ===== 품목 정보 (등록 시 전달) =====
|
||||
|
||||
/** 품목 기본 정보 */
|
||||
export interface ItemInfo {
|
||||
id: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
itemType: string;
|
||||
specification?: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
// ===== 폼 데이터 =====
|
||||
|
||||
/** 단가 폼 입력 데이터 */
|
||||
export interface PricingFormData {
|
||||
effectiveDate: string;
|
||||
receiveDate: string;
|
||||
author: string;
|
||||
purchasePrice: number;
|
||||
processingCost: number;
|
||||
loss: number;
|
||||
roundingRule: RoundingRule;
|
||||
roundingUnit: number;
|
||||
marginRate: number;
|
||||
salesPrice: number;
|
||||
supplier: string;
|
||||
note: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
// ===== 통계 =====
|
||||
|
||||
/** 단가 통계 */
|
||||
export interface PricingStats {
|
||||
totalItems: number;
|
||||
registeredCount: number;
|
||||
notRegisteredCount: number;
|
||||
finalizedCount: number;
|
||||
}
|
||||
|
||||
// ===== 목록 아이템 (테이블용) =====
|
||||
|
||||
/** 목록 표시용 데이터 */
|
||||
export interface PricingListItem {
|
||||
id: string;
|
||||
itemId: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
itemType: string;
|
||||
specification?: string;
|
||||
unit: string;
|
||||
purchasePrice?: number;
|
||||
processingCost?: number;
|
||||
salesPrice?: number;
|
||||
marginRate?: number;
|
||||
effectiveDate?: string;
|
||||
status: PricingStatus | 'not_registered';
|
||||
currentRevision: number;
|
||||
isFinal: boolean;
|
||||
}
|
||||
|
||||
// ===== 유틸리티 타입 =====
|
||||
|
||||
/** 품목 유형 */
|
||||
export type ItemType = 'FG' | 'PT' | 'SM' | 'RM' | 'CS' | 'BENDING';
|
||||
|
||||
/** 품목 유형 라벨 맵 */
|
||||
export const ITEM_TYPE_LABELS: Record<ItemType, string> = {
|
||||
FG: '제품',
|
||||
PT: '부품',
|
||||
SM: '부자재',
|
||||
RM: '원자재',
|
||||
CS: '소모품',
|
||||
BENDING: '절곡물',
|
||||
};
|
||||
|
||||
/** 품목 유형 색상 맵 */
|
||||
export const ITEM_TYPE_COLORS: Record<ItemType, { bg: string; text: string; border: string }> = {
|
||||
FG: { bg: 'bg-purple-50', text: 'text-purple-700', border: 'border-purple-200' },
|
||||
PT: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200' },
|
||||
SM: { bg: 'bg-cyan-50', text: 'text-cyan-700', border: 'border-cyan-200' },
|
||||
RM: { bg: 'bg-green-50', text: 'text-green-700', border: 'border-green-200' },
|
||||
CS: { bg: 'bg-gray-50', text: 'text-gray-700', border: 'border-gray-200' },
|
||||
BENDING: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200' },
|
||||
};
|
||||
|
||||
/** 단위 옵션 */
|
||||
export const UNIT_OPTIONS = [
|
||||
{ value: 'EA', label: 'EA (개)' },
|
||||
{ value: 'SET', label: 'SET (세트)' },
|
||||
{ value: 'KG', label: 'KG (킬로그램)' },
|
||||
{ value: 'G', label: 'G (그램)' },
|
||||
{ value: 'M', label: 'M (미터)' },
|
||||
{ value: 'CM', label: 'CM (센티미터)' },
|
||||
{ value: 'MM', label: 'MM (밀리미터)' },
|
||||
{ value: 'L', label: 'L (리터)' },
|
||||
{ value: 'ML', label: 'ML (밀리리터)' },
|
||||
{ value: 'BOX', label: 'BOX (박스)' },
|
||||
{ value: 'ROLL', label: 'ROLL (롤)' },
|
||||
{ value: 'SHEET', label: 'SHEET (장)' },
|
||||
];
|
||||
|
||||
/** 반올림 규칙 옵션 */
|
||||
export const ROUNDING_RULE_OPTIONS = [
|
||||
{ value: 'round', label: '반올림' },
|
||||
{ value: 'ceil', label: '올림' },
|
||||
{ value: 'floor', label: '내림' },
|
||||
];
|
||||
|
||||
/** 반올림 단위 옵션 */
|
||||
export const ROUNDING_UNIT_OPTIONS = [
|
||||
{ value: 1, label: '1원 단위' },
|
||||
{ value: 10, label: '10원 단위' },
|
||||
{ value: 100, label: '100원 단위' },
|
||||
{ value: 1000, label: '1,000원 단위' },
|
||||
{ value: 10000, label: '10,000원 단위' },
|
||||
];
|
||||
Reference in New Issue
Block a user