feat(WEB): 자재/출고/생산/품질/단가 기능 대폭 개선 및 신규 페이지 추가
자재관리: - 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장 - 재고현황 컴포넌트 리팩토링 출고관리: - 출하관리 생성/수정/목록/상세 개선 - 차량배차관리 상세/수정/목록 기능 보강 생산관리: - 작업지시서 WIP 생산 모달 신규 추가 - 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가 - 작업자화면 기능 대폭 확장 (카드/목록 개선) - 검사성적서 모달 개선 품질관리: - 실적보고서 관리 페이지 신규 추가 - 검사관리 문서/타입/목데이터 개선 단가관리: - 단가배포 페이지 및 컴포넌트 신규 추가 - 단가표 관리 페이지 및 컴포넌트 신규 추가 공통: - 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard) - 메뉴 폴링 훅 개선, 레이아웃 수정 - 모바일 줌/패닝 CSS 수정 - locale 유틸 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PricingTableForm } from './PricingTableForm';
|
||||
import { getPricingTableById } from './actions';
|
||||
import type { PricingTable } from './types';
|
||||
import { DetailPageSkeleton } from '@/components/ui/skeleton';
|
||||
import { ErrorCard } from '@/components/ui/error-card';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface PricingTableDetailClientProps {
|
||||
pricingTableId?: string;
|
||||
}
|
||||
|
||||
const BASE_PATH = '/ko/master-data/pricing-table-management';
|
||||
|
||||
export function PricingTableDetailClient({ pricingTableId }: PricingTableDetailClientProps) {
|
||||
const isNewMode = !pricingTableId || pricingTableId === 'new';
|
||||
|
||||
const [data, setData] = useState<PricingTable | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(!isNewMode);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (isNewMode) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await getPricingTableById(pricingTableId!);
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError(result.error || '단가표를 찾을 수 없습니다.');
|
||||
toast.error('단가표를 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('단가표 조회 실패:', err);
|
||||
setError('단가표 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
toast.error('단가표를 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [pricingTableId, isNewMode]);
|
||||
|
||||
if (isLoading) {
|
||||
return <DetailPageSkeleton sections={2} fieldsPerSection={4} />;
|
||||
}
|
||||
|
||||
if (error && !isNewMode) {
|
||||
return (
|
||||
<ErrorCard
|
||||
type="network"
|
||||
title="단가표를 불러올 수 없습니다"
|
||||
description={error}
|
||||
tips={[
|
||||
'해당 단가표가 존재하는지 확인해주세요',
|
||||
'인터넷 연결 상태를 확인해주세요',
|
||||
'잠시 후 다시 시도해주세요',
|
||||
]}
|
||||
homeButtonLabel="목록으로 이동"
|
||||
homeButtonHref={BASE_PATH}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isNewMode) {
|
||||
return <PricingTableForm mode="create" />;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
return <PricingTableForm mode="edit" initialData={data} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorCard
|
||||
type="not-found"
|
||||
title="단가표를 찾을 수 없습니다"
|
||||
description="요청하신 단가표 정보가 존재하지 않습니다."
|
||||
homeButtonLabel="목록으로 이동"
|
||||
homeButtonHref={BASE_PATH}
|
||||
/>
|
||||
);
|
||||
}
|
||||
485
src/components/pricing-table-management/PricingTableForm.tsx
Normal file
485
src/components/pricing-table-management/PricingTableForm.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
'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 '@/store/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-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 flex items-center justify-between`}
|
||||
>
|
||||
<Button variant="outline" onClick={handleList}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEdit ? (
|
||||
<>
|
||||
{canDelete && (
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(true)}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
{canUpdate && (
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isSaving ? '저장 중...' : '수정'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
canCreate && (
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isSaving ? '저장 중...' : '등록'}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
{isEdit && (
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
description="이 단가표를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||
loading={isDeleting}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DollarSign, Plus } from 'lucide-react';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { toast } from 'sonner';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import type { PricingTable, TradeGrade } from './types';
|
||||
import {
|
||||
getPricingTableList,
|
||||
getPricingTableStats,
|
||||
deletePricingTable,
|
||||
deletePricingTables,
|
||||
} from './actions';
|
||||
|
||||
export default function PricingTableListClient() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 상태 =====
|
||||
const [allItems, setAllItems] = useState<PricingTable[]>([]);
|
||||
const [stats, setStats] = useState({ total: 0, active: 0, inactive: 0 });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
|
||||
// 날짜 범위
|
||||
const [startDate, setStartDate] = useState('2025-01-01');
|
||||
const [endDate, setEndDate] = useState('2025-12-31');
|
||||
|
||||
// 검색어
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 거래등급 필터
|
||||
const [selectedGrade, setSelectedGrade] = useState<TradeGrade>('A등급');
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getPricingTableList({ size: 1000 }),
|
||||
getPricingTableStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setAllItems(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(item: PricingTable) => {
|
||||
router.push(`/ko/master-data/pricing-table-management/${item.id}?mode=view`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push('/ko/master-data/pricing-table-management?mode=new');
|
||||
}, [router]);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deletePricingTable(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('단가표가 삭제되었습니다.');
|
||||
setAllItems((prev) => prev.filter((p) => p.id !== deleteTargetId));
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
|
||||
const handleBulkDelete = useCallback(
|
||||
async (selectedIds: string[]) => {
|
||||
if (selectedIds.length === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deletePricingTables(selectedIds);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
|
||||
await loadData();
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('일괄 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[loadData]
|
||||
);
|
||||
|
||||
// 해당 거래등급의 마진율/판매단가 가져오기
|
||||
const getGradePricing = useCallback(
|
||||
(item: PricingTable) => {
|
||||
return item.gradePricings.find((gp) => gp.grade === selectedGrade);
|
||||
},
|
||||
[selectedGrade]
|
||||
);
|
||||
|
||||
// 숫자 포맷
|
||||
const formatNumber = (num: number) => num.toLocaleString('ko-KR');
|
||||
|
||||
// ===== Config =====
|
||||
const config: UniversalListConfig<PricingTable> = useMemo(
|
||||
() => ({
|
||||
title: '단가표 목록',
|
||||
icon: DollarSign,
|
||||
basePath: '/master-data/pricing-table-management',
|
||||
|
||||
idField: 'id',
|
||||
|
||||
actions: {
|
||||
getList: async () => {
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getPricingTableList({ size: 1000 }),
|
||||
getPricingTableStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setAllItems(listResult.data.items);
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: listResult.data.items,
|
||||
totalCount: listResult.data.items.length,
|
||||
totalPages: 1,
|
||||
};
|
||||
}
|
||||
return { success: false, error: '데이터 로드에 실패했습니다.' };
|
||||
} catch {
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deletePricingTable(id);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
deleteBulk: async (ids: string[]) => {
|
||||
const result = await deletePricingTables(ids);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
columns: [
|
||||
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'pricingCode', label: '단가번호', className: 'w-[100px]' },
|
||||
{ key: 'itemCode', label: '품목코드', className: 'w-[100px]' },
|
||||
{ key: 'itemType', label: '품목유형', className: 'w-[80px]' },
|
||||
{ key: 'itemName', label: '품목명', className: 'min-w-[120px]' },
|
||||
{ key: 'specification', label: '규격', className: 'w-[70px]' },
|
||||
{ key: 'unit', label: '단위', className: 'w-[50px] text-center' },
|
||||
{ key: 'purchasePrice', label: '매입단가', className: 'w-[90px] text-right' },
|
||||
{ key: 'processingCost', label: '가공비', className: 'w-[80px] text-right' },
|
||||
{ key: 'marginRate', label: '마진율', className: 'w-[70px] text-right' },
|
||||
{ key: 'sellingPrice', label: '판매단가', className: 'w-[90px] text-right' },
|
||||
{ key: 'status', label: '상태', className: 'w-[70px] text-center' },
|
||||
{ key: 'author', label: '작성자', className: 'w-[80px] text-center' },
|
||||
{ key: 'changedDate', label: '변경일', className: 'w-[100px] text-center' },
|
||||
],
|
||||
|
||||
clientSideFiltering: true,
|
||||
itemsPerPage: 20,
|
||||
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single' as const,
|
||||
options: [
|
||||
{ value: '사용', label: '사용' },
|
||||
{ value: '미사용', label: '미사용' },
|
||||
],
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
],
|
||||
initialFilters: { status: '' },
|
||||
|
||||
customFilterFn: (items: PricingTable[], filterValues: Record<string, string | string[]>) => {
|
||||
const statusFilter = filterValues.status as string;
|
||||
if (!statusFilter) return items;
|
||||
return items.filter((item) => item.status === statusFilter);
|
||||
},
|
||||
|
||||
searchFilter: (item, searchValue) => {
|
||||
if (!searchValue || !searchValue.trim()) return true;
|
||||
const search = searchValue.toLowerCase().trim();
|
||||
return (
|
||||
item.pricingCode.toLowerCase().includes(search) ||
|
||||
item.itemCode.toLowerCase().includes(search) ||
|
||||
item.itemName.toLowerCase().includes(search) ||
|
||||
item.itemType.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
createButton: {
|
||||
label: '단가표 등록',
|
||||
onClick: handleCreate,
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
onBulkDelete: handleBulkDelete,
|
||||
|
||||
// 테이블 위 커스텀 영역 (거래등급 배지 필터)
|
||||
renderCustomHeader: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
{(['A등급', 'B등급', 'C등급', 'D등급'] as TradeGrade[]).map((grade) => (
|
||||
<Badge
|
||||
key={grade}
|
||||
variant={selectedGrade === grade ? 'default' : 'outline'}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSelectedGrade(grade)}
|
||||
>
|
||||
{grade}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
|
||||
renderTableRow: (
|
||||
item: PricingTable,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<PricingTable>
|
||||
) => {
|
||||
const gp = getGradePricing(item);
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{item.pricingCode}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{item.itemCode}</TableCell>
|
||||
<TableCell>{item.itemType}</TableCell>
|
||||
<TableCell className="font-medium">{item.itemName}</TableCell>
|
||||
<TableCell>{item.specification}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.purchasePrice)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.processingCost)}</TableCell>
|
||||
<TableCell className="text-right">{gp ? `${gp.marginRate}%` : '-'}</TableCell>
|
||||
<TableCell className="text-right">{gp ? formatNumber(gp.sellingPrice) : '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={item.status === '사용' ? 'default' : 'secondary'}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.author}</TableCell>
|
||||
<TableCell className="text-center">{item.changedDate}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
|
||||
renderMobileCard: (
|
||||
item: PricingTable,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<PricingTable>
|
||||
) => {
|
||||
const gp = getGradePricing(item);
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggleSelection={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
#{globalIndex}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{item.pricingCode}
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
title={item.itemName}
|
||||
statusBadge={
|
||||
<Badge variant={item.status === '사용' ? 'default' : 'secondary'}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="품목코드" value={item.itemCode} />
|
||||
<InfoField label="품목유형" value={item.itemType} />
|
||||
<InfoField label="매입단가" value={formatNumber(item.purchasePrice)} />
|
||||
<InfoField label="가공비" value={formatNumber(item.processingCost)} />
|
||||
<InfoField label="마진율" value={gp ? `${gp.marginRate}%` : '-'} />
|
||||
<InfoField label="판매단가" value={gp ? formatNumber(gp.sellingPrice) : '-'} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[
|
||||
handleCreate,
|
||||
handleRowClick,
|
||||
handleBulkDelete,
|
||||
startDate,
|
||||
endDate,
|
||||
searchQuery,
|
||||
selectedGrade,
|
||||
getGradePricing,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage config={config} initialData={allItems} onSearchChange={setSearchQuery} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
description="선택한 단가표를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||
loading={isLoading}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
294
src/components/pricing-table-management/actions.ts
Normal file
294
src/components/pricing-table-management/actions.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
'use server';
|
||||
|
||||
import type { PricingTable, PricingTableFormData, TradeGrade } from './types';
|
||||
|
||||
// ============================================================================
|
||||
// 목데이터
|
||||
// ============================================================================
|
||||
|
||||
const MOCK_PRICING_TABLES: PricingTable[] = [
|
||||
{
|
||||
id: '1',
|
||||
pricingCode: '123123',
|
||||
itemCode: '123123',
|
||||
itemType: '반제품',
|
||||
itemName: '품목명A',
|
||||
specification: 'ST',
|
||||
unit: 'EA',
|
||||
purchasePrice: 10000,
|
||||
processingCost: 5000,
|
||||
status: '사용',
|
||||
author: '홍길동',
|
||||
changedDate: '2026-01-15',
|
||||
gradePricings: [
|
||||
{ id: 'gp-1-1', grade: 'A등급', marginRate: 50.0, sellingPrice: 20000, note: '' },
|
||||
{ id: 'gp-1-2', grade: 'B등급', marginRate: 50.0, sellingPrice: 20000, note: '' },
|
||||
{ id: 'gp-1-3', grade: 'C등급', marginRate: 50.0, sellingPrice: 20000, note: '' },
|
||||
{ id: 'gp-1-4', grade: 'D등급', marginRate: 50.0, sellingPrice: 20000, note: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
pricingCode: '123124',
|
||||
itemCode: '123124',
|
||||
itemType: '완제품',
|
||||
itemName: '품목명B',
|
||||
specification: '규격B',
|
||||
unit: 'SET',
|
||||
purchasePrice: 8000,
|
||||
processingCost: 3000,
|
||||
status: '사용',
|
||||
author: '김철수',
|
||||
changedDate: '2026-01-20',
|
||||
gradePricings: [
|
||||
{ id: 'gp-2-1', grade: 'A등급', marginRate: 40.0, sellingPrice: 14000, note: '' },
|
||||
{ id: 'gp-2-2', grade: 'B등급', marginRate: 35.0, sellingPrice: 13000, note: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
pricingCode: '123125',
|
||||
itemCode: '123125',
|
||||
itemType: '반제품',
|
||||
itemName: '품목명C',
|
||||
specification: 'ST',
|
||||
unit: 'EA',
|
||||
purchasePrice: 15000,
|
||||
processingCost: 5000,
|
||||
status: '미사용',
|
||||
author: '이영희',
|
||||
changedDate: '2026-01-10',
|
||||
gradePricings: [
|
||||
{ id: 'gp-3-1', grade: 'A등급', marginRate: 50.0, sellingPrice: 27000, note: '' },
|
||||
{ id: 'gp-3-2', grade: 'B등급', marginRate: 45.0, sellingPrice: 26000, note: '' },
|
||||
{ id: 'gp-3-3', grade: 'C등급', marginRate: 40.0, sellingPrice: 26000, note: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
pricingCode: '123126',
|
||||
itemCode: '123126',
|
||||
itemType: '원자재',
|
||||
itemName: '품목명D',
|
||||
specification: 'AL',
|
||||
unit: 'KG',
|
||||
purchasePrice: 5000,
|
||||
processingCost: 2000,
|
||||
status: '사용',
|
||||
author: '박민수',
|
||||
changedDate: '2026-02-01',
|
||||
gradePricings: [
|
||||
{ id: 'gp-4-1', grade: 'A등급', marginRate: 60.0, sellingPrice: 10000, note: '' },
|
||||
{ id: 'gp-4-2', grade: 'B등급', marginRate: 55.0, sellingPrice: 9000, note: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
pricingCode: '123127',
|
||||
itemCode: '123127',
|
||||
itemType: '완제품',
|
||||
itemName: '품목명E',
|
||||
specification: '규격E',
|
||||
unit: 'SET',
|
||||
purchasePrice: 20000,
|
||||
processingCost: 8000,
|
||||
status: '사용',
|
||||
author: '홍길동',
|
||||
changedDate: '2026-01-25',
|
||||
gradePricings: [
|
||||
{ id: 'gp-5-1', grade: 'A등급', marginRate: 50.0, sellingPrice: 38000, note: '' },
|
||||
{ id: 'gp-5-2', grade: 'B등급', marginRate: 45.0, sellingPrice: 37000, note: '' },
|
||||
{ id: 'gp-5-3', grade: 'C등급', marginRate: 40.0, sellingPrice: 36000, note: '' },
|
||||
{ id: 'gp-5-4', grade: 'D등급', marginRate: 35.0, sellingPrice: 35000, note: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
pricingCode: '123128',
|
||||
itemCode: '123128',
|
||||
itemType: '반제품',
|
||||
itemName: '품목명F',
|
||||
specification: 'ST',
|
||||
unit: 'EA',
|
||||
purchasePrice: 12000,
|
||||
processingCost: 4000,
|
||||
status: '사용',
|
||||
author: '김철수',
|
||||
changedDate: '2026-01-18',
|
||||
gradePricings: [
|
||||
{ id: 'gp-6-1', grade: 'A등급', marginRate: 50.0, sellingPrice: 22000, note: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
pricingCode: '123129',
|
||||
itemCode: '123129',
|
||||
itemType: '원자재',
|
||||
itemName: '품목명G',
|
||||
specification: 'SUS',
|
||||
unit: 'KG',
|
||||
purchasePrice: 7000,
|
||||
processingCost: 3000,
|
||||
status: '사용',
|
||||
author: '이영희',
|
||||
changedDate: '2026-01-22',
|
||||
gradePricings: [
|
||||
{ id: 'gp-7-1', grade: 'A등급', marginRate: 45.0, sellingPrice: 13000, note: '' },
|
||||
{ id: 'gp-7-2', grade: 'B등급', marginRate: 40.0, sellingPrice: 12000, note: '' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// API 함수 (목데이터 기반)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 단가표 목록 조회
|
||||
*/
|
||||
export async function getPricingTableList(params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
q?: string;
|
||||
status?: string;
|
||||
grade?: TradeGrade;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: { items: PricingTable[]; total: number };
|
||||
error?: string;
|
||||
}> {
|
||||
let items = [...MOCK_PRICING_TABLES];
|
||||
|
||||
// 상태 필터
|
||||
if (params?.status) {
|
||||
items = items.filter((item) => item.status === params.status);
|
||||
}
|
||||
|
||||
// 거래등급 필터 (해당 등급의 gradePricing이 있는 항목만)
|
||||
if (params?.grade) {
|
||||
items = items.filter((item) =>
|
||||
item.gradePricings.some((gp) => gp.grade === params.grade)
|
||||
);
|
||||
}
|
||||
|
||||
// 검색
|
||||
if (params?.q) {
|
||||
const q = params.q.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.pricingCode.toLowerCase().includes(q) ||
|
||||
item.itemCode.toLowerCase().includes(q) ||
|
||||
item.itemName.toLowerCase().includes(q) ||
|
||||
item.itemType.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { items, total: items.length },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가표 통계
|
||||
*/
|
||||
export async function getPricingTableStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: { total: number; active: number; inactive: number };
|
||||
error?: string;
|
||||
}> {
|
||||
const total = MOCK_PRICING_TABLES.length;
|
||||
const active = MOCK_PRICING_TABLES.filter((p) => p.status === '사용').length;
|
||||
const inactive = total - active;
|
||||
return { success: true, data: { total, active, inactive } };
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가표 상세 조회
|
||||
*/
|
||||
export async function getPricingTableById(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: PricingTable;
|
||||
error?: string;
|
||||
}> {
|
||||
const item = MOCK_PRICING_TABLES.find((p) => p.id === id);
|
||||
if (!item) {
|
||||
return { success: false, error: '단가표를 찾을 수 없습니다.' };
|
||||
}
|
||||
return { success: true, data: item };
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가표 생성
|
||||
*/
|
||||
export async function createPricingTable(data: PricingTableFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: PricingTable;
|
||||
error?: string;
|
||||
}> {
|
||||
const newItem: PricingTable = {
|
||||
id: String(Date.now()),
|
||||
pricingCode: `PT-${Date.now()}`,
|
||||
itemCode: data.itemCode,
|
||||
itemType: data.itemType,
|
||||
itemName: data.itemName,
|
||||
specification: data.specification,
|
||||
unit: data.unit,
|
||||
purchasePrice: data.purchasePrice,
|
||||
processingCost: data.processingCost,
|
||||
status: data.status,
|
||||
author: '현재사용자',
|
||||
changedDate: new Date().toISOString().split('T')[0],
|
||||
gradePricings: data.gradePricings,
|
||||
};
|
||||
return { success: true, data: newItem };
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가표 수정
|
||||
*/
|
||||
export async function updatePricingTable(
|
||||
id: string,
|
||||
data: PricingTableFormData
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: PricingTable;
|
||||
error?: string;
|
||||
}> {
|
||||
const existing = MOCK_PRICING_TABLES.find((p) => p.id === id);
|
||||
if (!existing) {
|
||||
return { success: false, error: '단가표를 찾을 수 없습니다.' };
|
||||
}
|
||||
const updated: PricingTable = {
|
||||
...existing,
|
||||
...data,
|
||||
changedDate: new Date().toISOString().split('T')[0],
|
||||
};
|
||||
return { success: true, data: updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가표 삭제
|
||||
*/
|
||||
export async function deletePricingTable(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const exists = MOCK_PRICING_TABLES.find((p) => p.id === id);
|
||||
if (!exists) {
|
||||
return { success: false, error: '단가표를 찾을 수 없습니다.' };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가표 일괄 삭제
|
||||
*/
|
||||
export async function deletePricingTables(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
return { success: true, deletedCount: ids.length };
|
||||
}
|
||||
|
||||
3
src/components/pricing-table-management/index.ts
Normal file
3
src/components/pricing-table-management/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as PricingTableListClient } from './PricingTableListClient';
|
||||
export { PricingTableForm } from './PricingTableForm';
|
||||
export { PricingTableDetailClient } from './PricingTableDetailClient';
|
||||
57
src/components/pricing-table-management/types.ts
Normal file
57
src/components/pricing-table-management/types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// 거래등급
|
||||
export type TradeGrade = 'A등급' | 'B등급' | 'C등급' | 'D등급';
|
||||
|
||||
// 단가표 상태
|
||||
export type PricingTableStatus = '사용' | '미사용';
|
||||
|
||||
// 거래등급별 판매단가 행
|
||||
export interface GradePricing {
|
||||
id: string;
|
||||
grade: TradeGrade;
|
||||
marginRate: number; // 마진율 (%, 소수점 첫째자리)
|
||||
sellingPrice: number; // 판매단가 (자동계산)
|
||||
note: string; // 비고
|
||||
}
|
||||
|
||||
// 단가표 엔티티
|
||||
export interface PricingTable {
|
||||
id: string;
|
||||
pricingCode: string; // 단가번호
|
||||
itemCode: string; // 품목코드
|
||||
itemType: string; // 품목유형
|
||||
itemName: string; // 품목명
|
||||
specification: string; // 규격
|
||||
unit: string; // 단위
|
||||
purchasePrice: number; // 매입단가
|
||||
processingCost: number; // 가공비
|
||||
status: PricingTableStatus; // 상태
|
||||
author: string; // 작성자
|
||||
changedDate: string; // 변경일
|
||||
gradePricings: GradePricing[]; // 거래등급별 판매단가
|
||||
}
|
||||
|
||||
// 폼 데이터 (등록/수정)
|
||||
export interface PricingTableFormData {
|
||||
itemCode: string;
|
||||
itemType: string;
|
||||
itemName: string;
|
||||
specification: string;
|
||||
unit: string;
|
||||
purchasePrice: number;
|
||||
processingCost: number;
|
||||
status: PricingTableStatus;
|
||||
gradePricings: GradePricing[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 판매단가 계산 유틸리티
|
||||
* 매입단가 × (1 + 마진율/100) + 가공비 → 1천원 이하 절사
|
||||
*/
|
||||
export function calculateSellingPrice(
|
||||
purchasePrice: number,
|
||||
marginRate: number,
|
||||
processingCost: number
|
||||
): number {
|
||||
const raw = purchasePrice * (1 + marginRate / 100) + processingCost;
|
||||
return Math.floor(raw / 1000) * 1000;
|
||||
}
|
||||
Reference in New Issue
Block a user