- 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>
486 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|