Files
sam-react-prod/src/components/pricing/PricingFormClient.tsx
byeongcheolryu f0e8e51d06 feat: 생산/품질/자재/출고/주문 관리 페이지 구현
- 생산관리: 대시보드, 작업지시, 작업실적, 작업자화면
- 품질관리: 검사관리 (리스트/등록/상세)
- 자재관리: 입고관리, 재고현황
- 출고관리: 출하관리 (리스트/등록/상세/수정)
- 주문관리: 수주관리, 생산의뢰
- 기존 컴포넌트 개선: CardTransactionInquiry, VendorDetail, QuoteRegistration
- IntegratedListTemplateV2 개선
- 공통 컴포넌트 분석 문서 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 21:13:07 +09:00

793 lines
28 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 단가 등록/수정 폼 클라이언트 컴포넌트
*
* 기능:
* - 품목 정보 표시 (읽기전용)
* - 단가 정보 입력
* - 원가/마진 자동 계산
* - 반올림 규칙 적용
* - 수정 이력 관리
*/
'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;