- 생산관리: 대시보드, 작업지시, 작업실적, 작업자화면 - 품질관리: 검사관리 (리스트/등록/상세) - 자재관리: 입고관리, 재고현황 - 출고관리: 출하관리 (리스트/등록/상세/수정) - 주문관리: 수주관리, 생산의뢰 - 기존 컴포넌트 개선: CardTransactionInquiry, VendorDetail, QuoteRegistration - IntegratedListTemplateV2 개선 - 공통 컴포넌트 분석 문서 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
793 lines
28 KiB
TypeScript
793 lines
28 KiB
TypeScript
/**
|
||
* 단가 등록/수정 폼 클라이언트 컴포넌트
|
||
*
|
||
* 기능:
|
||
* - 품목 정보 표시 (읽기전용)
|
||
* - 단가 정보 입력
|
||
* - 원가/마진 자동 계산
|
||
* - 반올림 규칙 적용
|
||
* - 수정 이력 관리
|
||
*/
|
||
|
||
'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 { Alert, AlertDescription } from '@/components/ui/alert';
|
||
|
||
// 필드명 매핑
|
||
const FIELD_NAME_MAP: Record<string, string> = {
|
||
effectiveDate: "적용일",
|
||
purchasePrice: "입고가",
|
||
salesPrice: "판매단가",
|
||
};
|
||
|
||
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>;
|
||
onFinalize?: (id: string) => Promise<void>;
|
||
}
|
||
|
||
export function PricingFormClient({
|
||
mode,
|
||
itemInfo,
|
||
initialData,
|
||
onSave,
|
||
onFinalize,
|
||
}: 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, string>>({});
|
||
|
||
// 다이얼로그 상태
|
||
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, string> = {};
|
||
if (!effectiveDate) newErrors.effectiveDate = "적용일을 선택해주세요";
|
||
if (purchasePrice <= 0 && salesPrice <= 0) {
|
||
newErrors.purchasePrice = "입고가 또는 판매단가 중 최소 하나를 입력해주세요";
|
||
newErrors.salesPrice = "입고가 또는 판매단가 중 최소 하나를 입력해주세요";
|
||
}
|
||
setErrors(newErrors);
|
||
return Object.keys(newErrors).length === 0;
|
||
}, [effectiveDate, purchasePrice, salesPrice]);
|
||
|
||
// 저장 처리
|
||
const handleSave = async (isRevision = false, revisionReason = '') => {
|
||
if (!validateForm()) {
|
||
// 페이지 상단으로 스크롤
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
return;
|
||
}
|
||
|
||
// 에러 초기화
|
||
setErrors({});
|
||
|
||
// 수정 모드이고 리비전 있으면 수정 이력 다이얼로그
|
||
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 {
|
||
if (onFinalize) {
|
||
// 서버 액션으로 확정 처리
|
||
await onFinalize(initialData.id);
|
||
}
|
||
|
||
toast.success('단가가 최종 확정되었습니다.');
|
||
setShowFinalizeDialog(false);
|
||
router.push('/sales/pricing-management');
|
||
} catch (error) {
|
||
toast.error('확정 중 오류가 발생했습니다.');
|
||
console.error(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>
|
||
|
||
{/* Validation 에러 표시 */}
|
||
{Object.keys(errors).length > 0 && (
|
||
<Alert className="bg-red-50 border-red-200 mb-6">
|
||
<AlertDescription className="text-red-900">
|
||
<div className="flex items-start gap-2">
|
||
<span className="text-lg">⚠️</span>
|
||
<div className="flex-1">
|
||
<strong className="block mb-2">
|
||
입력 내용을 확인해주세요 ({Object.keys(errors).length}개 오류)
|
||
</strong>
|
||
<ul className="space-y-1 text-sm">
|
||
{Object.entries(errors).map(([field, message]) => {
|
||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||
return (
|
||
<li key={field} className="flex items-start gap-1">
|
||
<span>•</span>
|
||
<span>
|
||
<strong>{fieldName}</strong>: {message}
|
||
</span>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{/* 상태 표시 (수정 모드) */}
|
||
{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) => { const n = {...prev}; delete n.effectiveDate; return n; });
|
||
}}
|
||
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) => { const n = {...prev}; delete n.purchasePrice; delete n.salesPrice; return n; });
|
||
}}
|
||
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) => { const n = {...prev}; delete n.purchasePrice; delete n.salesPrice; return n; });
|
||
}}
|
||
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;
|