Files
sam-react-prod/src/components/pricing/PricingFormClient.tsx

752 lines
27 KiB
TypeScript
Raw Normal View History

/**
* /
*
* :
* - ()
* -
* - /
* -
* -
*/
'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>;
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, 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 {
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>
{/* 상태 표시 (수정 모드) */}
{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;