feat(WEB): 자재/출고/생산/품질/단가 기능 대폭 개선 및 신규 페이지 추가

자재관리:
- 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장
- 재고현황 컴포넌트 리팩토링

출고관리:
- 출하관리 생성/수정/목록/상세 개선
- 차량배차관리 상세/수정/목록 기능 보강

생산관리:
- 작업지시서 WIP 생산 모달 신규 추가
- 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가
- 작업자화면 기능 대폭 확장 (카드/목록 개선)
- 검사성적서 모달 개선

품질관리:
- 실적보고서 관리 페이지 신규 추가
- 검사관리 문서/타입/목데이터 개선

단가관리:
- 단가배포 페이지 및 컴포넌트 신규 추가
- 단가표 관리 페이지 및 컴포넌트 신규 추가

공통:
- 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard)
- 메뉴 폴링 훅 개선, 레이아웃 수정
- 모바일 줌/패닝 CSS 수정
- locale 유틸 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-04 12:46:19 +09:00
parent 17c16028b1
commit c1b63b850a
70 changed files with 6832 additions and 384 deletions

View File

@@ -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}
/>
);
}

View 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>
);
}

View File

@@ -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}
/>
</>
);
}

View 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 };
}

View File

@@ -0,0 +1,3 @@
export { default as PricingTableListClient } from './PricingTableListClient';
export { PricingTableForm } from './PricingTableForm';
export { PricingTableDetailClient } from './PricingTableDetailClient';

View 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;
}