Files
sam-react-prod/src/components/pricing-table-management/PricingTableForm.tsx
유병철 a38996b751 refactor(WEB): V2 파일 통합, store 구조 정리 및 대시보드 개선
- V2 컴포넌트를 원본에 통합 후 V2 파일 삭제 (InspectionModal, BillDetail, ContractDocumentModal, LaborDetailClient, PricingDetailClient, QuoteRegistration)
- store → stores 디렉토리 이동 및 favoritesStore 추가
- dashboard_type3~5 추가 및 기존 대시보드 차트/훅 분리
- Sidebar 리팩토링 및 HeaderFavoritesBar 추가
- DashboardSwitcher 컴포넌트 추가
- 백업 파일(.v1-backup) 및 불필요 코드 정리
- InspectionPreviewModal 레이아웃 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:09:51 +09:00

486 lines
19 KiB
TypeScript

'use client';
/**
* 단가표 등록/상세(수정) 통합 폼
*
* 기획서 기준:
* - edit 모드(=상세): 기본정보 readonly, 상태/단가정보 editable, 하단 삭제+수정
* - create 모드(=등록): 기본정보 전부 editable, 하단 등록
*/
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Save, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { useMenuStore } from '@/stores/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { toast } from 'sonner';
import { createPricingTable, updatePricingTable, deletePricingTable } from './actions';
import { calculateSellingPrice } from './types';
import type { PricingTable, PricingTableFormData, GradePricing, TradeGrade, PricingTableStatus } from './types';
interface PricingTableFormProps {
mode: 'create' | 'edit';
initialData?: PricingTable;
}
const GRADE_OPTIONS: TradeGrade[] = ['A등급', 'B등급', 'C등급', 'D등급'];
export function PricingTableForm({ mode, initialData }: PricingTableFormProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const { canCreate, canUpdate, canDelete } = usePermission();
const [isSaving, setIsSaving] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isEdit = mode === 'edit';
// ===== 폼 상태 =====
const [itemCode, setItemCode] = useState(initialData?.itemCode ?? '');
const [itemType, setItemType] = useState(initialData?.itemType ?? '');
const [itemName, setItemName] = useState(initialData?.itemName ?? '');
const [specification, setSpecification] = useState(initialData?.specification ?? '');
const [unit, setUnit] = useState(initialData?.unit ?? '');
const [status, setStatus] = useState<PricingTableStatus>(initialData?.status ?? '사용');
const [purchasePrice, setPurchasePrice] = useState(initialData?.purchasePrice ?? 0);
const [processingCost, setProcessingCost] = useState(initialData?.processingCost ?? 0);
const [gradePricings, setGradePricings] = useState<GradePricing[]>(
initialData?.gradePricings ?? [
{ id: `gp-new-1`, grade: 'A등급', marginRate: 50.0, sellingPrice: 0, note: '' },
]
);
// ===== 판매단가 계산 =====
const recalcSellingPrices = useCallback(
(newPurchasePrice: number, newProcessingCost: number, pricings: GradePricing[]) => {
return pricings.map((gp) => ({
...gp,
sellingPrice: calculateSellingPrice(newPurchasePrice, gp.marginRate, newProcessingCost),
}));
},
[]
);
const handlePurchasePriceChange = (value: string) => {
const num = parseInt(value, 10) || 0;
setPurchasePrice(num);
setGradePricings((prev) => recalcSellingPrices(num, processingCost, prev));
};
const handleProcessingCostChange = (value: string) => {
const num = parseInt(value, 10) || 0;
setProcessingCost(num);
setGradePricings((prev) => recalcSellingPrices(purchasePrice, num, prev));
};
const handleGradeChange = (index: number, grade: TradeGrade) => {
setGradePricings((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], grade };
return updated;
});
};
const handleMarginRateChange = (index: number, value: string) => {
const rate = parseFloat(value) || 0;
setGradePricings((prev) => {
const updated = [...prev];
updated[index] = {
...updated[index],
marginRate: rate,
sellingPrice: calculateSellingPrice(purchasePrice, rate, processingCost),
};
return updated;
});
};
const handleNoteChange = (index: number, note: string) => {
setGradePricings((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], note };
return updated;
});
};
const handleAddRow = () => {
const usedGrades = gradePricings.map((gp) => gp.grade);
const nextGrade = GRADE_OPTIONS.find((g) => !usedGrades.includes(g)) ?? 'A등급';
setGradePricings((prev) => [
...prev,
{
id: `gp-new-${Date.now()}`,
grade: nextGrade,
marginRate: 50.0,
sellingPrice: calculateSellingPrice(purchasePrice, 50.0, processingCost),
note: '',
},
]);
};
const handleRemoveRow = (index: number) => {
setGradePricings((prev) => prev.filter((_, i) => i !== index));
};
// ===== 저장 =====
const handleSave = async () => {
if (!isEdit && !itemCode.trim()) {
toast.error('품목코드를 입력해주세요.');
return;
}
if (!isEdit && !itemName.trim()) {
toast.error('품목명을 입력해주세요.');
return;
}
if (gradePricings.length === 0) {
toast.error('거래등급별 판매단가를 최소 1개 이상 등록해주세요.');
return;
}
setIsSaving(true);
try {
const formData: PricingTableFormData = {
itemCode: isEdit ? initialData!.itemCode : itemCode,
itemType: isEdit ? initialData!.itemType : itemType,
itemName: isEdit ? initialData!.itemName : itemName,
specification: isEdit ? initialData!.specification : specification,
unit: isEdit ? initialData!.unit : unit,
purchasePrice,
processingCost,
status,
gradePricings,
};
const result = isEdit
? await updatePricingTable(initialData!.id, formData)
: await createPricingTable(formData);
if (result.success) {
toast.success(isEdit ? '단가표가 수정되었습니다.' : '단가표가 등록되었습니다.');
router.push('/ko/master-data/pricing-table-management');
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
// ===== 삭제 =====
const handleDeleteConfirm = useCallback(async () => {
if (!initialData) return;
setIsDeleting(true);
try {
const result = await deletePricingTable(initialData.id);
if (result.success) {
toast.success('단가표가 삭제되었습니다.');
router.push('/ko/master-data/pricing-table-management');
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsDeleting(false);
setDeleteDialogOpen(false);
}
}, [initialData, router]);
const handleList = () => {
router.push('/ko/master-data/pricing-table-management');
};
const formatNumber = (num: number) => num.toLocaleString('ko-KR');
return (
<PageLayout>
<PageHeader
title={isEdit ? '단가표 상세' : '단가표 등록'}
description={isEdit ? '단가표 상세를 관리합니다' : '새 단가표를 등록합니다'}
/>
<div className="space-y-6 pb-24">
{/* 기본 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
{/* Row 1: 단가번호, 품목코드, 품목유형, 품목명 */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6">
<div className="space-y-2">
<Label></Label>
<Input
value={isEdit ? initialData?.pricingCode ?? '' : '자동생성'}
disabled
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={isEdit ? initialData?.itemCode ?? '' : itemCode}
onChange={isEdit ? undefined : (e) => setItemCode(e.target.value)}
disabled={isEdit}
placeholder={isEdit ? undefined : '품목코드 입력'}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={isEdit ? initialData?.itemType ?? '' : itemType}
onChange={isEdit ? undefined : (e) => setItemType(e.target.value)}
disabled={isEdit}
placeholder={isEdit ? undefined : '품목유형 입력'}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={isEdit ? initialData?.itemName ?? '' : itemName}
onChange={isEdit ? undefined : (e) => setItemName(e.target.value)}
disabled={isEdit}
placeholder={isEdit ? undefined : '품목명 입력'}
/>
</div>
</div>
{/* Row 2: 규격, 단위, 상태, 작성자 */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6 mt-6">
<div className="space-y-2">
<Label></Label>
<Input
value={isEdit ? initialData?.specification ?? '' : specification}
onChange={isEdit ? undefined : (e) => setSpecification(e.target.value)}
disabled={isEdit}
placeholder={isEdit ? undefined : '규격 입력'}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={isEdit ? initialData?.unit ?? '' : unit}
onChange={isEdit ? undefined : (e) => setUnit(e.target.value)}
disabled={isEdit}
placeholder={isEdit ? undefined : '단위 입력'}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select
key={`status-${status}`}
value={status}
onValueChange={(v) => setStatus(v as PricingTableStatus)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="사용"></SelectItem>
<SelectItem value="미사용"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={isEdit ? initialData?.author ?? '' : '현재사용자'}
disabled
/>
</div>
</div>
{/* Row 3: 변경일 */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6 mt-6">
<div className="space-y-2">
<Label></Label>
<Input
value={isEdit ? initialData?.changedDate ?? '' : new Date().toISOString().split('T')[0]}
disabled
/>
</div>
</div>
</CardContent>
</Card>
{/* 단가 정보 */}
<Card>
<CardHeader className="bg-muted/50">
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="pt-6">
{/* 매입단가 / 가공비 */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={purchasePrice || ''}
onChange={(e) => handlePurchasePriceChange(e.target.value)}
placeholder="숫자 입력"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={processingCost || ''}
onChange={(e) => handleProcessingCostChange(e.target.value)}
placeholder="숫자 입력"
/>
</div>
</div>
{/* 거래등급별 판매단가 */}
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/30">
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground w-[140px]">
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-muted-foreground w-[120px]">
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-muted-foreground w-[120px]">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-muted-foreground w-[80px]">
<Button size="sm" variant="outline" onClick={handleAddRow} className="h-7 text-xs">
</Button>
</th>
</tr>
</thead>
<tbody>
{gradePricings.map((gp, index) => (
<tr key={gp.id} className="border-b last:border-b-0">
<td className="px-4 py-2">
<Select
key={`grade-${gp.id}-${gp.grade}`}
value={gp.grade}
onValueChange={(v) => handleGradeChange(index, v as TradeGrade)}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{GRADE_OPTIONS.map((g) => (
<SelectItem key={g} value={g}>
{g}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="px-4 py-2">
<div className="flex items-center gap-1">
<Input
type="number"
step="0.1"
value={gp.marginRate || ''}
onChange={(e) => handleMarginRateChange(index, e.target.value)}
className="h-9 text-right"
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
</td>
<td className="px-4 py-2 text-right font-medium text-sm">
{formatNumber(gp.sellingPrice)}
</td>
<td className="px-4 py-2">
<Input
value={gp.note}
onChange={(e) => handleNoteChange(index, e.target.value)}
placeholder="비고"
className="h-9"
/>
</td>
<td className="px-4 py-2 text-center">
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveRow(index)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-muted-foreground mt-2">
= x (1 + ) + (1 )
</p>
</CardContent>
</Card>
</div>
{/* 하단 액션 버튼 (sticky) */}
<div
className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}
>
<Button variant="outline" onClick={handleList} size="sm" className="md:size-default">
<ArrowLeft className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<div className="flex items-center gap-1 md:gap-2">
{isEdit ? (
<>
{canDelete && (
<Button variant="outline" onClick={() => setDeleteDialogOpen(true)} size="sm" className="md:size-default">
<Trash2 className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
)}
{canUpdate && (
<Button onClick={handleSave} disabled={isSaving} size="sm" className="md:size-default">
<Save className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline">{isSaving ? '저장 중...' : '수정'}</span>
</Button>
)}
</>
) : (
canCreate && (
<Button onClick={handleSave} disabled={isSaving} size="sm" className="md:size-default">
<Save className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline">{isSaving ? '저장 중...' : '등록'}</span>
</Button>
)
)}
</div>
</div>
{/* 삭제 확인 다이얼로그 */}
{isEdit && (
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
description="이 단가표를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
loading={isDeleting}
onConfirm={handleDeleteConfirm}
/>
)}
</PageLayout>
);
}