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,537 @@
/**
* 단가배포 상세/수정 페이지
*
* mode 패턴:
* - view: 상세 조회 (읽기 전용) → 하단: 단가표 보기, 최종확정, 수정
* - edit: 수정 모드 → 하단: 취소, 저장
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft, FileText, CheckCircle2, Edit3, Save, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useMenuStore } from '@/store/menuStore';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import type {
PriceDistributionDetail as DetailType,
PriceDistributionFormData,
DistributionStatus,
} from './types';
import {
DISTRIBUTION_STATUS_LABELS,
DISTRIBUTION_STATUS_STYLES,
TRADE_GRADE_OPTIONS,
} from './types';
import {
getPriceDistributionById,
updatePriceDistribution,
finalizePriceDistribution,
} from './actions';
import { PriceDistributionDocumentModal } from './PriceDistributionDocumentModal';
import { usePermission } from '@/hooks/usePermission';
interface Props {
id: string;
mode?: 'view' | 'edit';
}
export function PriceDistributionDetail({ id, mode: propMode }: Props) {
const router = useRouter();
const searchParams = useSearchParams();
const { canUpdate, canApprove } = usePermission();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const mode = propMode || (searchParams.get('mode') as 'view' | 'edit') || 'view';
const isEditMode = mode === 'edit';
const isViewMode = mode === 'view';
const [detail, setDetail] = useState<DetailType | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [showFinalizeDialog, setShowFinalizeDialog] = useState(false);
const [showDocumentModal, setShowDocumentModal] = useState(false);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [gradeFilter, setGradeFilter] = useState<string>('all');
// 수정 가능 폼 데이터
const [formData, setFormData] = useState<PriceDistributionFormData>({
distributionName: '',
documentNo: '',
effectiveDate: '',
officePhone: '',
orderPhone: '',
});
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getPriceDistributionById(id);
if (result.success && result.data) {
setDetail(result.data);
setFormData({
distributionName: result.data.distributionName,
documentNo: result.data.documentNo,
effectiveDate: result.data.effectiveDate,
officePhone: result.data.officePhone,
orderPhone: result.data.orderPhone,
});
} else {
toast.error(result.error || '데이터를 불러올 수 없습니다.');
}
} finally {
setIsLoading(false);
}
}, [id]);
useEffect(() => {
loadData();
}, [loadData]);
// 폼 값 변경
const handleChange = (field: keyof PriceDistributionFormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// 저장
const handleSave = async () => {
setIsSaving(true);
try {
const result = await updatePriceDistribution(id, formData);
if (result.success) {
toast.success('저장되었습니다.');
router.push(`/master-data/price-distribution/${id}`);
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
// 최종확정
const handleFinalize = async () => {
try {
const result = await finalizePriceDistribution(id);
if (result.success) {
toast.success('최종확정 되었습니다.');
setShowFinalizeDialog(false);
loadData();
} else {
toast.error(result.error || '최종확정에 실패했습니다.');
}
} catch {
toast.error('최종확정 중 오류가 발생했습니다.');
}
};
// 수정 모드 전환
const handleEditMode = () => {
router.push(`/master-data/price-distribution/${id}/edit`);
};
// 취소
const handleCancel = () => {
router.push(`/master-data/price-distribution/${id}`);
};
// 목록으로
const handleBack = () => {
router.push('/master-data/price-distribution');
};
// 체크박스 전체 선택/해제
const handleSelectAll = (checked: boolean) => {
if (!detail) return;
if (checked) {
setSelectedItems(new Set(detail.items.map((item) => item.id)));
} else {
setSelectedItems(new Set());
}
};
// 체크박스 개별 선택
const handleSelectItem = (itemId: string, checked: boolean) => {
setSelectedItems((prev) => {
const next = new Set(prev);
if (checked) {
next.add(itemId);
} else {
next.delete(itemId);
}
return next;
});
};
// 상태 뱃지
const renderStatusBadge = (status: DistributionStatus) => {
const style = DISTRIBUTION_STATUS_STYLES[status];
const label = DISTRIBUTION_STATUS_LABELS[status];
return (
<Badge variant="outline" className={`${style.bg} ${style.text} ${style.border}`}>
{label}
</Badge>
);
};
// 금액 포맷
const formatPrice = (price?: number) => {
if (price === undefined || price === null) return '-';
return price.toLocaleString();
};
if (isLoading) {
return (
<PageLayout>
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground"> ...</p>
</div>
</PageLayout>
);
}
if (!detail) {
return (
<PageLayout>
<div className="flex flex-col items-center justify-center h-64 gap-4">
<p className="text-muted-foreground"> .</p>
<Button variant="outline" onClick={handleBack}></Button>
</div>
</PageLayout>
);
}
const isAllSelected = detail.items.length > 0 && selectedItems.size === detail.items.length;
return (
<PageLayout>
<PageHeader
title={`단가배포 ${isEditMode ? '수정' : '상세'}`}
description={`${detail.distributionName} (${detail.distributionNo})`}
icon={FileText}
/>
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 단가배포번호 */}
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"></Label>
<p className="text-sm font-medium">{detail.distributionNo}</p>
</div>
{/* 단가배포명 */}
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"></Label>
{isEditMode ? (
<Input
value={formData.distributionName}
onChange={(e) => handleChange('distributionName', e.target.value)}
className="h-8 text-sm"
/>
) : (
<p className="text-sm font-medium">{detail.distributionName}</p>
)}
</div>
{/* 상태 */}
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"></Label>
<Select value={detail.status} disabled>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="initial">{DISTRIBUTION_STATUS_LABELS.initial}</SelectItem>
<SelectItem value="revision">{DISTRIBUTION_STATUS_LABELS.revision}</SelectItem>
<SelectItem value="finalized">{DISTRIBUTION_STATUS_LABELS.finalized}</SelectItem>
</SelectContent>
</Select>
</div>
{/* 작성자 */}
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"></Label>
<p className="text-sm font-medium">{detail.author}</p>
</div>
{/* 등록일 */}
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"></Label>
<p className="text-sm font-medium">{detail.createdAt}</p>
</div>
{/* 적용시점 */}
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"></Label>
{isEditMode ? (
<Input
type="date"
value={formData.effectiveDate}
onChange={(e) => handleChange('effectiveDate', e.target.value)}
className="h-8 text-sm"
/>
) : (
<p className="text-sm font-medium">
{detail.effectiveDate ? new Date(detail.effectiveDate).toLocaleDateString('ko-KR') : '-'}
</p>
)}
</div>
{/* 사무실 연락처 */}
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"> </Label>
{isEditMode ? (
<Input
value={formData.officePhone}
onChange={(e) => handleChange('officePhone', e.target.value)}
className="h-8 text-sm"
placeholder="02-0000-0000"
/>
) : (
<p className="text-sm font-medium">{detail.officePhone || '-'}</p>
)}
</div>
{/* 발주전용 연락처 */}
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"> </Label>
{isEditMode ? (
<Input
value={formData.orderPhone}
onChange={(e) => handleChange('orderPhone', e.target.value)}
className="h-8 text-sm"
placeholder="02-0000-0000"
/>
) : (
<p className="text-sm font-medium">{detail.orderPhone || '-'}</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* 단가 목록 테이블 */}
<Card className="mt-4">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
<div className="flex items-center gap-3">
<Select value={gradeFilter} onValueChange={setGradeFilter}>
<SelectTrigger className="h-8 w-[120px] text-sm">
<SelectValue placeholder="등급" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{TRADE_GRADE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">
{detail.items.length}
</span>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px] text-center">
<Checkbox
checked={isAllSelected}
onCheckedChange={(checked) => handleSelectAll(!!checked)}
/>
</TableHead>
<TableHead className="w-[50px] text-center"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[60px]"></TableHead>
<TableHead className="min-w-[100px] text-right"></TableHead>
<TableHead className="min-w-[80px] text-right"></TableHead>
<TableHead className="min-w-[70px] text-right"></TableHead>
<TableHead className="min-w-[100px] text-right"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detail.items.length === 0 ? (
<TableRow>
<TableCell colSpan={15} className="text-center text-muted-foreground py-8">
.
</TableCell>
</TableRow>
) : (
detail.items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">
<Checkbox
checked={selectedItems.has(item.id)}
onCheckedChange={(checked) => handleSelectItem(item.id, !!checked)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">
{index + 1}
</TableCell>
<TableCell>{item.pricingCode}</TableCell>
<TableCell>{item.itemCode}</TableCell>
<TableCell className="text-muted-foreground">{item.itemType}</TableCell>
<TableCell className="font-medium">{item.itemName}</TableCell>
<TableCell className="text-muted-foreground">{item.specification}</TableCell>
<TableCell className="text-muted-foreground">{item.unit}</TableCell>
<TableCell className="text-right font-mono">{formatPrice(item.purchasePrice)}</TableCell>
<TableCell className="text-right font-mono">{formatPrice(item.processingCost)}</TableCell>
<TableCell className="text-right font-mono">{item.marginRate}%</TableCell>
<TableCell className="text-right font-mono font-semibold">{formatPrice(item.salesPrice)}</TableCell>
<TableCell>
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
{item.status}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{item.author}</TableCell>
<TableCell className="text-muted-foreground">{item.changedDate}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* 하단 버튼 (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`}>
{/* 왼쪽: 목록으로 / 취소 */}
{isViewMode ? (
<Button variant="outline" onClick={handleBack} size="sm" className="md:size-default">
<ArrowLeft className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
) : (
<Button variant="outline" onClick={handleCancel} disabled={isSaving} size="sm" className="md:size-default">
<X className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
)}
{/* 오른쪽: 액션 버튼들 */}
<div className="flex items-center gap-1 md:gap-2">
{isViewMode && (
<>
<Button
variant="outline"
size="sm"
className="md:size-default"
onClick={() => setShowDocumentModal(true)}
>
<FileText className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"> </span>
</Button>
{canApprove && (
<Button
variant="outline"
size="sm"
className="md:size-default"
onClick={() => setShowFinalizeDialog(true)}
disabled={detail.status === 'finalized'}
>
<CheckCircle2 className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
)}
{canUpdate && (
<Button
onClick={handleEditMode}
size="sm"
className="md:size-default"
disabled={detail.status === 'finalized'}
>
<Edit3 className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
)}
</>
)}
{isEditMode && canUpdate && (
<Button onClick={handleSave} disabled={isSaving} size="sm" className="md:size-default">
<Save className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline">{isSaving ? '저장 중...' : '저장'}</span>
</Button>
)}
</div>
</div>
{/* 최종확정 다이얼로그 */}
<AlertDialog open={showFinalizeDialog} onOpenChange={setShowFinalizeDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleFinalize}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 단가표 보기 모달 */}
<PriceDistributionDocumentModal
open={showDocumentModal}
onOpenChange={setShowDocumentModal}
detail={detail}
/>
</PageLayout>
);
}
export default PriceDistributionDetail;

View File

@@ -0,0 +1,157 @@
/**
* 단가표 보기 모달 (문서 스타일)
*
* DocumentViewer 래퍼 사용 (인쇄/공유/닫기)
* DocumentHeader + ApprovalLine 활용
* 경동기업 자재단가 조정 문서
*/
'use client';
import { DocumentViewer } from '@/components/document-system/viewer/DocumentViewer';
import { DocumentHeader } from '@/components/document-system/components/DocumentHeader';
import { ConstructionApprovalTable } from '@/components/document-system/components/ConstructionApprovalTable';
import type { PriceDistributionDetail } from './types';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
detail: PriceDistributionDetail;
}
export function PriceDistributionDocumentModal({ open, onOpenChange, detail }: Props) {
const effectiveDate = detail.effectiveDate
? new Date(detail.effectiveDate)
: new Date();
const year = effectiveDate.getFullYear();
const month = effectiveDate.getMonth() + 1;
const day = effectiveDate.getDate();
return (
<DocumentViewer
title="문서 상세_단가표_팝업"
subtitle="단가표 보기"
preset="readonly"
open={open}
onOpenChange={onOpenChange}
>
<div className="bg-white mx-auto" style={{ width: '210mm', minHeight: '297mm', padding: '15mm 20mm' }}>
{/* 문서 헤더 + 결재란 */}
<DocumentHeader
title="경동기업 자재단가 조정"
documentCode={detail.distributionNo}
subtitle={`적용기간: ${year}${month}${day}일 ~`}
layout="construction"
className="pb-4 border-b-2 border-black"
approval={null}
customApproval={
<ConstructionApprovalTable
approvers={{
writer: { name: detail.author },
}}
/>
}
/>
{/* 수신/발신 정보 */}
<div className="border border-gray-300 mb-6 mt-6">
<table className="w-full text-sm">
<tbody>
<tr className="border-b border-gray-300">
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 w-28 font-medium"></td>
<td className="border-r border-gray-300 px-2 py-1"> </td>
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 w-28 font-medium"></td>
<td className="px-2 py-1"></td>
</tr>
<tr className="border-b border-gray-300">
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 font-medium"></td>
<td className="border-r border-gray-300 px-2 py-1">
{year}-{String(month).padStart(2, '0')}-{String(day).padStart(2, '0')}
</td>
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 font-medium"> </td>
<td className="px-2 py-1">{detail.officePhone || '-'}</td>
</tr>
<tr>
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 font-medium"></td>
<td className="border-r border-gray-300 px-2 py-1"></td>
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 font-medium"> </td>
<td className="px-2 py-1">{detail.orderPhone || '-'}</td>
</tr>
</tbody>
</table>
</div>
{/* 안내 문구 */}
<div className="text-sm leading-relaxed space-y-4 mb-8">
<p>
1. .
</p>
<p>
2. () ,
()
.
</p>
<p>
3. ,
<br />
<span className="ml-4">
. {year} {month} {day} ~
</span>
<br />
<span className="ml-4">
.
</span>
</p>
</div>
{/* 단가 테이블 */}
<div className="overflow-x-auto mb-8">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-3 py-2 text-center font-semibold"></th>
<th className="border border-gray-300 px-3 py-2 text-center font-semibold"></th>
<th className="border border-gray-300 px-3 py-2 text-center font-semibold"></th>
<th className="border border-gray-300 px-3 py-2 text-center font-semibold">/T</th>
<th className="border border-gray-300 px-3 py-2 text-center font-semibold">/M</th>
<th className="border border-gray-300 px-3 py-2 text-center font-semibold"></th>
<th className="border border-gray-300 px-3 py-2 text-center font-semibold">
<br />
<span className="text-xs font-normal">(VAT별도)</span>
</th>
</tr>
</thead>
<tbody>
{detail.items.map((item) => (
<tr key={item.id}>
<td className="border border-gray-300 px-3 py-2 text-center">{item.itemType}</td>
<td className="border border-gray-300 px-3 py-2">{item.itemName}</td>
<td className="border border-gray-300 px-3 py-2 text-center">{item.specification}</td>
<td className="border border-gray-300 px-3 py-2 text-center">-</td>
<td className="border border-gray-300 px-3 py-2 text-center">-</td>
<td className="border border-gray-300 px-3 py-2 text-center">{item.unit}</td>
<td className="border border-gray-300 px-3 py-2 text-right font-mono">
{item.salesPrice.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 하단 날짜 및 회사 정보 */}
<div className="text-center mt-12 space-y-2">
<p className="text-sm">
{year} {month} {day}
</p>
<p className="text-lg font-bold">
</p>
</div>
</div>
</DocumentViewer>
);
}
export default PriceDistributionDocumentModal;

View File

@@ -0,0 +1,327 @@
/**
* 단가배포 목록 클라이언트 컴포넌트
*
* UniversalListPage 공통 템플릿 활용
* - 탭 없음, 통계 카드 없음
* - 상태 필터: filterConfig (SELECT 드롭다운)
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, FilePlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
UniversalListPage,
type UniversalListConfig,
type TableColumn,
type FilterFieldConfig,
type SelectionHandlers,
type RowClickHandlers,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { toast } from 'sonner';
import type { PriceDistributionListItem, DistributionStatus } from './types';
import {
DISTRIBUTION_STATUS_LABELS,
DISTRIBUTION_STATUS_STYLES,
} from './types';
import {
getPriceDistributionList,
createPriceDistribution,
deletePriceDistribution,
} from './actions';
export function PriceDistributionList() {
const router = useRouter();
const [data, setData] = useState<PriceDistributionListItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showRegisterDialog, setShowRegisterDialog] = useState(false);
const [isRegistering, setIsRegistering] = useState(false);
const pageSize = 20;
// 날짜 범위 상태 (최근 30일)
const today = new Date();
const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
const [startDate, setStartDate] = useState<string>(thirtyDaysAgo.toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(today.toISOString().split('T')[0]);
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const listResult = await getPriceDistributionList();
if (listResult.success && listResult.data) {
setData(listResult.data.items);
}
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
// 검색 필터
const searchFilter = (item: PriceDistributionListItem, search: string) => {
const s = search.toLowerCase();
return (
item.distributionNo.toLowerCase().includes(s) ||
item.distributionName.toLowerCase().includes(s) ||
item.author.toLowerCase().includes(s)
);
};
// 상태 Badge 렌더링
const renderStatusBadge = (status: DistributionStatus) => {
const style = DISTRIBUTION_STATUS_STYLES[status];
const label = DISTRIBUTION_STATUS_LABELS[status];
return (
<Badge
variant="outline"
className={`${style.bg} ${style.text} ${style.border}`}
>
{label}
</Badge>
);
};
// 등록 핸들러
const handleRegister = async () => {
setIsRegistering(true);
try {
const result = await createPriceDistribution();
if (result.success && result.data) {
toast.success('단가배포가 등록되었습니다.');
setShowRegisterDialog(false);
router.push(`/master-data/price-distribution/${result.data.id}`);
} else {
toast.error(result.error || '등록에 실패했습니다.');
}
} catch {
toast.error('등록 중 오류가 발생했습니다.');
} finally {
setIsRegistering(false);
}
};
// 행 클릭 → 상세
const handleRowClick = (item: PriceDistributionListItem) => {
router.push(`/master-data/price-distribution/${item.id}`);
};
// 상태 필터 설정
const filterConfig: FilterFieldConfig[] = [
{
key: 'status',
label: '상태',
type: 'single',
options: [
{ value: 'initial', label: '최초작성' },
{ value: 'revision', label: '보이수정' },
{ value: 'finalized', label: '최종확정' },
],
},
];
// 커스텀 필터 함수
const customFilterFn = (items: PriceDistributionListItem[], filterValues: Record<string, string | string[]>) => {
const status = filterValues.status as string;
if (!status || status === '') return items;
return items.filter((item) => item.status === status);
};
// 테이블 컬럼
const tableColumns: TableColumn[] = [
{ key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' },
{ key: 'distributionNo', label: '단가배포번호', className: 'min-w-[120px]' },
{ key: 'distributionName', label: '단가배포명', className: 'min-w-[150px]' },
{ key: 'status', label: '상태', className: 'min-w-[100px]' },
{ key: 'author', label: '작성자', className: 'min-w-[100px]' },
{ key: 'createdAt', label: '등록일', className: 'min-w-[120px]' },
];
// 테이블 행 렌더링
const renderTableRow = (
item: PriceDistributionListItem,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<PriceDistributionListItem>
) => {
const { isSelected, onToggle } = handlers;
return (
<TableRow
key={item.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={() => handleRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
</TableCell>
<TableCell className="text-muted-foreground text-center">
{globalIndex}
</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{item.distributionNo}
</code>
</TableCell>
<TableCell>
<span className="font-medium">{item.distributionName}</span>
</TableCell>
<TableCell>{renderStatusBadge(item.status)}</TableCell>
<TableCell className="text-muted-foreground">{item.author}</TableCell>
<TableCell className="text-muted-foreground">
{new Date(item.createdAt).toLocaleDateString('ko-KR')}
</TableCell>
</TableRow>
);
};
// 모바일 카드 렌더링
const renderMobileCard = (
item: PriceDistributionListItem,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<PriceDistributionListItem>
) => {
const { isSelected, onToggle } = handlers;
return (
<ListMobileCard
key={item.id}
id={item.id}
title={item.distributionName}
headerBadges={
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{item.distributionNo}
</code>
}
statusBadge={renderStatusBadge(item.status)}
isSelected={isSelected}
onToggleSelection={onToggle}
onCardClick={() => handleRowClick(item)}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="작성자" value={item.author} />
<InfoField
label="등록일"
value={new Date(item.createdAt).toLocaleDateString('ko-KR')}
/>
</div>
}
/>
);
};
// 헤더 액션
const headerActions = () => (
<Button
variant="default"
size="sm"
onClick={() => setShowRegisterDialog(true)}
className="ml-auto gap-2 bg-gray-900 text-white hover:bg-gray-800"
>
<FilePlus className="h-4 w-4" />
</Button>
);
// UniversalListPage 설정
const listConfig: UniversalListConfig<PriceDistributionListItem> = {
title: '단가배포 목록',
description: '단가표 기준 거래처별 단가 배포를 관리합니다',
icon: FileText,
basePath: '/master-data/price-distribution',
idField: 'id',
actions: {
getList: async () => ({
success: true,
data: data,
totalCount: data.length,
}),
deleteBulk: async (ids: string[]) => {
const result = await deletePriceDistribution(ids);
return { success: result.success, error: result.error };
},
},
columns: tableColumns,
headerActions,
filterConfig,
// 날짜 범위 필터 + 프리셋 버튼
dateRangeSelector: {
enabled: true,
showPresets: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
dateField: 'createdAt',
},
searchPlaceholder: '단가배포번호, 단가배포명, 작성자 검색...',
itemsPerPage: pageSize,
clientSideFiltering: true,
searchFilter,
customFilterFn,
renderTableRow,
renderMobileCard,
};
return (
<>
<UniversalListPage<PriceDistributionListItem>
config={listConfig}
initialData={data}
initialTotalCount={data.length}
/>
{/* 단가배포 등록 확인 다이얼로그 */}
<AlertDialog open={showRegisterDialog} onOpenChange={setShowRegisterDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<span className="block font-semibold text-foreground">
?
</span>
<span className="block text-muted-foreground">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRegistering}></AlertDialogCancel>
<AlertDialogAction
onClick={handleRegister}
disabled={isRegistering}
>
{isRegistering ? '등록 중...' : '등록'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
export default PriceDistributionList;

View File

@@ -0,0 +1,444 @@
'use server';
import type {
PriceDistributionListItem,
PriceDistributionDetail,
PriceDistributionFormData,
PriceDistributionStats,
DistributionStatus,
PriceDistributionItem,
} from './types';
// ============================================================================
// 목데이터
// ============================================================================
const MOCK_ITEMS: PriceDistributionItem[] = [
{
id: 'item-1',
pricingCode: '121212',
itemCode: '123123',
itemType: '반제품',
itemName: '품목명A',
specification: 'ST',
unit: 'EA',
purchasePrice: 10000,
processingCost: 5000,
marginRate: 50.0,
salesPrice: 20000,
status: '사용',
author: '홍길동',
changedDate: '2026-01-15',
},
{
id: 'item-2',
pricingCode: '121213',
itemCode: '123124',
itemType: '완제품',
itemName: '품목명B',
specification: '규격B',
unit: 'SET',
purchasePrice: 8000,
processingCost: 3000,
marginRate: 40.0,
salesPrice: 14000,
status: '사용',
author: '김철수',
changedDate: '2026-01-16',
},
{
id: 'item-3',
pricingCode: '121214',
itemCode: '123125',
itemType: '반제품',
itemName: '품목명C',
specification: 'ST',
unit: 'EA',
purchasePrice: 15000,
processingCost: 5000,
marginRate: 50.0,
salesPrice: 27000,
status: '사용',
author: '이영희',
changedDate: '2026-01-17',
},
{
id: 'item-4',
pricingCode: '121215',
itemCode: '123126',
itemType: '원자재',
itemName: '품목명D',
specification: 'AL',
unit: 'KG',
purchasePrice: 5000,
processingCost: 2000,
marginRate: 60.0,
salesPrice: 10000,
status: '사용',
author: '박민수',
changedDate: '2026-01-18',
},
{
id: 'item-5',
pricingCode: '121216',
itemCode: '123127',
itemType: '완제품',
itemName: '품목명E',
specification: '규격E',
unit: 'SET',
purchasePrice: 20000,
processingCost: 8000,
marginRate: 50.0,
salesPrice: 38000,
status: '사용',
author: '홍길동',
changedDate: '2026-01-19',
},
{
id: 'item-6',
pricingCode: '121217',
itemCode: '123128',
itemType: '반제품',
itemName: '품목명F',
specification: 'ST',
unit: 'EA',
purchasePrice: 12000,
processingCost: 4000,
marginRate: 50.0,
salesPrice: 22000,
status: '사용',
author: '김철수',
changedDate: '2026-01-20',
},
{
id: 'item-7',
pricingCode: '121218',
itemCode: '123129',
itemType: '원자재',
itemName: '품목명G',
specification: 'SUS',
unit: 'KG',
purchasePrice: 7000,
processingCost: 3000,
marginRate: 45.0,
salesPrice: 13000,
status: '사용',
author: '이영희',
changedDate: '2026-01-21',
},
];
const MOCK_LIST: PriceDistributionListItem[] = [
{
id: '1',
distributionNo: '121212',
distributionName: '2025년 1월',
status: 'finalized',
author: '김대표',
createdAt: '2026-01-15',
revisionCount: 3,
},
{
id: '2',
distributionNo: '121213',
distributionName: '2025년 1월',
status: 'revision',
author: '김대표',
createdAt: '2026-01-20',
revisionCount: 1,
},
{
id: '3',
distributionNo: '121214',
distributionName: '2025년 1월',
status: 'initial',
author: '김대표',
createdAt: '2026-01-25',
revisionCount: 0,
},
{
id: '4',
distributionNo: '121215',
distributionName: '2025년 1월',
status: 'revision',
author: '김대표',
createdAt: '2026-01-28',
revisionCount: 2,
},
{
id: '5',
distributionNo: '121216',
distributionName: '2025년 1월',
status: 'finalized',
author: '김대표',
createdAt: '2026-02-01',
revisionCount: 4,
},
{
id: '6',
distributionNo: '121217',
distributionName: '2025년 1월',
status: 'initial',
author: '김대표',
createdAt: '2026-02-03',
revisionCount: 0,
},
{
id: '7',
distributionNo: '121218',
distributionName: '2025년 1월',
status: 'revision',
author: '김대표',
createdAt: '2026-02-03',
revisionCount: 1,
},
];
const MOCK_DETAILS: Record<string, PriceDistributionDetail> = {
'1': {
id: '1',
distributionNo: '121212',
distributionName: '2025년 1월',
status: 'finalized',
createdAt: '2026-01-15',
documentNo: '',
effectiveDate: '2026-01-01',
officePhone: '02-1234-1234',
orderPhone: '02-1234-1234',
author: '김대표',
items: MOCK_ITEMS,
},
'2': {
id: '2',
distributionNo: '121213',
distributionName: '2025년 1월',
status: 'revision',
createdAt: '2026-01-20',
documentNo: '',
effectiveDate: '2026-01-01',
officePhone: '02-1234-1234',
orderPhone: '02-1234-1234',
author: '김대표',
items: MOCK_ITEMS.slice(0, 5),
},
'3': {
id: '3',
distributionNo: '121214',
distributionName: '2025년 1월',
status: 'initial',
createdAt: '2026-01-25',
documentNo: '',
effectiveDate: '2026-02-01',
officePhone: '02-1234-1234',
orderPhone: '02-1234-1234',
author: '김대표',
items: MOCK_ITEMS.slice(0, 3),
},
};
// ============================================================================
// API 함수 (목데이터 기반)
// ============================================================================
/**
* 단가배포 목록 조회
*/
export async function getPriceDistributionList(params?: {
page?: number;
size?: number;
q?: string;
status?: DistributionStatus;
dateFrom?: string;
dateTo?: string;
}): Promise<{
success: boolean;
data?: { items: PriceDistributionListItem[]; total: number };
error?: string;
}> {
let items = [...MOCK_LIST];
if (params?.status) {
items = items.filter((item) => item.status === params.status);
}
if (params?.q) {
const q = params.q.toLowerCase();
items = items.filter(
(item) =>
item.distributionNo.toLowerCase().includes(q) ||
item.distributionName.toLowerCase().includes(q) ||
item.author.toLowerCase().includes(q)
);
}
if (params?.dateFrom) {
items = items.filter((item) => item.createdAt >= params.dateFrom!);
}
if (params?.dateTo) {
items = items.filter((item) => item.createdAt <= params.dateTo!);
}
return {
success: true,
data: { items, total: items.length },
};
}
/**
* 단가배포 통계
*/
export async function getPriceDistributionStats(): Promise<{
success: boolean;
data?: PriceDistributionStats;
error?: string;
}> {
const total = MOCK_LIST.length;
const initial = MOCK_LIST.filter((p) => p.status === 'initial').length;
const revision = MOCK_LIST.filter((p) => p.status === 'revision').length;
const finalized = MOCK_LIST.filter((p) => p.status === 'finalized').length;
return { success: true, data: { total, initial, revision, finalized } };
}
/**
* 단가배포 상세 조회
*/
export async function getPriceDistributionById(id: string): Promise<{
success: boolean;
data?: PriceDistributionDetail;
error?: string;
}> {
const detail = MOCK_DETAILS[id];
if (!detail) {
// 목록에 있는 항목은 기본 상세 데이터 생성
const listItem = MOCK_LIST.find((item) => item.id === id);
if (listItem) {
return {
success: true,
data: {
id: listItem.id,
distributionNo: listItem.distributionNo,
distributionName: listItem.distributionName,
status: listItem.status,
createdAt: listItem.createdAt,
documentNo: '',
effectiveDate: '2026-01-01',
officePhone: '02-1234-1234',
orderPhone: '02-1234-1234',
author: listItem.author,
items: MOCK_ITEMS,
},
};
}
return { success: false, error: '단가배포를 찾을 수 없습니다.' };
}
return { success: true, data: detail };
}
/**
* 단가배포 등록 (현재 단가표 기준 자동 생성)
*/
export async function createPriceDistribution(): Promise<{
success: boolean;
data?: PriceDistributionDetail;
error?: string;
}> {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
let name = `${year}${month}`;
// 중복 체크 → (N) 추가
const existing = MOCK_LIST.filter((item) =>
item.distributionName.startsWith(name)
);
if (existing.length > 0) {
name = `${name}(${existing.length})`;
}
const newId = String(Date.now());
const newItem: PriceDistributionDetail = {
id: newId,
distributionNo: String(Math.floor(100000 + Math.random() * 900000)),
distributionName: name,
status: 'initial',
createdAt: now.toISOString().split('T')[0],
documentNo: '',
effectiveDate: now.toISOString().split('T')[0],
officePhone: '',
orderPhone: '',
author: '현재사용자',
items: MOCK_ITEMS,
};
return { success: true, data: newItem };
}
/**
* 단가배포 수정
*/
export async function updatePriceDistribution(
id: string,
data: PriceDistributionFormData
): Promise<{
success: boolean;
data?: PriceDistributionDetail;
error?: string;
}> {
const detailData = MOCK_DETAILS[id];
const listItem = MOCK_LIST.find((item) => item.id === id);
if (!detailData && !listItem) {
return { success: false, error: '단가배포를 찾을 수 없습니다.' };
}
const detail: PriceDistributionDetail = detailData || {
id,
distributionNo: listItem!.distributionNo,
distributionName: data.distributionName,
status: 'revision' as DistributionStatus,
createdAt: listItem!.createdAt,
documentNo: data.documentNo,
effectiveDate: data.effectiveDate,
officePhone: data.officePhone,
orderPhone: data.orderPhone,
author: listItem!.author,
items: MOCK_ITEMS,
};
const updated: PriceDistributionDetail = {
...detail,
distributionName: data.distributionName,
documentNo: data.documentNo,
effectiveDate: data.effectiveDate,
officePhone: data.officePhone,
orderPhone: data.orderPhone,
status: detail.status === 'initial' ? 'revision' : detail.status,
};
return { success: true, data: updated };
}
/**
* 단가배포 최종확정
*/
export async function finalizePriceDistribution(id: string): Promise<{
success: boolean;
error?: string;
}> {
const exists = MOCK_LIST.find((item) => item.id === id) || MOCK_DETAILS[id];
if (!exists) {
return { success: false, error: '단가배포를 찾을 수 없습니다.' };
}
return { success: true };
}
/**
* 단가배포 삭제
*/
export async function deletePriceDistribution(ids: string[]): Promise<{
success: boolean;
deletedCount?: number;
error?: string;
}> {
return { success: true, deletedCount: ids.length };
}

View File

@@ -0,0 +1,3 @@
export { PriceDistributionList } from './PriceDistributionList';
export { PriceDistributionDetail } from './PriceDistributionDetail';
export { PriceDistributionDocumentModal } from './PriceDistributionDocumentModal';

View File

@@ -0,0 +1,96 @@
/**
* 단가배포관리 타입 정의
*/
// ===== 단가배포 상태 =====
export type DistributionStatus = 'initial' | 'revision' | 'finalized';
export const DISTRIBUTION_STATUS_LABELS: Record<DistributionStatus, string> = {
initial: '최초작성',
revision: '보이수정',
finalized: '최종확정',
};
export const DISTRIBUTION_STATUS_STYLES: Record<DistributionStatus, { bg: string; text: string; border: string }> = {
initial: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200' },
revision: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200' },
finalized: { bg: 'bg-green-50', text: 'text-green-700', border: 'border-green-200' },
};
// ===== 단가배포 목록 아이템 =====
export interface PriceDistributionListItem {
id: string;
distributionNo: string; // 단가배포번호
distributionName: string; // 단가배포명 (YYYY년 MM월)
status: DistributionStatus; // 상태
author: string; // 작성자
createdAt: string; // 등록일
revisionCount: number; // 보이수정 횟수
}
// ===== 단가배포 상세 =====
export interface PriceDistributionDetail {
id: string;
distributionNo: string; // 단가배포번호
distributionName: string; // 단가배포명
status: DistributionStatus; // 상태
createdAt: string; // 작성일
documentNo: string; // 용지번 (발송번)
effectiveDate: string; // 적용시점
officePhone: string; // 사무실 연락처
orderPhone: string; // 발주전용 연락처
author: string; // 작성자
items: PriceDistributionItem[]; // 단가 목록
}
// ===== 단가배포 품목 항목 =====
export interface PriceDistributionItem {
id: string;
pricingCode: string; // 단가번호
itemCode: string; // 품목코드
itemType: string; // 품목유형
itemName: string; // 품목명
specification: string; // 규격
unit: string; // 단위
purchasePrice: number; // 매입단가
processingCost: number; // 가공비
marginRate: number; // 마진율
salesPrice: number; // 판매단가
status: string; // 상태
author: string; // 작성자
changedDate: string; // 변경일
}
// ===== 등급 필터 =====
export type TradeGrade = 'A등급' | 'B등급' | 'C등급' | 'D등급';
export const TRADE_GRADE_OPTIONS: { value: TradeGrade; label: string }[] = [
{ value: 'A등급', label: 'A등급' },
{ value: 'B등급', label: 'B등급' },
{ value: 'C등급', label: 'C등급' },
{ value: 'D등급', label: 'D등급' },
];
// ===== 단가배포 폼 데이터 (수정용) =====
export interface PriceDistributionFormData {
distributionName: string;
documentNo: string;
effectiveDate: string;
officePhone: string;
orderPhone: string;
}
// ===== 통계 =====
export interface PriceDistributionStats {
total: number;
initial: number;
revision: number;
finalized: number;
}