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,14 @@
'use client';
import { use } from 'react';
import { PriceDistributionDetail } from '@/components/pricing-distribution';
export default function PriceDistributionEditPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return <PriceDistributionDetail id={id} mode="edit" />;
}

View File

@@ -0,0 +1,14 @@
'use client';
import { use } from 'react';
import { PriceDistributionDetail } from '@/components/pricing-distribution';
export default function PriceDistributionDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return <PriceDistributionDetail id={id} mode="view" />;
}

View File

@@ -0,0 +1,13 @@
'use client';
import { Suspense } from 'react';
import { PriceDistributionList } from '@/components/pricing-distribution';
import { ListPageSkeleton } from '@/components/ui/skeleton';
export default function PriceDistributionPage() {
return (
<Suspense fallback={<ListPageSkeleton showHeader={false} />}>
<PriceDistributionList />
</Suspense>
);
}

View File

@@ -0,0 +1,14 @@
'use client';
import { use } from 'react';
import { PricingTableDetailClient } from '@/components/pricing-table-management';
export default function PricingTableDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return <PricingTableDetailClient pricingTableId={id} />;
}

View File

@@ -0,0 +1,26 @@
'use client';
import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import PricingTableListClient from '@/components/pricing-table-management/PricingTableListClient';
import { PricingTableDetailClient } from '@/components/pricing-table-management';
import { ListPageSkeleton } from '@/components/ui/skeleton';
function PricingTableManagementContent() {
const searchParams = useSearchParams();
const mode = searchParams.get('mode');
if (mode === 'new') {
return <PricingTableDetailClient pricingTableId="new" />;
}
return <PricingTableListClient />;
}
export default function PricingTableManagementPage() {
return (
<Suspense fallback={<ListPageSkeleton showHeader={false} />}>
<PricingTableManagementContent />
</Suspense>
);
}

View File

@@ -0,0 +1,12 @@
'use client';
/**
* 실적신고관리 페이지
* URL: /quality/performance-reports
*/
import { PerformanceReportList } from '@/components/quality/PerformanceReportManagement';
export default function PerformanceReportsPage() {
return <PerformanceReportList />;
}

View File

@@ -222,6 +222,8 @@
html {
/* 🔧 Always show scrollbar to prevent layout shift */
/*overflow-y: scroll;*/
/* 📱 모바일 확대 후 좌우 패닝 허용 */
touch-action: manipulation;
}
body {

View File

@@ -2,6 +2,7 @@
import { useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { stripLocaleSlashPrefix } from '@/lib/utils/locale';
interface ParentMenuRedirectProps {
/** 현재 부모 메뉴 경로 (예: '/accounting') */
@@ -41,7 +42,7 @@ export function ParentMenuRedirect({ parentPath, fallbackPath }: ParentMenuRedir
const findParentMenu = (items: any[], targetPath: string): any | null => {
for (const item of items) {
// 경로가 일치하는지 확인 (locale prefix 제거 후 비교)
const itemPath = item.path?.replace(/^\/(ko|en|ja)\//, '/') || '';
const itemPath = stripLocaleSlashPrefix(item.path || '');
if (itemPath === targetPath || item.path === targetPath) {
return item;
}
@@ -59,7 +60,7 @@ export function ParentMenuRedirect({ parentPath, fallbackPath }: ParentMenuRedir
if (parentMenu && parentMenu.children && parentMenu.children.length > 0) {
// 첫 번째 자식 메뉴의 경로로 리다이렉트
const firstChild = parentMenu.children[0];
const firstChildPath = firstChild.path?.replace(/^\/(ko|en|ja)\//, '/') || fallbackPath;
const firstChildPath = stripLocaleSlashPrefix(firstChild.path || '') || fallbackPath;
router.replace(firstChildPath);
} else {
// 자식이 없으면 fallback으로 이동

View File

@@ -1,7 +1,7 @@
'use client';
import { usePermission } from '@/hooks/usePermission';
import type { PermissionAction } from '@/lib/permissions/types';
import type { PermissionAction, UsePermissionReturn } from '@/lib/permissions/types';
interface PermissionGuardProps {
action: PermissionAction;
@@ -34,16 +34,8 @@ export function PermissionGuard({
}: PermissionGuardProps) {
const permission = usePermission(url);
const actionMap: Record<PermissionAction, boolean> = {
view: permission.canView,
create: permission.canCreate,
update: permission.canUpdate,
delete: permission.canDelete,
approve: permission.canApprove,
export: permission.canExport,
};
if (!actionMap[action]) {
const key = `can${action.charAt(0).toUpperCase()}${action.slice(1)}` as keyof UsePermissionReturn;
if (!permission[key]) {
return <>{fallback}</>;
}

View File

@@ -0,0 +1,235 @@
'use client';
/**
* 재고 조정 팝업 (기획서 Page 78)
*
* - 품목 선택 (검색)
* - 유형 필터 셀렉트 박스 (전체, 유형 목록)
* - 테이블: 로트번호, 품목코드, 품목유형, 품목명, 규격, 단위, 재고량, 증감 수량
* - 증감 수량: 양수/음수 입력 가능
* - 취소/저장 버튼
*/
import { useState, useMemo, useCallback } from 'react';
import { Search, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { toast } from 'sonner';
import type { InventoryAdjustmentItem } from './types';
// 목데이터 - 품목 유형 목록
const ITEM_TYPE_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'raw', label: '원자재' },
{ value: 'sub', label: '부자재' },
{ value: 'part', label: '부품' },
{ value: 'product', label: '완제품' },
];
// 목데이터 - 재고 품목 목록
const MOCK_STOCK_ITEMS: InventoryAdjustmentItem[] = [
{ id: '1', lotNo: 'LOT-2026-001', itemCode: 'STEEL-001', itemType: '원자재', itemName: 'SUS304 스테인리스 판재', specification: '1000x2000x3T', unit: 'EA', stockQty: 100 },
{ id: '2', lotNo: 'LOT-2026-002', itemCode: 'ELEC-002', itemType: '부품', itemName: 'MCU 컨트롤러 IC', specification: 'STM32F103C8T6', unit: 'EA', stockQty: 500 },
{ id: '3', lotNo: 'LOT-2026-003', itemCode: 'PLAS-003', itemType: '부자재', itemName: 'ABS 사출 케이스', specification: '150x100x50', unit: 'SET', stockQty: 200 },
{ id: '4', lotNo: 'LOT-2026-004', itemCode: 'STEEL-002', itemType: '원자재', itemName: '알루미늄 프로파일', specification: '40x40x2000L', unit: 'EA', stockQty: 50 },
{ id: '5', lotNo: 'LOT-2026-005', itemCode: 'ELEC-005', itemType: '부품', itemName: 'DC 모터 24V', specification: '24V 100RPM', unit: 'EA', stockQty: 80 },
{ id: '6', lotNo: 'LOT-2026-006', itemCode: 'CHEM-001', itemType: '부자재', itemName: '에폭시 접착제', specification: '500ml', unit: 'EA', stockQty: 300 },
{ id: '7', lotNo: 'LOT-2026-007', itemCode: 'ELEC-007', itemType: '부품', itemName: '커패시터 100uF', specification: '100uF 50V', unit: 'EA', stockQty: 1000 },
];
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave?: (items: InventoryAdjustmentItem[]) => void;
}
export function InventoryAdjustmentDialog({ open, onOpenChange, onSave }: Props) {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState('all');
const [adjustments, setAdjustments] = useState<Record<string, number | undefined>>({});
const [isSaving, setIsSaving] = useState(false);
// 필터링된 품목 목록
const filteredItems = useMemo(() => {
return MOCK_STOCK_ITEMS.filter((item) => {
const matchesSearch = !search ||
item.itemCode.toLowerCase().includes(search.toLowerCase()) ||
item.itemName.toLowerCase().includes(search.toLowerCase()) ||
item.lotNo.toLowerCase().includes(search.toLowerCase());
const matchesType = typeFilter === 'all' || item.itemType === ITEM_TYPE_OPTIONS.find(o => o.value === typeFilter)?.label;
return matchesSearch && matchesType;
});
}, [search, typeFilter]);
// 증감 수량 변경
const handleAdjustmentChange = useCallback((itemId: string, value: string) => {
const numValue = value === '' || value === '-' ? undefined : Number(value);
setAdjustments((prev) => ({
...prev,
[itemId]: numValue,
}));
}, []);
// 저장
const handleSave = async () => {
const itemsWithAdjustment = MOCK_STOCK_ITEMS
.filter((item) => adjustments[item.id] !== undefined && adjustments[item.id] !== 0)
.map((item) => ({ ...item, adjustmentQty: adjustments[item.id] }));
if (itemsWithAdjustment.length === 0) {
toast.error('증감 수량을 입력해주세요.');
return;
}
setIsSaving(true);
try {
onSave?.(itemsWithAdjustment);
toast.success('재고 조정이 저장되었습니다.');
handleClose();
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
// 닫기 (상태 초기화)
const handleClose = () => {
setSearch('');
setTypeFilter('all');
setAdjustments({});
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-[700px] max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-lg font-semibold"> </DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 flex-1 min-h-0">
{/* 품목 선택 - 검색 */}
<div className="font-medium text-sm"> </div>
<div className="relative">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="품목코드, 품목명, 로트번호 검색"
className="pr-10"
/>
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
</div>
{/* 총 건수 + 유형 필터 */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
<strong>{filteredItems.length}</strong>
</span>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[120px] h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ITEM_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 테이블 */}
<div className="flex-1 overflow-auto border rounded-md">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-center w-[100px]"></TableHead>
<TableHead className="text-center w-[90px]"></TableHead>
<TableHead className="text-center w-[70px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="w-[90px]"></TableHead>
<TableHead className="text-center w-[50px]"></TableHead>
<TableHead className="text-center w-[70px]"></TableHead>
<TableHead className="text-center w-[90px]"> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.map((item) => (
<TableRow key={item.id}>
<TableCell className="text-center text-sm">{item.lotNo}</TableCell>
<TableCell className="text-center text-sm">{item.itemCode}</TableCell>
<TableCell className="text-center text-sm">{item.itemType}</TableCell>
<TableCell className="text-sm">{item.itemName}</TableCell>
<TableCell className="text-sm">{item.specification}</TableCell>
<TableCell className="text-center text-sm">{item.unit}</TableCell>
<TableCell className="text-center text-sm">{item.stockQty}</TableCell>
<TableCell className="text-center">
<Input
type="number"
value={adjustments[item.id] ?? ''}
onChange={(e) => handleAdjustmentChange(item.id, e.target.value)}
className="h-8 text-sm text-center w-[80px] mx-auto"
placeholder="0"
/>
</TableCell>
</TableRow>
))}
{filteredItems.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 하단 버튼 */}
<div className="flex justify-center gap-3 pt-2">
<Button
variant="outline"
onClick={handleClose}
className="min-w-[120px]"
disabled={isSaving}
>
</Button>
<Button
variant="default"
onClick={handleSave}
className="min-w-[120px] bg-gray-900 text-white hover:bg-gray-800"
disabled={isSaving}
>
{isSaving && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -16,7 +16,7 @@
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Upload, FileText, Search, X } from 'lucide-react';
import { Upload, FileText, Search, X, Plus } from 'lucide-react';
import { FileDropzone } from '@/components/ui/file-dropzone';
import { ItemSearchModal } from '@/components/quotes/ItemSearchModal';
import { InspectionModalV2 } from '@/app/[locale]/(protected)/quality/qms/components/InspectionModalV2';
@@ -40,10 +40,19 @@ import {
createReceiving,
updateReceiving,
} from './actions';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
RECEIVING_STATUS_OPTIONS,
type ReceivingDetail as ReceivingDetailType,
type ReceivingStatus,
type InventoryAdjustmentRecord,
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { toast } from 'sonner';
@@ -56,6 +65,7 @@ interface Props {
// 초기 폼 데이터
const INITIAL_FORM_DATA: Partial<ReceivingDetailType> = {
materialNo: '',
lotNo: '',
itemCode: '',
itemName: '',
@@ -71,6 +81,7 @@ const INITIAL_FORM_DATA: Partial<ReceivingDetailType> = {
inspectionDate: '',
inspectionResult: '',
certificateFile: undefined,
inventoryAdjustments: [],
};
// 로트번호 생성 (YYMMDD-NN)
@@ -121,6 +132,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
const [isItemSearchOpen, setIsItemSearchOpen] = useState(false);
const [isSupplierSearchOpen, setIsSupplierSearchOpen] = useState(false);
// 재고 조정 이력 상태
const [adjustments, setAdjustments] = useState<InventoryAdjustmentRecord[]>([]);
// Dev 모드 폼 자동 채우기
useDevFill(
'receiving',
@@ -159,9 +173,14 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
if (result.success && result.data) {
setDetail(result.data);
// 재고 조정 이력 설정
if (result.data.inventoryAdjustments) {
setAdjustments(result.data.inventoryAdjustments);
}
// 수정 모드일 때 폼 데이터 설정
if (isEditMode) {
setFormData({
materialNo: result.data.materialNo || '',
lotNo: result.data.lotNo || '',
itemCode: result.data.itemCode,
itemName: result.data.itemName,
@@ -239,6 +258,30 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
setIsInspectionModalOpen(true);
};
// 재고 조정 행 추가
const handleAddAdjustment = () => {
const newRecord: InventoryAdjustmentRecord = {
id: `adj-${Date.now()}`,
adjustmentDate: new Date().toISOString().split('T')[0],
quantity: 0,
inspector: getLoggedInUserName() || '홍길동',
};
setAdjustments((prev) => [...prev, newRecord]);
};
// 재고 조정 행 삭제
const handleRemoveAdjustment = (adjId: string) => {
setAdjustments((prev) => prev.filter((a) => a.id !== adjId));
};
// 재고 조정 수량 변경
const handleAdjustmentQtyChange = (adjId: string, value: string) => {
const numValue = value === '' || value === '-' ? 0 : Number(value);
setAdjustments((prev) =>
prev.map((a) => (a.id === adjId ? { ...a, quantity: numValue } : a))
);
};
// 취소 핸들러 - 등록 모드면 목록으로, 수정 모드면 상세로 이동
const handleCancel = () => {
if (isNewMode) {
@@ -277,6 +320,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{renderReadOnlyField('자재번호', detail.materialNo)}
{renderReadOnlyField('로트번호', detail.lotNo)}
{renderReadOnlyField('품목코드', detail.itemCode)}
{renderReadOnlyField('품목명', detail.itemName)}
@@ -293,6 +337,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
detail.status === 'inspection_completed' ? '검사완료' :
detail.status
)}
</div>
{/* 비고 - 전체 너비 */}
<div className="mt-4">
{renderReadOnlyField('비고', detail.remark)}
</div>
</CardContent>
@@ -317,15 +364,54 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
<span>{detail.certificateFileName}</span>
</div>
) : (
'등록된 파일이 없습니다.'
'클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'
)}
</div>
</div>
</CardContent>
</Card>
{/* 재고 조정 */}
<Card>
<CardHeader className="pb-4 flex flex-row items-center justify-between">
<CardTitle className="text-base font-medium"> </CardTitle>
</CardHeader>
<CardContent>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-center w-[50px]">No</TableHead>
<TableHead className="text-center min-w-[140px]"></TableHead>
<TableHead className="text-center min-w-[120px]"> </TableHead>
<TableHead className="text-center min-w-[120px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adjustments.length > 0 ? (
adjustments.map((adj, idx) => (
<TableRow key={adj.id}>
<TableCell className="text-center">{idx + 1}</TableCell>
<TableCell className="text-center">{adj.adjustmentDate}</TableCell>
<TableCell className="text-center">{adj.quantity}</TableCell>
<TableCell className="text-center">{adj.inspector}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} className="text-center py-6 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}, [detail]);
}, [detail, adjustments]);
// ===== 등록/수정 폼 콘텐츠 =====
const renderFormContent = useCallback(() => {
@@ -338,6 +424,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* 자재번호 - 읽기전용 */}
{renderReadOnlyField('자재번호', formData.materialNo, true)}
{/* 로트번호 - 읽기전용 */}
{renderReadOnlyField('로트번호', formData.lotNo, true)}
@@ -512,17 +601,107 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
</div>
</CardContent>
</Card>
{/* 재고 조정 */}
<Card>
<CardHeader className="pb-4 flex flex-row items-center justify-between">
<CardTitle className="text-base font-medium"> </CardTitle>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddAdjustment}
>
<Plus className="w-4 h-4 mr-1" />
</Button>
</CardHeader>
<CardContent>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-center w-[50px]">No</TableHead>
<TableHead className="text-center min-w-[140px]"></TableHead>
<TableHead className="text-center min-w-[120px]"> </TableHead>
<TableHead className="text-center min-w-[120px]"></TableHead>
<TableHead className="text-center w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adjustments.length > 0 ? (
adjustments.map((adj, idx) => (
<TableRow key={adj.id}>
<TableCell className="text-center">{idx + 1}</TableCell>
<TableCell className="text-center">
<Input
type="date"
value={adj.adjustmentDate}
onChange={(e) => {
setAdjustments((prev) =>
prev.map((a) =>
a.id === adj.id ? { ...a, adjustmentDate: e.target.value } : a
)
);
}}
className="h-8 text-sm"
/>
</TableCell>
<TableCell className="text-center">
<Input
type="number"
value={adj.quantity || ''}
onChange={(e) => handleAdjustmentQtyChange(adj.id, e.target.value)}
className="h-8 text-sm text-center w-[100px] mx-auto"
placeholder="0"
/>
</TableCell>
<TableCell className="text-center">{adj.inspector}</TableCell>
<TableCell className="text-center">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => handleRemoveAdjustment(adj.id)}
>
<X className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="text-center py-6 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}, [formData]);
}, [formData, adjustments]);
// ===== 커스텀 헤더 액션 (view/edit 모드) =====
const customHeaderActions = (isViewMode || isEditMode) && detail ? (
<>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleInspection}>
</Button>
</>
{isViewMode && (
<Button
variant="default"
className="bg-gray-900 text-white hover:bg-gray-800"
onClick={() => router.push(`/ko/material/receiving-management/${id}?mode=edit`)}
>
</Button>
)}
</div>
) : undefined;
// 에러 상태 표시 (view/edit 모드에서만)

View File

@@ -20,6 +20,7 @@ import {
ClipboardCheck,
Plus,
Eye,
Settings2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -35,6 +36,7 @@ import {
type FilterFieldConfig,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { InventoryAdjustmentDialog } from './InventoryAdjustmentDialog';
import { getReceivings, getReceivingStats } from './actions';
import { RECEIVING_STATUS_LABELS, RECEIVING_STATUS_STYLES } from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
@@ -50,6 +52,9 @@ export function ReceivingList() {
const [stats, setStats] = useState<ReceivingStats | null>(null);
const [totalItems, setTotalItems] = useState(0);
// ===== 재고 조정 팝업 상태 =====
const [isAdjustmentOpen, setIsAdjustmentOpen] = useState(false);
// ===== 날짜 범위 상태 (최근 30일) =====
const today = new Date();
const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
@@ -139,7 +144,7 @@ export function ReceivingList() {
const tableFooter = useMemo(
() => (
<TableRow className="bg-gray-50 hover:bg-gray-50">
<TableCell colSpan={15} className="py-3">
<TableCell colSpan={18} className="py-3">
<span className="text-sm text-muted-foreground">
{totalItems}
</span>
@@ -200,20 +205,23 @@ export function ReceivingList() {
},
},
// 테이블 컬럼 (기획서 순서)
// 테이블 컬럼 (기획서 2026-02-03 순서)
columns: [
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
{ key: 'no', label: 'No.', className: 'w-[50px] text-center' },
{ key: 'materialNo', label: '자재번호', className: 'w-[100px]' },
{ key: 'lotNo', label: '로트번호', className: 'w-[120px]' },
{ key: 'inspectionStatus', label: '수입검사', className: 'w-[80px] text-center' },
{ key: 'inspectionDate', label: '검사일', className: 'w-[100px] text-center' },
{ key: 'inspectionStatus', label: '수입검사', className: 'w-[70px] text-center' },
{ key: 'inspectionDate', label: '검사일', className: 'w-[90px] text-center' },
{ key: 'supplier', label: '발주처', className: 'min-w-[100px]' },
{ key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' },
{ key: 'itemName', label: '품목', className: 'min-w-[150px]' },
{ key: 'specification', label: '규격', className: 'w-[100px]' },
{ key: 'unit', label: '단위', className: 'w-[60px] text-center' },
{ key: 'receivingQty', label: '입고수량', className: 'w-[80px] text-center' },
{ key: 'receivingDate', label: '입고일', className: 'w-[100px] text-center' },
{ key: 'createdBy', label: '작성자', className: 'w-[80px] text-center' },
{ key: 'manufacturer', label: '제조사', className: 'min-w-[100px]' },
{ key: 'itemCode', label: '품목코드', className: 'min-w-[100px]' },
{ key: 'itemType', label: '품목유형', className: 'w-[80px] text-center' },
{ key: 'itemName', label: '품목명', className: 'min-w-[130px]' },
{ key: 'specification', label: '규격', className: 'w-[90px]' },
{ key: 'unit', label: '단위', className: 'w-[50px] text-center' },
{ key: 'receivingQty', label: '수량', className: 'w-[60px] text-center' },
{ key: 'receivingDate', label: '입고변경일', className: 'w-[100px] text-center' },
{ key: 'createdBy', label: '작성자', className: 'w-[70px] text-center' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
],
@@ -250,17 +258,27 @@ export function ReceivingList() {
// 통계 카드
stats: statCards,
// 헤더 액션 (입고 등록 버튼)
// 헤더 액션 (재고 조정 + 입고 등록 버튼)
headerActions: () => (
<Button
variant="default"
size="sm"
className="bg-gray-900 text-white hover:bg-gray-800"
onClick={handleRegister}
>
<Plus className="w-4 h-4 mr-1" />
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsAdjustmentOpen(true)}
>
<Settings2 className="w-4 h-4 mr-1" />
</Button>
<Button
variant="default"
size="sm"
className="bg-gray-900 text-white hover:bg-gray-800"
onClick={handleRegister}
>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
),
// 테이블 푸터
@@ -286,12 +304,15 @@ export function ReceivingList() {
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{item.materialNo || '-'}</TableCell>
<TableCell className="font-medium">{item.lotNo || '-'}</TableCell>
<TableCell className="text-center">{item.inspectionStatus || '-'}</TableCell>
<TableCell className="text-center">{item.inspectionDate || '-'}</TableCell>
<TableCell>{item.supplier}</TableCell>
<TableCell>{item.manufacturer || '-'}</TableCell>
<TableCell>{item.itemCode}</TableCell>
<TableCell className="max-w-[150px] truncate">{item.itemName}</TableCell>
<TableCell className="text-center">{item.itemType || '-'}</TableCell>
<TableCell className="max-w-[130px] truncate">{item.itemName}</TableCell>
<TableCell>{item.specification || '-'}</TableCell>
<TableCell className="text-center">{item.unit}</TableCell>
<TableCell className="text-center">
@@ -342,12 +363,14 @@ export function ReceivingList() {
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="자재번호" value={item.materialNo || '-'} />
<InfoField label="품목코드" value={item.itemCode} />
<InfoField label="품목유형" value={item.itemType || '-'} />
<InfoField label="발주처" value={item.supplier} />
<InfoField label="제조사" value={item.manufacturer || '-'} />
<InfoField label="수입검사" value={item.inspectionStatus || '-'} />
<InfoField label="검사일" value={item.inspectionDate || '-'} />
<InfoField label="입고수량" value={item.receivingQty !== undefined ? `${item.receivingQty}` : '-'} />
<InfoField label="입고일" value={item.receivingDate || '-'} />
<InfoField label="수량" value={item.receivingQty !== undefined ? `${item.receivingQty}` : '-'} />
<InfoField label="입고변경일" value={item.receivingDate || '-'} />
</div>
}
actions={
@@ -376,9 +399,17 @@ export function ReceivingList() {
);
return (
<UniversalListPage
config={config}
onFilterChange={(newFilters) => setFilterValues(newFilters)}
/>
<>
<UniversalListPage
config={config}
onFilterChange={(newFilters) => setFilterValues(newFilters)}
/>
{/* 재고 조정 팝업 */}
<InventoryAdjustmentDialog
open={isAdjustmentOpen}
onOpenChange={setIsAdjustmentOpen}
/>
</>
);
}

View File

@@ -31,11 +31,14 @@ import type {
const MOCK_RECEIVING_LIST: ReceivingItem[] = [
{
id: '1',
materialNo: 'MAT-001',
lotNo: 'LOT-2026-001',
inspectionStatus: '적',
inspectionDate: '2026-01-25',
supplier: '(주)대한철강',
manufacturer: '포스코',
itemCode: 'STEEL-001',
itemType: '원자재',
itemName: 'SUS304 스테인리스 판재',
specification: '1000x2000x3T',
unit: 'EA',
@@ -46,11 +49,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
},
{
id: '2',
materialNo: 'MAT-002',
lotNo: 'LOT-2026-002',
inspectionStatus: '적',
inspectionDate: '2026-01-26',
supplier: '삼성전자부품',
manufacturer: '삼성전자',
itemCode: 'ELEC-002',
itemType: '부품',
itemName: 'MCU 컨트롤러 IC',
specification: 'STM32F103C8T6',
unit: 'EA',
@@ -61,11 +67,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
},
{
id: '3',
materialNo: 'MAT-003',
lotNo: 'LOT-2026-003',
inspectionStatus: '-',
inspectionDate: undefined,
supplier: '한국플라스틱',
manufacturer: '한국플라스틱',
itemCode: 'PLAS-003',
itemType: '부자재',
itemName: 'ABS 사출 케이스',
specification: '150x100x50',
unit: 'SET',
@@ -76,11 +85,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
},
{
id: '4',
materialNo: 'MAT-004',
lotNo: 'LOT-2026-004',
inspectionStatus: '부적',
inspectionDate: '2026-01-27',
supplier: '(주)대한철강',
manufacturer: '포스코',
itemCode: 'STEEL-002',
itemType: '원자재',
itemName: '알루미늄 프로파일',
specification: '40x40x2000L',
unit: 'EA',
@@ -91,11 +103,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
},
{
id: '5',
materialNo: 'MAT-005',
lotNo: 'LOT-2026-005',
inspectionStatus: '-',
inspectionDate: undefined,
supplier: '글로벌전자',
manufacturer: '글로벌전자',
itemCode: 'ELEC-005',
itemType: '부품',
itemName: 'DC 모터 24V',
specification: '24V 100RPM',
unit: 'EA',
@@ -106,11 +121,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
},
{
id: '6',
materialNo: 'MAT-006',
lotNo: 'LOT-2026-006',
inspectionStatus: '적',
inspectionDate: '2026-01-24',
supplier: '동양화학',
manufacturer: '동양화학',
itemCode: 'CHEM-001',
itemType: '부자재',
itemName: '에폭시 접착제',
specification: '500ml',
unit: 'EA',
@@ -121,11 +139,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
},
{
id: '7',
materialNo: 'MAT-007',
lotNo: 'LOT-2026-007',
inspectionStatus: '적',
inspectionDate: '2026-01-28',
supplier: '삼성전자부품',
manufacturer: '삼성전자',
itemCode: 'ELEC-007',
itemType: '부품',
itemName: '커패시터 100uF',
specification: '100uF 50V',
unit: 'EA',
@@ -136,11 +157,14 @@ const MOCK_RECEIVING_LIST: ReceivingItem[] = [
},
{
id: '8',
materialNo: 'MAT-008',
lotNo: 'LOT-2026-008',
inspectionStatus: '-',
inspectionDate: undefined,
supplier: '한국볼트',
manufacturer: '한국볼트',
itemCode: 'BOLT-001',
itemType: '부품',
itemName: 'SUS 볼트 M8x30',
specification: 'M8x30 SUS304',
unit: 'EA',
@@ -158,11 +182,11 @@ const MOCK_RECEIVING_STATS: ReceivingStats = {
inspectionCompletedCount: 5,
};
// 기획서 2026-01-28 기준 상세 목데이터
// 기획서 2026-02-03 기준 상세 목데이터
const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
'1': {
id: '1',
// 기본 정보
materialNo: 'MAT-001',
lotNo: 'LOT-2026-001',
itemCode: 'STEEL-001',
itemName: 'SUS304 스테인리스 판재',
@@ -175,16 +199,21 @@ const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
createdBy: '김철수',
status: 'completed',
remark: '',
// 수입검사 정보
inspectionDate: '2026-01-25',
inspectionResult: '합격',
certificateFile: undefined,
// 하위 호환
inventoryAdjustments: [
{ id: 'adj-1', adjustmentDate: '2026-01-05', quantity: 10, inspector: '홍길동' },
{ id: 'adj-2', adjustmentDate: '2026-01-05', quantity: 5, inspector: '홍길동' },
{ id: 'adj-3', adjustmentDate: '2026-01-05', quantity: -15, inspector: '홍길동' },
{ id: 'adj-4', adjustmentDate: '2026-01-05', quantity: 5, inspector: '홍길동' },
],
orderNo: 'PO-2026-001',
orderUnit: 'EA',
},
'2': {
id: '2',
materialNo: 'MAT-002',
lotNo: 'LOT-2026-002',
itemCode: 'ELEC-002',
itemName: 'MCU 컨트롤러 IC',
@@ -199,11 +228,13 @@ const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
remark: '긴급 입고',
inspectionDate: '2026-01-26',
inspectionResult: '합격',
inventoryAdjustments: [],
orderNo: 'PO-2026-002',
orderUnit: 'EA',
},
'3': {
id: '3',
materialNo: 'MAT-003',
lotNo: 'LOT-2026-003',
itemCode: 'PLAS-003',
itemName: 'ABS 사출 케이스',
@@ -218,11 +249,13 @@ const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
remark: '',
inspectionDate: undefined,
inspectionResult: undefined,
inventoryAdjustments: [],
orderNo: 'PO-2026-003',
orderUnit: 'SET',
},
'4': {
id: '4',
materialNo: 'MAT-004',
lotNo: 'LOT-2026-004',
itemCode: 'STEEL-002',
itemName: '알루미늄 프로파일',
@@ -237,11 +270,13 @@ const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
remark: '검사 진행 중',
inspectionDate: '2026-01-27',
inspectionResult: '불합격',
inventoryAdjustments: [],
orderNo: 'PO-2026-004',
orderUnit: 'EA',
},
'5': {
id: '5',
materialNo: 'MAT-005',
lotNo: 'LOT-2026-005',
itemCode: 'ELEC-005',
itemName: 'DC 모터 24V',
@@ -256,6 +291,7 @@ const MOCK_RECEIVING_DETAIL: Record<string, ReceivingDetail> = {
remark: '',
inspectionDate: undefined,
inspectionResult: undefined,
inventoryAdjustments: [],
orderNo: 'PO-2026-005',
orderUnit: 'EA',
},

View File

@@ -7,5 +7,6 @@ export { ReceivingDetail } from './ReceivingDetail';
export { InspectionCreate } from './InspectionCreate';
export { ReceivingProcessDialog } from './ReceivingProcessDialog';
export { ReceivingReceiptDialog } from './ReceivingReceiptDialog';
export { InventoryAdjustmentDialog } from './InventoryAdjustmentDialog';
export { SuccessDialog } from './SuccessDialog';
export * from './types';

View File

@@ -41,16 +41,19 @@ export const RECEIVING_STATUS_OPTIONS = [
// 입고 목록 아이템
export interface ReceivingItem {
id: string;
materialNo?: string; // 자재번호
lotNo?: string; // 로트번호
inspectionStatus?: string; // 수입검사 (적/부적/-)
inspectionDate?: string; // 검사일
supplier: string; // 발주처
manufacturer?: string; // 제조사
itemCode: string; // 품목코드
itemType?: string; // 품목유형
itemName: string; // 품목명
specification?: string; // 규격
unit: string; // 단위
receivingQty?: number; // 입고수량
receivingDate?: string; // 입고일
receivingQty?: number; // 수량
receivingDate?: string; // 입고변경
createdBy?: string; // 작성자
status: ReceivingStatus; // 상태
// 기존 필드 (하위 호환)
@@ -59,10 +62,11 @@ export interface ReceivingItem {
orderUnit?: string; // 발주단위
}
// 입고 상세 정보 (기획서 2026-01-28 기준)
// 입고 상세 정보 (기획서 2026-02-03 기준)
export interface ReceivingDetail {
id: string;
// 기본 정보
materialNo?: string; // 자재번호 (읽기전용)
lotNo?: string; // 로트번호 (읽기전용)
itemCode: string; // 품목코드 (수정가능)
itemName: string; // 품목명 (읽기전용 - 품목코드 선택 시 자동)
@@ -80,6 +84,8 @@ export interface ReceivingDetail {
inspectionResult?: string; // 검사결과 (읽기전용) - 합격/불합격
certificateFile?: string; // 업체 제공 성적서 자료 (수정가능)
certificateFileName?: string; // 파일명
// 재고 조정 이력
inventoryAdjustments?: InventoryAdjustmentRecord[];
// 기존 필드 (하위 호환)
orderNo?: string; // 발주번호
orderDate?: string; // 발주일자
@@ -92,6 +98,27 @@ export interface ReceivingDetail {
receivingManager?: string; // 입고담당
}
// 재고 조정 이력 레코드 (입고 상세 내)
export interface InventoryAdjustmentRecord {
id: string;
adjustmentDate: string; // 조정일시
quantity: number; // 증감 수량 (양수/음수)
inspector: string; // 검수자
}
// 재고 조정 팝업용 품목 아이템
export interface InventoryAdjustmentItem {
id: string;
lotNo: string; // 로트번호
itemCode: string; // 품목코드
itemType: string; // 품목유형
itemName: string; // 품목명
specification: string; // 규격
unit: string; // 단위
stockQty: number; // 재고량
adjustmentQty?: number; // 증감 수량
}
// 검사 대상 아이템
export interface InspectionTarget {
id: string;

View File

@@ -4,13 +4,12 @@
* 재고현황 상세/수정 페이지
*
* 기획서 기준:
* - 기본 정보: 재번호, 품목코드, 품목명, 규격, 단위, 계산 재고량 (읽기 전용)
* - 수정 가능: 실제 재고량, 안전재고, 상태 (사용/미사용)
* - 기본 정보: 재번호, 품목코드, 품목유형, 품목명, 규격, 단위, 재고량 (읽기 전용)
* - 수정 가능: 안전재고, 상태 (사용/미사용)
*/
import { useState, useCallback, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -25,7 +24,8 @@ import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetai
import { stockStatusConfig } from './stockStatusConfig';
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
import { getStockById, updateStock } from './actions';
import { USE_STATUS_LABELS } from './types';
import { USE_STATUS_LABELS, ITEM_TYPE_LABELS } from './types';
import type { ItemType } from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { toast } from 'sonner';
@@ -38,11 +38,11 @@ interface StockDetailData {
id: string;
stockNumber: string;
itemCode: string;
itemType: ItemType;
itemName: string;
specification: string;
unit: string;
calculatedQty: number;
actualQty: number;
safetyStock: number;
useStatus: 'active' | 'inactive';
}
@@ -59,11 +59,9 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
// 폼 데이터 (수정 모드용)
const [formData, setFormData] = useState<{
actualQty: number;
safetyStock: number;
useStatus: 'active' | 'inactive';
}>({
actualQty: 0,
safetyStock: 0,
useStatus: 'active',
});
@@ -86,17 +84,16 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
id: data.id,
stockNumber: data.id, // stockNumber가 없으면 id 사용
itemCode: data.itemCode,
itemType: (data.itemType || 'RM') as ItemType,
itemName: data.itemName,
specification: data.specification || '-',
unit: data.unit,
calculatedQty: data.currentStock, // 계산 재고량
actualQty: data.currentStock, // 실제 재고량 (별도 필드 없으면 currentStock 사용)
calculatedQty: data.currentStock, // 재고량
safetyStock: data.safetyStock,
useStatus: data.status === null ? 'active' : 'active', // 기본값
};
setDetail(detailData);
setFormData({
actualQty: detailData.actualQty,
safetyStock: detailData.safetyStock,
useStatus: detailData.useStatus,
});
@@ -140,7 +137,6 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
prev
? {
...prev,
actualQty: formData.actualQty,
safetyStock: formData.safetyStock,
useStatus: formData.useStatus,
}
@@ -189,19 +185,19 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Row 1: 재번호, 품목코드, 품목명, 규격 */}
{/* Row 1: 재번호, 품목코드, 품목유형, 품목명 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{renderReadOnlyField('재번호', detail.stockNumber)}
{renderReadOnlyField('재번호', detail.stockNumber)}
{renderReadOnlyField('품목코드', detail.itemCode)}
{renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-')}
{renderReadOnlyField('품목명', detail.itemName)}
{renderReadOnlyField('규격', detail.specification)}
</div>
{/* Row 2: 단위, 계산 재고량, 실제 재고량, 안전재고 */}
{/* Row 2: 규격, 단위, 재고량, 안전재고 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{renderReadOnlyField('규격', detail.specification)}
{renderReadOnlyField('단위', detail.unit)}
{renderReadOnlyField('계산 재고량', detail.calculatedQty)}
{renderReadOnlyField('실제 재고량', detail.actualQty)}
{renderReadOnlyField('재고량', detail.calculatedQty)}
{renderReadOnlyField('안전재고', detail.safetyStock)}
</div>
@@ -226,33 +222,19 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Row 1: 재번호, 품목코드, 품목명, 규격 (읽기 전용) */}
{/* Row 1: 재번호, 품목코드, 품목유형, 품목명 (읽기 전용) */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{renderReadOnlyField('재번호', detail.stockNumber, true)}
{renderReadOnlyField('재번호', detail.stockNumber, true)}
{renderReadOnlyField('품목코드', detail.itemCode, true)}
{renderReadOnlyField('품목유형', ITEM_TYPE_LABELS[detail.itemType] || '-', true)}
{renderReadOnlyField('품목명', detail.itemName, true)}
{renderReadOnlyField('규격', detail.specification, true)}
</div>
{/* Row 2: 단위, 계산 재고량 (읽기 전용) + 실제 재고량, 안전재고 (수정 가능) */}
{/* Row 2: 규격, 단위, 재고량 (읽기 전용) + 안전재고 (수정 가능) */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{renderReadOnlyField('규격', detail.specification, true)}
{renderReadOnlyField('단위', detail.unit, true)}
{renderReadOnlyField('계산 재고량', detail.calculatedQty, true)}
{/* 실제 재고량 (수정 가능) */}
<div>
<Label htmlFor="actualQty" className="text-sm text-muted-foreground">
</Label>
<Input
id="actualQty"
type="number"
value={formData.actualQty}
onChange={(e) => handleInputChange('actualQty', e.target.value)}
className="mt-1.5 border-gray-300 focus:border-blue-500 focus:ring-blue-500"
min={0}
/>
</div>
{renderReadOnlyField('재고량', detail.calculatedQty, true)}
{/* 안전재고 (수정 가능) */}
<div>
@@ -322,4 +304,4 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
onSubmit={async () => { await handleSave(); return { success: true }; }}
/>
);
}
}

View File

@@ -17,7 +17,7 @@ import {
CheckCircle2,
AlertCircle,
Eye,
Loader2,
AlertTriangle,
} from 'lucide-react';
import type { ExcelColumn } from '@/lib/utils/excel-download';
import { Button } from '@/components/ui/button';
@@ -33,11 +33,9 @@ import {
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { getStocks, getStockStats } from './actions';
import { USE_STATUS_LABELS } from './types';
import { USE_STATUS_LABELS, ITEM_TYPE_LABELS } from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type { StockItem, StockStats, ItemType, StockStatusType } from './types';
import { ClipboardList } from 'lucide-react';
import { StockAuditModal } from './StockAuditModal';
// 페이지당 항목 수
const ITEMS_PER_PAGE = 20;
@@ -65,10 +63,6 @@ export function StockStatusList() {
useStatus: 'all',
});
// ===== 재고 실사 모달 상태 =====
const [isAuditModalOpen, setIsAuditModalOpen] = useState(false);
const [isAuditLoading, setIsAuditLoading] = useState(false);
// 데이터 로드 함수
const loadData = useCallback(async () => {
try {
@@ -130,30 +124,15 @@ export function StockStatusList() {
router.push(`/ko/material/stock-status/${item.id}?mode=view`);
};
// ===== 재고 실사 버튼 핸들러 =====
const handleStockAudit = () => {
setIsAuditLoading(true);
// 약간의 딜레이 후 모달 오픈 (로딩 UI 표시를 위해)
setTimeout(() => {
setIsAuditModalOpen(true);
setIsAuditLoading(false);
}, 100);
};
// ===== 재고 실사 완료 핸들러 =====
const handleAuditComplete = () => {
loadData(); // 데이터 새로고침
};
// ===== 엑셀 컬럼 정의 =====
const excelColumns: ExcelColumn<StockItem>[] = [
{ header: '재번호', key: 'stockNumber' },
{ header: '재번호', key: 'stockNumber' },
{ header: '품목코드', key: 'itemCode' },
{ header: '품목유형', key: 'itemType', transform: (value) => ITEM_TYPE_LABELS[value as ItemType] || '-' },
{ header: '품목명', key: 'itemName' },
{ header: '규격', key: 'specification' },
{ header: '단위', key: 'unit' },
{ header: '계산 재고량', key: 'calculatedQty' },
{ header: '실제 재고량', key: 'actualQty' },
{ header: '재고량', key: 'calculatedQty' },
{ header: '안전재고', key: 'safetyStock' },
{ header: '상태', key: 'useStatus', transform: (value) => USE_STATUS_LABELS[value as 'active' | 'inactive'] || '-' },
];
@@ -202,11 +181,17 @@ export function StockStatusList() {
iconColor: 'text-green-600',
},
{
label: '재고부족',
label: '재고 부족',
value: `${stockStats?.lowCount || 0}`,
icon: AlertCircle,
iconColor: 'text-red-600',
},
{
label: '안전재고 미달',
value: `${stockStats?.outCount || 0}`,
icon: AlertTriangle,
iconColor: 'text-orange-600',
},
];
// ===== 필터 설정 (전체/사용/미사용) =====
@@ -226,13 +211,13 @@ export function StockStatusList() {
// ===== 테이블 컬럼 =====
const tableColumns = [
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
{ key: 'stockNumber', label: '재번호', className: 'w-[100px]' },
{ key: 'stockNumber', label: '재번호', className: 'w-[100px]' },
{ key: 'itemCode', label: '품목코드', className: 'min-w-[100px]' },
{ key: 'itemType', label: '품목유형', className: 'w-[80px]' },
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
{ key: 'specification', label: '규격', className: 'w-[100px]' },
{ key: 'unit', label: '단위', className: 'w-[60px] text-center' },
{ key: 'calculatedQty', label: '계산 재고량', className: 'w-[100px] text-center' },
{ key: 'actualQty', label: '실제 재고량', className: 'w-[100px] text-center' },
{ key: 'calculatedQty', label: '재고량', className: 'w-[80px] text-center' },
{ key: 'safetyStock', label: '안전재고', className: 'w-[80px] text-center' },
{ key: 'useStatus', label: '상태', className: 'w-[80px] text-center' },
];
@@ -259,11 +244,11 @@ export function StockStatusList() {
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{item.stockNumber}</TableCell>
<TableCell>{item.itemCode}</TableCell>
<TableCell>{ITEM_TYPE_LABELS[item.itemType] || '-'}</TableCell>
<TableCell className="max-w-[150px] truncate">{item.itemName}</TableCell>
<TableCell>{item.specification || '-'}</TableCell>
<TableCell className="text-center">{item.unit}</TableCell>
<TableCell className="text-center">{item.calculatedQty}</TableCell>
<TableCell className="text-center">{item.actualQty}</TableCell>
<TableCell className="text-center">{item.safetyStock}</TableCell>
<TableCell className="text-center">
<span className={item.useStatus === 'inactive' ? 'text-gray-400' : ''}>
@@ -306,10 +291,10 @@ export function StockStatusList() {
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="품목코드" value={item.itemCode} />
<InfoField label="품목유형" value={ITEM_TYPE_LABELS[item.itemType] || '-'} />
<InfoField label="규격" value={item.specification || '-'} />
<InfoField label="단위" value={item.unit} />
<InfoField label="계산 재고량" value={`${item.calculatedQty}`} />
<InfoField label="실제 재고량" value={`${item.actualQty}`} />
<InfoField label="재고량" value={`${item.calculatedQty}`} />
<InfoField label="안전재고" value={`${item.safetyStock}`} />
</div>
}
@@ -401,24 +386,6 @@ export function StockStatusList() {
// 통계
computeStats: () => stats,
// 헤더 액션 버튼
headerActions: () => (
<Button
variant="default"
size="sm"
className="bg-gray-900 text-white hover:bg-gray-800"
onClick={handleStockAudit}
disabled={isAuditLoading}
>
{isAuditLoading ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<ClipboardList className="w-4 h-4 mr-1" />
)}
{isAuditLoading ? '로딩 중...' : '재고 실사'}
</Button>
),
// 테이블 푸터
tableFooter: (
<TableRow className="bg-gray-50 hover:bg-gray-50">
@@ -468,22 +435,12 @@ export function StockStatusList() {
}
return (
<>
<UniversalListPage<StockItem>
config={config}
initialData={filteredStocks}
initialTotalCount={filteredStocks.length}
onFilterChange={(newFilters) => setFilterValues(newFilters)}
onSearchChange={setSearchTerm}
/>
{/* 재고 실사 모달 */}
<StockAuditModal
open={isAuditModalOpen}
onOpenChange={setIsAuditModalOpen}
stocks={stocks}
onComplete={handleAuditComplete}
/>
</>
<UniversalListPage<StockItem>
config={config}
initialData={filteredStocks}
initialTotalCount={filteredStocks.length}
onFilterChange={(newFilters) => setFilterValues(newFilters)}
onSearchChange={setSearchTerm}
/>
);
}
}

View File

@@ -439,7 +439,6 @@ export async function getStockById(id: string): Promise<{
export async function updateStock(
id: string,
data: {
actualQty: number;
safetyStock: number;
useStatus: 'active' | 'inactive';
}
@@ -457,7 +456,6 @@ export async function updateStock(
'Content-Type': 'application/json',
},
body: JSON.stringify({
actual_qty: data.actualQty,
safety_stock: data.safetyStock,
is_active: data.useStatus === 'active',
}),

View File

@@ -50,10 +50,6 @@ import {
getLogisticsOptions,
getVehicleTonnageOptions,
} from './actions';
import {
FREIGHT_COST_LABELS,
DELIVERY_METHOD_LABELS,
} from './types';
import type {
ShipmentCreateFormData,
DeliveryMethod,
@@ -83,10 +79,12 @@ const deliveryMethodOptions: { value: DeliveryMethod; label: string }[] = [
{ value: 'self_pickup', label: '직접수령' },
];
// 운임비용 옵션
const freightCostOptions: { value: FreightCostType; label: string }[] = Object.entries(
FREIGHT_COST_LABELS
).map(([value, label]) => ({ value: value as FreightCostType, label }));
// 운임비용 옵션 (선불, 착불, 없음)
const freightCostOptions: { value: FreightCostType; label: string }[] = [
{ value: 'prepaid', label: '선불' },
{ value: 'collect', label: '착불' },
{ value: 'none', label: '없음' },
];
// 빈 배차 행 생성
function createEmptyDispatch(): VehicleDispatch {
@@ -111,7 +109,7 @@ export function ShipmentCreate() {
priority: 'normal',
deliveryMethod: 'direct_dispatch',
shipmentDate: '',
freightCost: undefined,
freightCost: 'none',
receiver: '',
receiverContact: '',
zipCode: '',
@@ -229,9 +227,22 @@ export function ShipmentCreate() {
if (validationErrors.length > 0) setValidationErrors([]);
}, [validationErrors]);
// 배송방식에 따라 운임비용 '없음' 고정 여부 판단
const isFreightCostLocked = (method: DeliveryMethod) =>
method === 'direct_dispatch' || method === 'self_pickup';
// 폼 입력 핸들러
const handleInputChange = (field: keyof ShipmentCreateFormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (field === 'deliveryMethod') {
const method = value as DeliveryMethod;
if (isFreightCostLocked(method)) {
setFormData(prev => ({ ...prev, deliveryMethod: method, freightCost: 'none' as FreightCostType }));
} else {
setFormData(prev => ({ ...prev, deliveryMethod: method }));
}
} else {
setFormData(prev => ({ ...prev, [field]: value }));
}
if (validationErrors.length > 0) setValidationErrors([]);
};
@@ -455,7 +466,7 @@ export function ShipmentCreate() {
<Select
value={formData.freightCost || ''}
onValueChange={(value) => handleInputChange('freightCost', value)}
disabled={isSubmitting}
disabled={isSubmitting || isFreightCostLocked(formData.deliveryMethod)}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
@@ -549,7 +560,7 @@ export function ShipmentCreate() {
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>

View File

@@ -338,18 +338,16 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{renderInfoField('출고번호', detail.shipmentNo)}
{renderInfoField('로트번호', detail.lotNo)}
{renderInfoField('현장명', detail.siteName)}
{renderInfoField('수주처', detail.customerName)}
{renderInfoField('거래등급', detail.customerGrade)}
{renderInfoField('작성자', detail.registrant)}
{renderInfoField(
'상태',
<Badge className={SHIPMENT_STATUS_STYLES[detail.status]}>
{SHIPMENT_STATUS_LABELS[detail.status]}
</Badge>
)}
{renderInfoField('작성자', detail.registrant)}
</div>
</CardContent>
</Card>
@@ -408,7 +406,7 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>

View File

@@ -52,7 +52,6 @@ import {
import {
SHIPMENT_STATUS_LABELS,
SHIPMENT_STATUS_STYLES,
FREIGHT_COST_LABELS,
} from './types';
import type {
ShipmentDetail,
@@ -79,10 +78,12 @@ const deliveryMethodOptions: { value: DeliveryMethod; label: string }[] = [
{ value: 'self_pickup', label: '직접수령' },
];
// 운임비용 옵션
const freightCostOptions: { value: FreightCostType; label: string }[] = Object.entries(
FREIGHT_COST_LABELS
).map(([value, label]) => ({ value: value as FreightCostType, label }));
// 운임비용 옵션 (선불, 착불, 없음)
const freightCostOptions: { value: FreightCostType; label: string }[] = [
{ value: 'prepaid', label: '선불' },
{ value: 'collect', label: '착불' },
{ value: 'none', label: '없음' },
];
// 빈 배차 행 생성
function createEmptyDispatch(): VehicleDispatch {
@@ -174,12 +175,13 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
setDetail(shipmentDetail);
// 폼 초기값 설정
const lockedFreight = shipmentDetail.deliveryMethod === 'direct_dispatch' || shipmentDetail.deliveryMethod === 'self_pickup';
setFormData({
scheduledDate: shipmentDetail.scheduledDate,
shipmentDate: shipmentDetail.shipmentDate || '',
priority: shipmentDetail.priority,
deliveryMethod: shipmentDetail.deliveryMethod,
freightCost: shipmentDetail.freightCost,
freightCost: lockedFreight ? 'none' : shipmentDetail.freightCost,
receiver: shipmentDetail.receiver || '',
receiverContact: shipmentDetail.receiverContact || '',
zipCode: shipmentDetail.zipCode || '',
@@ -223,9 +225,22 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
loadData();
}, [loadData]);
// 배송방식에 따라 운임비용 '없음' 고정 여부 판단
const isFreightCostLocked = (method: DeliveryMethod) =>
method === 'direct_dispatch' || method === 'self_pickup';
// 폼 입력 핸들러
const handleInputChange = (field: keyof ShipmentEditFormData, value: string | number | undefined) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (field === 'deliveryMethod') {
const method = value as DeliveryMethod;
if (isFreightCostLocked(method)) {
setFormData(prev => ({ ...prev, deliveryMethod: method, freightCost: 'none' as FreightCostType }));
} else {
setFormData(prev => ({ ...prev, deliveryMethod: method }));
}
} else {
setFormData(prev => ({ ...prev, [field]: value }));
}
if (validationErrors.length > 0) setValidationErrors([]);
};
@@ -375,10 +390,6 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div className="space-y-1">
<Label className="text-muted-foreground"></Label>
<div className="font-medium">{detail.shipmentNo}</div>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground"></Label>
<div className="font-medium">{detail.lotNo}</div>
@@ -388,12 +399,12 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
<div className="font-medium">{detail.siteName}</div>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground"></Label>
<Label className="text-muted-foreground"></Label>
<div className="font-medium">{detail.customerName}</div>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground"></Label>
<div className="font-medium">{detail.customerGrade || '-'}</div>
<Label className="text-muted-foreground"></Label>
<div className="font-medium">{detail.orderer || '-'}</div>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground"></Label>
@@ -454,7 +465,7 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
key={`freight-${formData.freightCost}`}
value={formData.freightCost || ''}
onValueChange={(value) => handleInputChange('freightCost', value)}
disabled={isSubmitting}
disabled={isSubmitting || isFreightCostLocked(formData.deliveryMethod)}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
@@ -548,7 +559,7 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>

View File

@@ -231,23 +231,17 @@ export function ShipmentList() {
icon: Plus,
},
// 테이블 컬럼 (18개 - 출고번호/로트번호 통합)
// 테이블 컬럼 (11개)
columns: [
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
{ key: 'shipmentNo', label: '출고번호/로트번호', className: 'min-w-[160px]' },
{ key: 'scheduledDate', label: '출고예정일', className: 'w-[100px] text-center' },
{ key: 'lotNo', label: '로트번호', className: 'min-w-[120px]' },
{ key: 'siteName', label: '현장명', className: 'min-w-[100px]' },
{ key: 'orderCustomer', label: '수주처', className: 'min-w-[100px]' },
{ key: 'customerGrade', label: '거래등급', className: 'w-[80px] text-center' },
{ key: 'receiver', label: '수신자', className: 'w-[80px] text-center' },
{ key: 'receiverAddress', label: '수신주소', className: 'min-w-[140px]' },
{ key: 'receiverCompany', label: '수신처', className: 'min-w-[100px]' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'dispatch', label: '배차', className: 'w-[80px] text-center' },
{ key: 'arrivalDateTime', label: '입차일시', className: 'w-[130px] text-center' },
{ key: 'tonnage', label: '톤수', className: 'w-[70px] text-center' },
{ key: 'unloadingNo', label: '하차번호', className: 'w-[90px] text-center' },
{ key: 'driverContact', label: '기사연락처', className: 'min-w-[110px] text-center' },
{ key: 'writer', label: '작성자', className: 'w-[80px] text-center' },
{ key: 'shipmentDate', label: '출고일', className: 'w-[100px] text-center' },
],
@@ -292,7 +286,7 @@ export function ShipmentList() {
// 통계 카드
stats,
// 테이블 행 렌더링 (19개 컬럼)
// 테이블 행 렌더링 (11개 컬럼)
renderTableRow: (
item: ShipmentItem,
index: number,
@@ -312,16 +306,9 @@ export function ShipmentList() {
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">
<div>{item.shipmentNo}</div>
{item.lotNo && item.lotNo !== item.shipmentNo && (
<div className="text-xs text-muted-foreground">{item.lotNo}</div>
)}
</TableCell>
<TableCell className="text-center">{item.scheduledDate}</TableCell>
<TableCell className="font-medium">{item.lotNo || item.shipmentNo || '-'}</TableCell>
<TableCell className="max-w-[100px] truncate">{item.siteName}</TableCell>
<TableCell>{item.orderCustomer || item.customerName || '-'}</TableCell>
<TableCell className="text-center">{item.customerGrade || '-'}</TableCell>
<TableCell className="text-center">{item.receiver || '-'}</TableCell>
<TableCell className="max-w-[140px] truncate">{item.receiverAddress || '-'}</TableCell>
<TableCell>{item.receiverCompany || '-'}</TableCell>
@@ -331,10 +318,6 @@ export function ShipmentList() {
</Badge>
</TableCell>
<TableCell className="text-center">{item.dispatch || item.deliveryMethodLabel || '-'}</TableCell>
<TableCell className="text-center">{item.arrivalDateTime || '-'}</TableCell>
<TableCell className="text-center">{item.tonnage || '-'}</TableCell>
<TableCell className="text-center">{item.unloadingNo || '-'}</TableCell>
<TableCell className="text-center">{item.driverContact || '-'}</TableCell>
<TableCell className="text-center">{item.writer || item.manager || '-'}</TableCell>
<TableCell className="text-center">{item.shipmentDate || '-'}</TableCell>
</TableRow>
@@ -373,11 +356,14 @@ export function ShipmentList() {
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="출고번호/로트번호" value={item.shipmentNo || item.lotNo} />
<InfoField label="수주처" value={item.orderCustomer || item.customerName} />
<InfoField label="출고예정일" value={item.scheduledDate} />
<InfoField label="배송방식" value={item.deliveryMethodLabel || DELIVERY_METHOD_LABELS[item.deliveryMethod]} />
<InfoField label="로트번호" value={item.lotNo || item.shipmentNo} />
<InfoField label="현장명" value={item.siteName} />
<InfoField label="수주처" value={item.orderCustomer || item.customerName || '-'} />
<InfoField label="수신자" value={item.receiver || '-'} />
<InfoField label="수신주소" value={item.receiverAddress || '-'} />
<InfoField label="수신처" value={item.receiverCompany || '-'} />
<InfoField label="배차" value={item.dispatch || item.deliveryMethodLabel || '-'} />
<InfoField label="작성자" value={item.writer || item.manager || '-'} />
<InfoField label="출고일" value={item.shipmentDate || '-'} />
</div>
}

View File

@@ -41,11 +41,12 @@ export const PRIORITY_STYLES: Record<ShipmentPriority, string> = {
};
// 운임비용 타입
export type FreightCostType = 'prepaid' | 'collect' | 'free' | 'negotiable';
export type FreightCostType = 'prepaid' | 'collect' | 'free' | 'negotiable' | 'none';
export const FREIGHT_COST_LABELS: Record<FreightCostType, string> = {
prepaid: '선불',
collect: '착불',
none: '없음',
free: '무료',
negotiable: '협의',
};
@@ -137,6 +138,8 @@ export interface ShipmentItem {
writer?: string; // 작성자
shipmentDate?: string; // 출고일
shipmentTime?: string; // 출고시간 (캘린더용)
orderer?: string; // 수주자
createdAt?: string; // 작성일
}
// 출고 품목
@@ -162,6 +165,7 @@ export interface ShipmentDetail {
customerGrade: string; // 거래등급
status: ShipmentStatus; // 상태
registrant?: string; // 작성자
orderer?: string; // 수주자
// 수주/배송 정보
scheduledDate: string; // 출고 예정일

View File

@@ -9,6 +9,14 @@ import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { vehicleDispatchConfig } from './vehicleDispatchConfig';
import { getVehicleDispatchById } from './actions';
@@ -87,7 +95,7 @@ export function VehicleDispatchDetail({ id }: VehicleDispatchDetailProps) {
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{renderInfoField('배차번호', detail.dispatchNo)}
{renderInfoField('출고번호', detail.shipmentNo)}
{renderInfoField('로트번호', detail.lotNo || detail.shipmentNo)}
{renderInfoField('현장명', detail.siteName)}
{renderInfoField('수주처', detail.orderCustomer)}
{renderInfoField(
@@ -107,20 +115,34 @@ export function VehicleDispatchDetail({ id }: VehicleDispatchDetailProps) {
</CardContent>
</Card>
{/* 카드 2: 배차 정보 */}
{/* 카드 2: 배차 정보 (테이블 형태) */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
{renderInfoField('물류업체', detail.logisticsCompany)}
{renderInfoField('입차일시', detail.arrivalDateTime)}
{renderInfoField('톤수', detail.tonnage)}
{renderInfoField('차량번호', detail.vehicleNo)}
{renderInfoField('기사연락처', detail.driverContact)}
{renderInfoField('비고', detail.remarks || '-')}
</div>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>{detail.logisticsCompany || '-'}</TableCell>
<TableCell>{detail.arrivalDateTime || '-'}</TableCell>
<TableCell>{detail.tonnage || '-'}</TableCell>
<TableCell>{detail.vehicleNo || '-'}</TableCell>
<TableCell>{detail.driverContact || '-'}</TableCell>
<TableCell>{detail.remarks || '-'}</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>

View File

@@ -202,8 +202,8 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
<div className="font-medium">{detail.dispatchNo}</div>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground"></Label>
<div className="font-medium">{detail.shipmentNo}</div>
<Label className="text-muted-foreground"></Label>
<div className="font-medium">{detail.lotNo || detail.shipmentNo}</div>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground"></Label>
@@ -275,11 +275,11 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
/>
</div>
<div className="space-y-2">
<Label></Label>
<Label></Label>
<Input
value={formData.tonnage}
onChange={(e) => handleInputChange('tonnage', e.target.value)}
placeholder="예: 3.5톤"
placeholder="예: 3.5 톤"
disabled={isSubmitting}
/>
</div>

View File

@@ -163,23 +163,19 @@ export function VehicleDispatchList() {
onEndDateChange: setEndDate,
},
// 테이블 컬럼
// 테이블 컬럼 (13개)
columns: [
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
{ key: 'no', label: 'No.', className: 'w-[50px] text-center' },
{ key: 'dispatchNo', label: '배차번호', className: 'min-w-[130px]' },
{ key: 'shipmentNo', label: '출고번호', className: 'min-w-[130px]' },
{ key: 'lotNo', label: '로트번호', className: 'min-w-[120px]' },
{ key: 'siteName', label: '현장명', className: 'min-w-[100px]' },
{ key: 'orderCustomer', label: '수주처', className: 'min-w-[100px]' },
{ key: 'logisticsCompany', label: '물류업체', className: 'min-w-[90px]' },
{ key: 'tonnage', label: '톤수', className: 'w-[70px] text-center' },
{ key: 'supplyAmount', label: '공급가액', className: 'w-[100px] text-right' },
{ key: 'vat', label: '부가세', className: 'w-[90px] text-right' },
{ key: 'totalAmount', label: '합계', className: 'w-[100px] text-right' },
{ key: 'freightCostType', label: '선/착불', className: 'w-[70px] text-center' },
{ key: 'vehicleNo', label: '차량번호', className: 'min-w-[100px]' },
{ key: 'driverContact', label: '기사연락처', className: 'min-w-[110px]' },
{ key: 'writer', label: '작성자', className: 'w-[80px] text-center' },
{ key: 'arrivalDateTime', label: '입차일시', className: 'w-[130px] text-center' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'remarks', label: '비고', className: 'min-w-[100px]' },
],
@@ -201,15 +197,14 @@ export function VehicleDispatchList() {
itemsPerPage: ITEMS_PER_PAGE,
// 검색
searchPlaceholder: '배차번호, 출고번호, 현장명, 수주처, 차량번호 검색...',
searchPlaceholder: '배차번호, 로트번호, 현장명, 수주처 검색...',
searchFilter: (item: VehicleDispatchItem, search: string) => {
const s = search.toLowerCase();
return (
item.dispatchNo.toLowerCase().includes(s) ||
item.shipmentNo.toLowerCase().includes(s) ||
(item.lotNo || item.shipmentNo).toLowerCase().includes(s) ||
item.siteName.toLowerCase().includes(s) ||
item.orderCustomer.toLowerCase().includes(s) ||
item.vehicleNo.toLowerCase().includes(s)
item.orderCustomer.toLowerCase().includes(s)
);
},
@@ -235,31 +230,29 @@ export function VehicleDispatchList() {
onCheckedChange={handlers.onToggle}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{item.dispatchNo}</TableCell>
<TableCell>{item.shipmentNo}</TableCell>
<TableCell className="max-w-[100px] truncate">{item.siteName}</TableCell>
<TableCell>{item.orderCustomer}</TableCell>
<TableCell>{item.logisticsCompany}</TableCell>
<TableCell className="text-center">{item.tonnage}</TableCell>
<TableCell className="text-right">{formatAmount(item.supplyAmount)}</TableCell>
<TableCell className="text-right">{formatAmount(item.vat)}</TableCell>
<TableCell className="text-right font-medium">{formatAmount(item.totalAmount)}</TableCell>
<TableCell className="text-center">
<Badge className={`text-xs ${FREIGHT_COST_STYLES[item.freightCostType]}`}>
{FREIGHT_COST_LABELS[item.freightCostType]}
</Badge>
<TableCell className="w-[50px] text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="min-w-[130px] font-medium">{item.dispatchNo}</TableCell>
<TableCell className="min-w-[120px]">{item.lotNo || item.shipmentNo}</TableCell>
<TableCell className="min-w-[100px] truncate">{item.siteName}</TableCell>
<TableCell className="min-w-[100px]">{item.orderCustomer}</TableCell>
<TableCell className="min-w-[90px]">{item.logisticsCompany}</TableCell>
<TableCell className="w-[100px] text-right">{formatAmount(item.supplyAmount || 0)}</TableCell>
<TableCell className="w-[90px] text-right">{formatAmount(item.vat || 0)}</TableCell>
<TableCell className="w-[100px] text-right font-medium">{formatAmount(item.totalAmount || 0)}</TableCell>
<TableCell className="w-[70px] text-center">
{item.freightCostType ? (
<Badge className={`text-xs ${FREIGHT_COST_STYLES[item.freightCostType]}`}>
{FREIGHT_COST_LABELS[item.freightCostType]}
</Badge>
) : '-'}
</TableCell>
<TableCell>{item.vehicleNo}</TableCell>
<TableCell>{item.driverContact}</TableCell>
<TableCell className="text-center">{item.writer}</TableCell>
<TableCell className="text-center">{item.arrivalDateTime}</TableCell>
<TableCell className="text-center">
<TableCell className="w-[80px] text-center">{item.writer || '-'}</TableCell>
<TableCell className="w-[80px] text-center">
<Badge className={`text-xs ${VEHICLE_DISPATCH_STATUS_STYLES[item.status]}`}>
{VEHICLE_DISPATCH_STATUS_LABELS[item.status]}
</Badge>
</TableCell>
<TableCell className="max-w-[100px] truncate">{item.remarks || '-'}</TableCell>
<TableCell className="min-w-[100px] truncate">{item.remarks || '-'}</TableCell>
</TableRow>
);
},
@@ -296,17 +289,16 @@ export function VehicleDispatchList() {
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="출고번호" value={item.shipmentNo} />
<InfoField label="로트번호" value={item.lotNo || item.shipmentNo} />
<InfoField label="수주처" value={item.orderCustomer} />
<InfoField label="물류업체" value={item.logisticsCompany} />
<InfoField label="톤수" value={item.tonnage} />
<InfoField label="공급가액" value={`${formatAmount(item.supplyAmount)}`} />
<InfoField label="합계" value={`${formatAmount(item.totalAmount)}`} />
<InfoField
label="선/착불"
value={FREIGHT_COST_LABELS[item.freightCostType]}
/>
<InfoField label="차량번호" value={item.vehicleNo} />
<InfoField label="입차일시" value={item.arrivalDateTime} />
<InfoField label="작성자" value={item.writer} />
</div>
}
actions={

View File

@@ -35,6 +35,7 @@ export interface VehicleDispatchItem {
id: string;
dispatchNo: string; // 배차번호
shipmentNo: string; // 출고번호
lotNo?: string; // 로트번호
siteName: string; // 현장명
orderCustomer: string; // 수주처
logisticsCompany: string; // 물류업체
@@ -57,6 +58,7 @@ export interface VehicleDispatchDetail {
// 기본 정보
dispatchNo: string; // 배차번호
shipmentNo: string; // 출고번호
lotNo?: string; // 로트번호
siteName: string; // 현장명
orderCustomer: string; // 수주처
freightCostType: FreightCostType; // 운임비용

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

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

View File

@@ -0,0 +1,295 @@
'use client';
/**
* 재공품 생산 모달
*
* 기획서 기준:
* - 품목 선택 (검색) → 재공품 목록 테이블 추가
* - 테이블: 품목코드, 품목명, 규격, 단위, 재고량, 안전재고, 수량(입력)
* - 행 삭제 (X 버튼)
* - 우선순위: 긴급/우선/일반 토글 (디폴트: 일반)
* - 부서 Select (디폴트: 생산부서)
* - 비고 Textarea
* - 하단: 취소 / 생산지시 확정
*/
import { useState, useCallback } from 'react';
import { Search, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
// 재공품 아이템 타입
interface WipItem {
id: string;
itemCode: string;
itemName: string;
specification: string;
unit: string;
stockQuantity: number;
safetyStock: number;
quantity: number; // 사용자 입력
}
// 우선순위
type Priority = '긴급' | '우선' | '일반';
// Mock 재공품 데이터 (품목관리 > 재공품/사용 상태 '사용' 품목)
const MOCK_WIP_ITEMS: Omit<WipItem, 'quantity'>[] = [
{ id: 'wip-1', itemCode: 'WIP-GR-001', itemName: '가이드레일(벽면형)', specification: '120X70 EGI 1.6T', unit: 'EA', stockQuantity: 150, safetyStock: 50 },
{ id: 'wip-2', itemCode: 'WIP-CS-001', itemName: '케이스(500X380)', specification: '500X380 EGI 1.6T', unit: 'EA', stockQuantity: 80, safetyStock: 30 },
{ id: 'wip-3', itemCode: 'WIP-BF-001', itemName: '하단마감재(60X40)', specification: '60X40 EGI 1.6T', unit: 'EA', stockQuantity: 200, safetyStock: 100 },
{ id: 'wip-4', itemCode: 'WIP-LB-001', itemName: '하단L-BAR(17X60)', specification: '17X60 EGI 1.6T', unit: 'EA', stockQuantity: 120, safetyStock: 40 },
];
interface WipProductionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function WipProductionModal({ open, onOpenChange }: WipProductionModalProps) {
const [searchTerm, setSearchTerm] = useState('');
const [selectedItems, setSelectedItems] = useState<WipItem[]>([]);
const [priority, setPriority] = useState<Priority>('일반');
const [department, setDepartment] = useState('생산부서');
const [note, setNote] = useState('');
// 검색 결과 필터링
const searchResults = searchTerm.trim()
? MOCK_WIP_ITEMS.filter(
(item) =>
!selectedItems.some((s) => s.id === item.id) &&
(item.itemCode.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.itemName.toLowerCase().includes(searchTerm.toLowerCase()))
)
: [];
// 품목 추가
const handleAddItem = useCallback((item: Omit<WipItem, 'quantity'>) => {
setSelectedItems((prev) => [...prev, { ...item, quantity: 0 }]);
setSearchTerm('');
}, []);
// 품목 삭제
const handleRemoveItem = useCallback((id: string) => {
setSelectedItems((prev) => prev.filter((item) => item.id !== id));
}, []);
// 수량 변경
const handleQuantityChange = useCallback((id: string, value: string) => {
const qty = parseInt(value) || 0;
setSelectedItems((prev) =>
prev.map((item) => (item.id === id ? { ...item, quantity: qty } : item))
);
}, []);
// 생산지시 확정
const handleConfirm = useCallback(() => {
if (selectedItems.length === 0) {
toast.error('품목을 추가해주세요.');
return;
}
const invalidItems = selectedItems.filter((item) => item.quantity <= 0);
if (invalidItems.length > 0) {
toast.error('수량을 입력해주세요.');
return;
}
// TODO: API 연동
toast.success(`재공품 생산지시가 확정되었습니다. (${selectedItems.length}건)`);
handleReset();
onOpenChange(false);
}, [selectedItems, onOpenChange]);
// 초기화
const handleReset = useCallback(() => {
setSearchTerm('');
setSelectedItems([]);
setPriority('일반');
setDepartment('생산부서');
setNote('');
}, []);
const handleCancel = useCallback(() => {
handleReset();
onOpenChange(false);
}, [handleReset, onOpenChange]);
const priorityOptions: Priority[] = ['긴급', '우선', '일반'];
const priorityColors: Record<Priority, string> = {
'긴급': 'bg-red-500 text-white hover:bg-red-600',
'우선': 'bg-orange-500 text-white hover:bg-orange-600',
'일반': 'bg-gray-500 text-white hover:bg-gray-600',
};
const priorityInactiveColors: Record<Priority, string> = {
'긴급': 'bg-white text-red-500 border-red-300 hover:bg-red-50',
'우선': 'bg-white text-orange-500 border-orange-300 hover:bg-orange-50',
'일반': 'bg-white text-gray-500 border-gray-300 hover:bg-gray-50',
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-5">
{/* 품목 검색 */}
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="품목코드 또는 품목명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
{/* 검색 결과 드롭다운 */}
{searchResults.length > 0 && (
<div className="border rounded-md bg-white shadow-md max-h-40 overflow-y-auto">
{searchResults.map((item) => (
<button
key={item.id}
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-gray-50 text-sm"
onClick={() => handleAddItem(item)}
>
<span className="text-muted-foreground font-mono text-xs">{item.itemCode}</span>
<span className="font-medium">{item.itemName}</span>
<span className="text-muted-foreground text-xs">{item.specification}</span>
</button>
))}
</div>
)}
</div>
{/* 재공품 목록 테이블 */}
{selectedItems.length > 0 && (
<div className="border rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50">
<th className="px-3 py-2 text-left font-medium text-xs"></th>
<th className="px-3 py-2 text-left font-medium text-xs"></th>
<th className="px-3 py-2 text-left font-medium text-xs"></th>
<th className="px-3 py-2 text-center font-medium text-xs"></th>
<th className="px-3 py-2 text-center font-medium text-xs"></th>
<th className="px-3 py-2 text-center font-medium text-xs"></th>
<th className="px-3 py-2 text-center font-medium text-xs w-24"></th>
<th className="px-3 py-2 text-center font-medium text-xs w-10"></th>
</tr>
</thead>
<tbody>
{selectedItems.map((item) => (
<tr key={item.id} className="border-t">
<td className="px-3 py-2 font-mono text-xs">{item.itemCode}</td>
<td className="px-3 py-2">{item.itemName}</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{item.specification}</td>
<td className="px-3 py-2 text-center">{item.unit}</td>
<td className="px-3 py-2 text-center">{item.stockQuantity}</td>
<td className="px-3 py-2 text-center">{item.safetyStock}</td>
<td className="px-3 py-2">
<Input
type="number"
min={0}
value={item.quantity || ''}
onChange={(e) => handleQuantityChange(item.id, e.target.value)}
className="h-8 text-center text-sm"
placeholder="0"
/>
</td>
<td className="px-3 py-2 text-center">
<button
onClick={() => handleRemoveItem(item.id)}
className="text-gray-400 hover:text-red-500 transition-colors"
>
<X className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 우선순위 */}
<div className="space-y-2">
<Label className="text-sm font-medium"></Label>
<div className="flex gap-2">
{priorityOptions.map((opt) => (
<Button
key={opt}
variant="outline"
size="sm"
className={`flex-1 ${
priority === opt ? priorityColors[opt] : priorityInactiveColors[opt]
}`}
onClick={() => setPriority(opt)}
>
{opt}
</Button>
))}
</div>
</div>
{/* 부서 */}
<div className="space-y-2">
<Label className="text-sm font-medium"></Label>
<Select value={department} onValueChange={setDepartment}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="생산부서"></SelectItem>
<SelectItem value="품질관리부"></SelectItem>
<SelectItem value="자재부"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 비고 */}
<div className="space-y-2">
<Label className="text-sm font-medium"></Label>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="비고 사항을 입력하세요..."
rows={3}
/>
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button
onClick={handleConfirm}
className="bg-orange-500 hover:bg-orange-600"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -15,6 +15,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Clock, Loader, CheckCircle2, AlertTriangle, TimerOff } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
@@ -38,6 +39,7 @@ import {
type WorkOrderStatus,
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { WipProductionModal } from './WipProductionModal';
// 페이지당 항목 수
const ITEMS_PER_PAGE = 20;
@@ -93,6 +95,10 @@ const filterConfig: FilterFieldConfig[] = [
export function WorkOrderList() {
const router = useRouter();
// ===== 활성 탭 및 재공품 모달 =====
const [activeTab, setActiveTab] = useState('screen');
const [isWipModalOpen, setIsWipModalOpen] = useState(false);
// ===== 공정 ID 매핑 (getProcessOptions) =====
const [processMap, setProcessMap] = useState<Record<string, number>>({});
const [processMapLoaded, setProcessMapLoaded] = useState(false);
@@ -240,6 +246,7 @@ export function WorkOrderList() {
try {
// 탭 → processId 매핑
const tabValue = params?.tab || 'screen';
setActiveTab(tabValue);
const processId = processMap[tabValue];
// 해당 공정이 DB에 없으면 빈 목록 반환
@@ -342,6 +349,17 @@ export function WorkOrderList() {
defaultTab: 'screen',
tabsPosition: 'above-stats',
// 테이블 헤더 액션 (절곡 탭일 때만 재공품 생산 버튼)
tableHeaderActions: activeTab === 'bending' ? (
<Button
onClick={() => setIsWipModalOpen(true)}
className="bg-orange-500 hover:bg-orange-600 text-white"
size="sm"
>
</Button>
) : undefined,
// 통계 카드 (6개)
stats,
@@ -446,7 +464,7 @@ export function WorkOrderList() {
);
},
}),
[tabs, stats, processMap, handleRowClick]
[tabs, stats, processMap, handleRowClick, activeTab]
);
// processMap 로딩 완료 전에는 UniversalListPage를 마운트하지 않음
@@ -459,5 +477,13 @@ export function WorkOrderList() {
);
}
return <UniversalListPage config={config} />;
return (
<>
<UniversalListPage config={config} />
<WipProductionModal
open={isWipModalOpen}
onOpenChange={setIsWipModalOpen}
/>
</>
);
}

View File

@@ -0,0 +1,375 @@
'use client';
/**
* 절곡 재공품 통합 문서 (작업일지 & 중간검사성적서)
*
* 기획서 기준:
* - 제목: "절곡품 재고생산 작업일지 중간검사성적서"
* - 결재란: 작성/승인/승인/승인
* - 기본 정보: 제품명, 규격, 길이, 판고 LOT NO / 생산 LOT NO, 수량, 검사일자, 검사자
* - ■ 중간검사 기준서 KDPS-20: 도해 IMG + 검사항목(겉모양/절곡상태, 치수/길이/폭/간격)
* - ■ 중간검사 DATA: No, 제품명, 절곡상태(양호/불량), 길이(mm), 너비(mm)+포인트, 간격(mm), 판정
* - 부적합 내용 / 종합판정 (자동)
*/
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import type { WorkOrder } from '../types';
export interface InspectionContentRef {
getInspectionData: () => unknown;
}
interface BendingWipInspectionContentProps {
data: WorkOrder;
readOnly?: boolean;
}
type CheckStatus = '양호' | '불량' | null;
interface InspectionRow {
id: number;
productName: string; // 제품명
processStatus: CheckStatus; // 절곡상태
lengthDesign: string; // 길이 도면치수
lengthMeasured: string; // 길이 측정값
widthDesign: string; // 너비 도면치수
widthMeasured: string; // 너비 측정값
spacingPoint: string; // 너비 포인트
spacingDesign: string; // 간격 도면치수
spacingMeasured: string; // 간격 측정값
}
const DEFAULT_ROW_COUNT = 6;
export const BendingWipInspectionContent = forwardRef<InspectionContentRef, BendingWipInspectionContentProps>(function BendingWipInspectionContent({ data: order, readOnly = false }, ref) {
const fullDate = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const today = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
const documentNo = order.workOrderNo || 'ABC123';
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
// 아이템 기반 초기 행 생성
const [rows, setRows] = useState<InspectionRow[]>(() => {
const items = order.items || [];
const count = Math.max(items.length, DEFAULT_ROW_COUNT);
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
productName: items[i]?.productName || '',
processStatus: null,
lengthDesign: '4000',
lengthMeasured: '',
widthDesign: 'N/A',
widthMeasured: 'N/A',
spacingPoint: '',
spacingDesign: '380',
spacingMeasured: '',
}));
});
const [inadequateContent, setInadequateContent] = useState('');
const handleStatusChange = useCallback((rowId: number, value: CheckStatus) => {
if (readOnly) return;
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, processStatus: value } : row
));
}, [readOnly]);
const handleInputChange = useCallback((rowId: number, field: keyof InspectionRow, value: string) => {
if (readOnly) return;
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, [field]: value } : row
));
}, [readOnly]);
const handleNumericInput = useCallback((rowId: number, field: keyof InspectionRow, value: string) => {
if (readOnly) return;
const filtered = value.replace(/[^\d.]/g, '');
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, [field]: filtered } : row
));
}, [readOnly]);
// 행별 판정 자동 계산
const getRowJudgment = useCallback((row: InspectionRow): '적' | '부' | null => {
if (row.processStatus === '불량') return '부';
if (row.processStatus === '양호') return '적';
return null;
}, []);
// 종합판정 자동 계산
const overallResult = useMemo(() => {
const judgments = rows.map(getRowJudgment);
if (judgments.some(j => j === '부')) return '불합격';
if (judgments.every(j => j === '적')) return '합격';
return null;
}, [rows, getRowJudgment]);
useImperativeHandle(ref, () => ({
getInspectionData: () => ({
rows: rows.map(row => ({
id: row.id,
productName: row.productName,
processStatus: row.processStatus,
lengthMeasured: row.lengthMeasured,
widthMeasured: row.widthMeasured,
spacingPoint: row.spacingPoint,
spacingMeasured: row.spacingMeasured,
})),
inadequateContent,
overallResult,
}),
}), [rows, inadequateContent, overallResult]);
// PDF 호환 체크박스 렌더
const renderCheckbox = (checked: boolean, onClick: () => void) => (
<span
className={`inline-flex items-center justify-center w-3 h-3 border rounded-sm text-[8px] leading-none cursor-pointer select-none ${
checked ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-400 bg-white'
}`}
onClick={() => !readOnly && onClick()}
role="checkbox"
aria-checked={checked}
>
{checked ? '✓' : ''}
</span>
);
const inputClass = 'w-full text-center border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs';
return (
<div className="p-6 bg-white">
{/* ===== 헤더 영역 ===== */}
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-2xl font-bold"> </h1>
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
: {documentNo} | : {fullDate}
</p>
</div>
{/* 결재란 */}
<table className="border-collapse text-sm flex-shrink-0">
<tbody>
<tr>
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}><br/></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-3 text-center">{primaryAssignee}</td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
</tr>
</tbody>
</table>
</div>
{/* ===== 기본 정보 ===== */}
<table className="w-full border-collapse text-xs mb-6">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-24"></td>
<td className="border border-gray-400 px-3 py-2">{order.items?.[0]?.productName || '가이드레일'}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-28"> LOT NO</td>
<td className="border border-gray-400 px-3 py-2">{order.lotNo || '-'}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.items?.[0]?.specification || 'EGI 1.6T'}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"> LOT NO</td>
<td className="border border-gray-400 px-3 py-2">{order.workOrderNo || '-'}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">3,000 mm</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.items?.reduce((sum, item) => sum + item.quantity, 0) || 0} EA</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{today}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{primaryAssignee}</td>
</tr>
</tbody>
</table>
{/* ===== 중간검사 기준서 KDPS-20 ===== */}
<div className="mb-1 font-bold text-sm"> KDPS-20</div>
<table className="w-full table-fixed border-collapse text-xs mb-6">
<colgroup>
<col style={{width: '200px'}} />
<col style={{width: '52px'}} />
<col style={{width: '58px'}} />
<col />
<col style={{width: '68px'}} />
<col style={{width: '78px'}} />
<col style={{width: '120px'}} />
</colgroup>
<tbody>
<tr>
{/* 도해 영역 - 넓게 */}
<td className="border border-gray-400 p-3 text-center align-middle" rowSpan={4}>
<div className="text-xs font-medium text-gray-500 mb-2 text-left"></div>
<div className="h-32 border border-gray-300 rounded flex items-center justify-center text-gray-300 text-sm">IMG</div>
</td>
{/* 헤더 행 */}
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center" colSpan={2}></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
</tr>
{/* 겉모양 > 절곡상태 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1"> </td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={3}>n = 1, c = 0</td>
<td className="border border-gray-400 px-2 py-1">KS F 4510 5.1</td>
</tr>
{/* 치수 > 길이 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium bg-gray-50" rowSpan={2}><br/>(mm)</td>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1"> ± 4</td>
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={2}></td>
<td className="border border-gray-400 px-2 py-1">KS F 4510 7<br/>9</td>
</tr>
{/* 치수 > 간격 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1"> ± 2</td>
<td className="border border-gray-400 px-2 py-1">KS F 4510 7<br/>9 / </td>
</tr>
</tbody>
</table>
{/* ===== 중간검사 DATA ===== */}
<div className="mb-1 font-bold text-sm"> DATA</div>
<table className="w-full border-collapse text-xs mb-4">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-1 w-8" rowSpan={2}>No.</th>
<th className="border border-gray-400 p-1 w-20" rowSpan={2}></th>
<th className="border border-gray-400 p-1 w-16" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-1" colSpan={2}> (mm)</th>
<th className="border border-gray-400 p-1" colSpan={2}> (mm)</th>
<th className="border border-gray-400 p-1" colSpan={3}> (mm)</th>
<th className="border border-gray-400 p-1 w-14" rowSpan={2}><br/>(/)</th>
</tr>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-1 w-14"></th>
<th className="border border-gray-400 p-1 w-14"></th>
<th className="border border-gray-400 p-1 w-14"></th>
<th className="border border-gray-400 p-1 w-14"></th>
<th className="border border-gray-400 p-1 w-12"></th>
<th className="border border-gray-400 p-1 w-14"></th>
<th className="border border-gray-400 p-1 w-14"></th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const judgment = getRowJudgment(row);
return (
<tr key={row.id}>
<td className="border border-gray-400 p-1 text-center">{row.id}</td>
{/* 제품명 */}
<td className="border border-gray-400 p-1">
<input
type="text"
value={row.productName}
onChange={(e) => handleInputChange(row.id, 'productName', e.target.value)}
disabled={readOnly}
className={inputClass}
placeholder="-"
/>
</td>
{/* 절곡상태 - 단일 셀, 세로 체크박스 (절곡 버전 동일) */}
<td className="border border-gray-400 p-1">
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
{renderCheckbox(row.processStatus === '양호', () => handleStatusChange(row.id, row.processStatus === '양호' ? null : '양호'))}
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
{renderCheckbox(row.processStatus === '불량', () => handleStatusChange(row.id, row.processStatus === '불량' ? null : '불량'))}
</label>
</div>
</td>
{/* 길이 - 도면치수 */}
<td className="border border-gray-400 p-1 text-center">{row.lengthDesign}</td>
{/* 길이 - 측정값 */}
<td className="border border-gray-400 p-1">
<input type="text" value={row.lengthMeasured} onChange={(e) => handleNumericInput(row.id, 'lengthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 너비 - 도면치수 */}
<td className="border border-gray-400 p-1 text-center">{row.widthDesign}</td>
{/* 너비 - 측정값 */}
<td className="border border-gray-400 p-1">
<input type="text" value={row.widthMeasured} onChange={(e) => handleInputChange(row.id, 'widthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 간격 - 포인트 */}
<td className="border border-gray-400 p-1">
<input type="text" value={row.spacingPoint} onChange={(e) => handleInputChange(row.id, 'spacingPoint', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 간격 - 도면치수 */}
<td className="border border-gray-400 p-1 text-center">{row.spacingDesign}</td>
{/* 간격 - 측정값 */}
<td className="border border-gray-400 p-1">
<input type="text" value={row.spacingMeasured} onChange={(e) => handleNumericInput(row.id, 'spacingMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 판정 - 자동 계산 */}
<td className={`border border-gray-400 p-1 text-center font-bold ${
judgment === '적' ? 'text-blue-600' : judgment === '부' ? 'text-red-600' : 'text-gray-300'
}`}>
{judgment || '-'}
</td>
</tr>
);
})}
</tbody>
</table>
{/* ===== 부적합 내용 + 종합판정 ===== */}
<table className="w-full border-collapse text-xs">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-middle text-center"> </td>
<td className="border border-gray-400 px-3 py-2">
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
</td>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24"></td>
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm w-24 ${
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
}`}>
{overallResult || '합격'}
</td>
</tr>
</tbody>
</table>
</div>
);
});

View File

@@ -19,12 +19,15 @@ import type { WorkOrder, ProcessType } from '../types';
import { ScreenInspectionContent } from './ScreenInspectionContent';
import { SlatInspectionContent } from './SlatInspectionContent';
import { BendingInspectionContent } from './BendingInspectionContent';
import { BendingWipInspectionContent } from './BendingWipInspectionContent';
import { SlatJointBarInspectionContent } from './SlatJointBarInspectionContent';
import type { InspectionContentRef } from './ScreenInspectionContent';
const PROCESS_LABELS: Record<ProcessType, string> = {
screen: '스크린',
slat: '슬랫',
bending: '절곡',
bending_wip: '절곡 재공품',
};
interface InspectionReportModalProps {
@@ -33,6 +36,7 @@ interface InspectionReportModalProps {
workOrderId: string | null;
processType?: ProcessType;
readOnly?: boolean;
isJointBar?: boolean;
}
export function InspectionReportModal({
@@ -41,6 +45,7 @@ export function InspectionReportModal({
workOrderId,
processType = 'screen',
readOnly = true,
isJointBar = false,
}: InspectionReportModalProps) {
const [order, setOrder] = useState<WorkOrder | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -138,6 +143,11 @@ export function InspectionReportModal({
const processLabel = PROCESS_LABELS[processType] || '스크린';
const subtitle = order ? `${processLabel} 생산부서` : undefined;
const modalTitle = processType === 'bending_wip'
? '절곡품 재고생산 작업일지 중간검사성적서'
: (isJointBar || (order?.items?.some(item => item.productName?.includes('조인트바'))))
? '중간검사성적서 (조인트바)'
: '중간검사 성적서';
const renderContent = () => {
if (!order) return null;
@@ -146,9 +156,15 @@ export function InspectionReportModal({
case 'screen':
return <ScreenInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
case 'slat':
// 조인트바 여부 체크: isJointBar prop 또는 items에서 자동 감지
if (isJointBar || order.items?.some(item => item.productName?.includes('조인트바'))) {
return <SlatJointBarInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
}
return <SlatInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
case 'bending':
return <BendingInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
case 'bending_wip':
return <BendingWipInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
default:
return <ScreenInspectionContent ref={contentRef} data={order} readOnly={readOnly} />;
}
@@ -167,7 +183,7 @@ export function InspectionReportModal({
return (
<DocumentViewer
title="중간검사 성적서"
title={modalTitle}
subtitle={subtitle}
preset="inspection"
open={open}

View File

@@ -0,0 +1,375 @@
'use client';
/**
* 슬랫 조인트바 중간검사성적서
*
* 기획서 기준:
* - 제목: "중간검사성적서 (조인트바)"
* - 결재란: 작성/승인/승인/승인
* - 기본 정보: 제품명/슬랫, 규격/슬랫, 수주처, 현장명 / 제품 LOT NO, 부서, 검사일자, 검사자
* - ■ 중간검사 기준서 KOPS-20: 도해 IMG + 치수 기준 (43.1 ± 0.5 등)
* - ■ 중간검사 DATA: No, 가공상태, 조립상태, ①높이(기준치/측정값),
* ②높이(기준치/측정값), ③길이(기준치/측정값), ④간격(기준치/측정값), 판정
* - 부적합 내용 / 종합판정 (자동)
*/
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import type { WorkOrder } from '../types';
export interface InspectionContentRef {
getInspectionData: () => unknown;
}
interface SlatJointBarInspectionContentProps {
data: WorkOrder;
readOnly?: boolean;
}
type CheckStatus = '양호' | '불량' | null;
interface InspectionRow {
id: number;
processStatus: CheckStatus; // 가공상태
assemblyStatus: CheckStatus; // 조립상태
height1Standard: string; // ①높이 기준치
height1Measured: string; // ①높이 측정값
height2Standard: string; // ②높이 기준치
height2Measured: string; // ②높이 측정값
lengthDesign: string; // 길이 도면치수
lengthMeasured: string; // 길이 측정값
intervalStandard: string; // 간격 기준치
intervalMeasured: string; // 간격 측정값
}
const DEFAULT_ROW_COUNT = 6;
export const SlatJointBarInspectionContent = forwardRef<InspectionContentRef, SlatJointBarInspectionContentProps>(function SlatJointBarInspectionContent({ data: order, readOnly = false }, ref) {
const fullDate = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const today = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
const documentNo = order.workOrderNo || 'ABC123';
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
const [rows, setRows] = useState<InspectionRow[]>(() =>
Array.from({ length: DEFAULT_ROW_COUNT }, (_, i) => ({
id: i + 1,
processStatus: null,
assemblyStatus: null,
height1Standard: '43.1 ± 0.5',
height1Measured: '',
height2Standard: '14.5 ± 1',
height2Measured: '',
lengthDesign: '',
lengthMeasured: '',
intervalStandard: '150 ± 4',
intervalMeasured: '',
}))
);
const [inadequateContent, setInadequateContent] = useState('');
const handleStatusChange = useCallback((rowId: number, field: 'processStatus' | 'assemblyStatus', value: CheckStatus) => {
if (readOnly) return;
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, [field]: value } : row
));
}, [readOnly]);
const handleInputChange = useCallback((rowId: number, field: keyof InspectionRow, value: string) => {
if (readOnly) return;
const filtered = value.replace(/[^\d.]/g, '');
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, [field]: filtered } : row
));
}, [readOnly]);
// 행별 판정 자동 계산
const getRowJudgment = useCallback((row: InspectionRow): '적' | '부' | null => {
const { processStatus, assemblyStatus } = row;
if (processStatus === '불량' || assemblyStatus === '불량') return '부';
if (processStatus === '양호' && assemblyStatus === '양호') return '적';
return null;
}, []);
// 종합판정 자동 계산
const overallResult = useMemo(() => {
const judgments = rows.map(getRowJudgment);
if (judgments.some(j => j === '부')) return '불합격';
if (judgments.every(j => j === '적')) return '합격';
return null;
}, [rows, getRowJudgment]);
useImperativeHandle(ref, () => ({
getInspectionData: () => ({
rows: rows.map(row => ({
id: row.id,
processStatus: row.processStatus,
assemblyStatus: row.assemblyStatus,
height1Measured: row.height1Measured,
height2Measured: row.height2Measured,
lengthMeasured: row.lengthMeasured,
intervalMeasured: row.intervalMeasured,
})),
inadequateContent,
overallResult,
}),
}), [rows, inadequateContent, overallResult]);
// PDF 호환 체크박스 렌더
const renderCheckbox = (checked: boolean, onClick: () => void) => (
<span
className={`inline-flex items-center justify-center w-3 h-3 border rounded-sm text-[8px] leading-none cursor-pointer select-none ${
checked ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-400 bg-white'
}`}
onClick={() => !readOnly && onClick()}
role="checkbox"
aria-checked={checked}
>
{checked ? '✓' : ''}
</span>
);
const renderCheckStatus = (rowId: number, field: 'processStatus' | 'assemblyStatus', value: CheckStatus) => (
<td className="border border-gray-400 p-1">
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
{renderCheckbox(value === '양호', () => handleStatusChange(rowId, field, value === '양호' ? null : '양호'))}
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
{renderCheckbox(value === '불량', () => handleStatusChange(rowId, field, value === '불량' ? null : '불량'))}
</label>
</div>
</td>
);
const inputClass = 'w-full text-center border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs';
return (
<div className="p-6 bg-white">
{/* ===== 헤더 영역 ===== */}
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-2xl font-bold"> ()</h1>
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
: {documentNo} | : {fullDate}
</p>
</div>
{/* 결재란 */}
<table className="border-collapse text-sm flex-shrink-0">
<tbody>
<tr>
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}><br/></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-3 text-center">{primaryAssignee}</td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
</tr>
</tbody>
</table>
</div>
{/* ===== 기본 정보 ===== */}
<table className="w-full border-collapse text-xs mb-6">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-24"></td>
<td className="border border-gray-400 px-3 py-2"></td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-28"> LOT NO</td>
<td className="border border-gray-400 px-3 py-2">{order.lotNo || '-'}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2"></td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.department || '생산부'}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.client || '-'}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{today}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.projectName || '-'}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{primaryAssignee}</td>
</tr>
</tbody>
</table>
{/* ===== 중간검사 기준서 KOPS-20 ===== */}
<div className="mb-1 font-bold text-sm"> KOPS-20</div>
<table className="w-full border-collapse text-xs mb-6">
<tbody>
<tr>
{/* 도해 영역 */}
<td className="border border-gray-400 p-4 text-center text-gray-300 align-middle w-1/5" rowSpan={8}>
<div className="h-40 flex items-center justify-center"> </div>
</td>
{/* 헤더 행 */}
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center" colSpan={2}></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
</tr>
{/* 결모양 > 가공상태 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium text-center" rowSpan={3}></td>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1"> </td>
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={3}></td>
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={7}>n = 1, c = 0</td>
<td className="border border-gray-400 px-2 py-1">KS F 4510 5.1</td>
</tr>
{/* 결모양 > 조립상태 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium" rowSpan={2}></td>
<td className="border border-gray-400 px-2 py-1"> <br/> </td>
<td className="border border-gray-400 px-2 py-1">KS F 4510 9</td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1"> <br/> </td>
<td className="border border-gray-400 px-2 py-1"></td>
</tr>
{/* 치수 > ① 높이 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium bg-gray-50 text-center" rowSpan={4}><br/>(mm)</td>
<td className="border border-gray-400 px-2 py-1 font-medium"> </td>
<td className="border border-gray-400 px-2 py-1 text-center">43.1 ± 0.5</td>
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={4}></td>
<td className="border border-gray-400 px-2 py-1" rowSpan={3}>KS F 4510 7<br/>9</td>
</tr>
{/* 치수 > ② 높이 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium"> </td>
<td className="border border-gray-400 px-2 py-1 text-center">14.5 ± 1</td>
</tr>
{/* 치수 > 길이 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1 text-center"> ± 4</td>
</tr>
{/* 치수 > 간격 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1 text-center">150 ± 4</td>
<td className="border border-gray-400 px-2 py-1"></td>
</tr>
</tbody>
</table>
{/* ===== 중간검사 DATA ===== */}
<div className="mb-1 font-bold text-sm"> DATA</div>
<table className="w-full border-collapse text-xs mb-4">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-1 w-8" rowSpan={2}>No.</th>
<th className="border border-gray-400 p-1" colSpan={2}></th>
<th className="border border-gray-400 p-1" colSpan={2}> (mm)</th>
<th className="border border-gray-400 p-1" colSpan={2}> (mm)</th>
<th className="border border-gray-400 p-1" colSpan={2}> (mm)</th>
<th className="border border-gray-400 p-1" colSpan={2}> </th>
<th className="border border-gray-400 p-1 w-14" rowSpan={2}><br/>(/)</th>
</tr>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const judgment = getRowJudgment(row);
return (
<tr key={row.id}>
<td className="border border-gray-400 p-1 text-center">{row.id}</td>
{/* 가공상태 */}
{renderCheckStatus(row.id, 'processStatus', row.processStatus)}
{/* 조립상태 */}
{renderCheckStatus(row.id, 'assemblyStatus', row.assemblyStatus)}
{/* ① 높이 */}
<td className="border border-gray-400 p-1 text-center">{row.height1Standard}</td>
<td className="border border-gray-400 p-1">
<input type="text" value={row.height1Measured} onChange={(e) => handleInputChange(row.id, 'height1Measured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* ② 높이 */}
<td className="border border-gray-400 p-1 text-center">{row.height2Standard}</td>
<td className="border border-gray-400 p-1">
<input type="text" value={row.height2Measured} onChange={(e) => handleInputChange(row.id, 'height2Measured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 길이 */}
<td className="border border-gray-400 p-1 text-center">{row.lengthDesign || '-'}</td>
<td className="border border-gray-400 p-1">
<input type="text" value={row.lengthMeasured} onChange={(e) => handleInputChange(row.id, 'lengthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* ④ 간격 */}
<td className="border border-gray-400 p-1 text-center">{row.intervalStandard}</td>
<td className="border border-gray-400 p-1">
<input type="text" value={row.intervalMeasured} onChange={(e) => handleInputChange(row.id, 'intervalMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 판정 - 자동 계산 */}
<td className={`border border-gray-400 p-1 text-center font-bold ${
judgment === '적' ? 'text-blue-600' : judgment === '부' ? 'text-red-600' : 'text-gray-300'
}`}>
{judgment || '-'}
</td>
</tr>
);
})}
</tbody>
</table>
{/* ===== 부적합 내용 + 종합판정 ===== */}
<table className="w-full border-collapse text-xs">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-middle text-center"> </td>
<td className="border border-gray-400 px-3 py-2">
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
</td>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24"></td>
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm w-24 ${
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
}`}>
{overallResult || '합격'}
</td>
</tr>
</tbody>
</table>
</div>
);
});

View File

@@ -7,6 +7,8 @@ export { BendingWorkLogContent } from './BendingWorkLogContent';
export { ScreenInspectionContent } from './ScreenInspectionContent';
export { SlatInspectionContent } from './SlatInspectionContent';
export { BendingInspectionContent } from './BendingInspectionContent';
export { BendingWipInspectionContent } from './BendingWipInspectionContent';
export { SlatJointBarInspectionContent } from './SlatJointBarInspectionContent';
export type { InspectionContentRef } from './ScreenInspectionContent';
// 모달

View File

@@ -11,12 +11,13 @@ export interface ProcessInfo {
// @deprecated process_type은 process_id FK로 변경됨
// 하위 호환성을 위해 유지
export type ProcessType = 'screen' | 'slat' | 'bending';
export type ProcessType = 'screen' | 'slat' | 'bending' | 'bending_wip';
export const PROCESS_TYPE_LABELS: Record<ProcessType, string> = {
screen: '스크린',
slat: '슬랫',
bending: '절곡',
bending_wip: '절곡 재공품',
};
// 작업 상태

View File

@@ -70,19 +70,23 @@ export function WorkItemCard({
{item.itemCode} ({item.itemName})
</span>
</div>
<span className="text-sm text-gray-500">
{item.floor} / {item.code}
</span>
{!item.isWip && (
<span className="text-sm text-gray-500">
{item.floor} / {item.code}
</span>
)}
</div>
{/* 제작 사이즈 */}
<div className="flex items-center gap-2 text-sm text-gray-700">
<span className="text-gray-500"> </span>
<span className="font-medium">
{item.width.toLocaleString()} X {item.height.toLocaleString()} mm
</span>
<span className="font-medium">{item.quantity}</span>
</div>
{/* 제작 사이즈 (재공품은 숨김) */}
{!item.isWip && (
<div className="flex items-center gap-2 text-sm text-gray-700">
<span className="text-gray-500"> </span>
<span className="font-medium">
{item.width.toLocaleString()} X {item.height.toLocaleString()} mm
</span>
<span className="font-medium">{item.quantity}</span>
</div>
)}
{/* 공정별 추가 정보 */}
{item.processType === 'screen' && item.cuttingInfo && (
@@ -100,10 +104,14 @@ export function WorkItemCard({
/>
)}
{item.processType === 'bending' && item.bendingInfo && (
{item.processType === 'bending' && !item.isWip && item.bendingInfo && (
<BendingExtraInfo info={item.bendingInfo} />
)}
{item.isWip && item.wipInfo && (
<WipExtraInfo info={item.wipInfo} />
)}
{/* 진척률 프로그래스 바 */}
<div className="space-y-1">
<Progress value={progressPercent} className="h-2" />
@@ -240,7 +248,7 @@ function SlatExtraInfo({
}
// ===== 절곡 전용: 도면 + 공통사항 + 세부부품 =====
import type { BendingInfo } from './types';
import type { BendingInfo, WipInfo } from './types';
function BendingExtraInfo({ info }: { info: BendingInfo }) {
return (
@@ -311,3 +319,41 @@ function BendingExtraInfo({ info }: { info: BendingInfo }) {
</div>
);
}
// ===== 재공품 전용: 도면 + 공통사항 (규격, 길이별 수량) =====
function WipExtraInfo({ info }: { info: WipInfo }) {
return (
<div className="flex gap-4">
{/* 도면 이미지 (큰 영역) */}
<div className="flex-1 min-h-[160px] border rounded-lg bg-gray-50 flex items-center justify-center overflow-hidden">
{info.drawingUrl ? (
<img
src={info.drawingUrl}
alt="도면"
className="w-full h-full object-contain"
/>
) : (
<div className="flex flex-col items-center gap-1 text-gray-400">
<ImageIcon className="h-8 w-8" />
<span className="text-xs">IMG</span>
</div>
)}
</div>
{/* 공통사항 */}
<div className="flex-1 space-y-0">
<p className="text-xs font-medium text-gray-500 mb-2"></p>
<div className="border rounded-lg divide-y">
<div className="flex">
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r"></span>
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.specification}</span>
</div>
<div className="flex">
<span className="px-3 py-2.5 text-xs text-gray-500 bg-gray-50 w-20 flex-shrink-0 border-r"> </span>
<span className="px-3 py-2.5 text-xs font-semibold text-gray-900 flex-1 text-right">{info.lengthQuantity}</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -166,8 +166,71 @@ const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
],
};
// 절곡 재공품 전용 목업 데이터 (토글로 전환)
const MOCK_ITEMS_BENDING_WIP: WorkItemData[] = [
{
id: 'mock-bw1', itemNo: 1, itemCode: 'KWWS03', itemName: '케이스 - 전면부', floor: '-', code: '-',
width: 0, height: 0, quantity: 6, processType: 'bending',
isWip: true,
wipInfo: { specification: 'EGI 1.55T (W576)', lengthQuantity: '4,000mm X 6개' },
steps: [
{ id: 'bw1-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'bw1-2', name: '절단', isMaterialInput: false, isCompleted: false },
],
},
{
id: 'mock-bw2', itemNo: 2, itemCode: 'KWWS04', itemName: '케이스 - 후면부', floor: '-', code: '-',
width: 0, height: 0, quantity: 4, processType: 'bending',
isWip: true,
wipInfo: { specification: 'EGI 1.55T (W576)', lengthQuantity: '3,500mm X 4개' },
steps: [
{ id: 'bw2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'bw2-2', name: '절단', isMaterialInput: false, isCompleted: false },
],
},
{
id: 'mock-bw3', itemNo: 3, itemCode: 'KWWS05', itemName: '하단마감재', floor: '-', code: '-',
width: 0, height: 0, quantity: 10, processType: 'bending',
isWip: true,
wipInfo: { specification: 'EGI 1.2T (W400)', lengthQuantity: '2,800mm X 10개' },
steps: [
{ id: 'bw3-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'bw3-2', name: '절단', isMaterialInput: false, isCompleted: false },
],
},
];
// 슬랫 조인트바 전용 목업 데이터 (토글로 전환)
const MOCK_ITEMS_SLAT_JOINTBAR: WorkItemData[] = [
{
id: 'mock-jb1', itemNo: 1, itemCode: 'KQJB01', itemName: '조인트바 A', floor: '1층', code: 'FSS-01',
width: 0, height: 0, quantity: 8, processType: 'slat',
isJointBar: true,
slatJointBarInfo: { specification: 'EGI 1.6T', length: 3910, quantity: 8 },
steps: [
{ id: 'jb1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 'jb1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'jb1-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'mjb1', lotNo: 'LOT-2026-020', itemName: '조인트바 코일', quantity: 100, unit: 'kg' },
],
},
{
id: 'mock-jb2', itemNo: 2, itemCode: 'KQJB02', itemName: '조인트바 B', floor: '2층', code: 'FSS-02',
width: 0, height: 0, quantity: 12, processType: 'slat',
isJointBar: true,
slatJointBarInfo: { specification: 'EGI 1.6T', length: 5200, quantity: 12 },
steps: [
{ id: 'jb2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'jb2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'jb2-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
];
// 하드코딩된 공정별 단계 폴백
const PROCESS_STEPS: Record<ProcessTab, { name: string; isMaterialInput: boolean }[]> = {
const PROCESS_STEPS: Record<string, { name: string; isMaterialInput: boolean }[]> = {
screen: [
{ name: '자재투입', isMaterialInput: true },
{ name: '절단', isMaterialInput: false },
@@ -185,6 +248,10 @@ const PROCESS_STEPS: Record<ProcessTab, { name: string; isMaterialInput: boolean
{ name: '절곡', isMaterialInput: false },
{ name: '포장완료', isMaterialInput: false },
],
bending_wip: [
{ name: '자재투입', isMaterialInput: true },
{ name: '절단', isMaterialInput: false },
],
};
export default function WorkerScreen() {
@@ -193,6 +260,8 @@ export default function WorkerScreen() {
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<ProcessTab>('screen');
const [bendingSubMode, setBendingSubMode] = useState<'normal' | 'wip'>('normal');
const [slatSubMode, setSlatSubMode] = useState<'normal' | 'jointbar'>('normal');
// 작업 정보
const [productionManagerId, setProductionManagerId] = useState('');
@@ -305,13 +374,27 @@ export default function WorkerScreen() {
});
// 목업 데이터 합치기 (API 데이터 뒤에 번호 이어서)
const mockItems = MOCK_ITEMS[activeTab].map((item, i) => ({
// 절곡 탭에서 재공품 서브모드면 WIP 전용 목업 사용
// 슬랫 탭에서 조인트바 서브모드면 조인트바 전용 목업 사용
const baseMockItems = (activeTab === 'bending' && bendingSubMode === 'wip')
? MOCK_ITEMS_BENDING_WIP
: (activeTab === 'slat' && slatSubMode === 'jointbar')
? MOCK_ITEMS_SLAT_JOINTBAR
: MOCK_ITEMS[activeTab];
const mockItems = baseMockItems.map((item, i) => ({
...item,
itemNo: apiItems.length + i + 1,
steps: item.steps.map((step) => {
const stepKey = `${item.id}-${step.name}`;
return {
...step,
isCompleted: stepCompletionMap[stepKey] ?? step.isCompleted,
};
}),
}));
return [...apiItems, ...mockItems];
}, [filteredWorkOrders, activeTab, stepCompletionMap]);
}, [filteredWorkOrders, activeTab, stepCompletionMap, bendingSubMode, slatSubMode]);
// ===== 수주 정보 (첫 번째 작업 기반 표시) =====
const orderInfo = useMemo(() => {
@@ -509,6 +592,27 @@ export default function WorkerScreen() {
}
}, [getTargetOrder]);
// ===== 재공품 감지 =====
const hasWipItems = useMemo(() => {
return activeTab === 'bending' && workItems.some(item => item.isWip);
}, [activeTab, workItems]);
// ===== 조인트바 감지 =====
const hasJointBarItems = useMemo(() => {
return activeTab === 'slat' && slatSubMode === 'jointbar';
}, [activeTab, slatSubMode]);
// 재공품 통합 문서 (작업일지 + 중간검사) 핸들러
const handleWipInspection = useCallback(() => {
const target = getTargetOrder();
if (target) {
setSelectedOrder(target);
setIsInspectionModalOpen(true);
} else {
toast.error('표시할 작업이 없습니다.');
}
}, [getTargetOrder]);
return (
<PageLayout>
<div className="space-y-6 pb-20">
@@ -521,7 +625,9 @@ export default function WorkerScreen() {
<ClipboardList className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold"> </h1>
<h1 className="text-2xl font-bold">
{hasJointBarItems ? '슬랫 조인트바 공정' : hasWipItems ? '절곡 재공품 공정' : '작업자 화면'}
</h1>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
@@ -571,6 +677,68 @@ export default function WorkerScreen() {
/>
</div>
{/* 슬랫 탭: 슬랫/조인트바 전환 토글 */}
{tab === 'slat' && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 p-1 bg-gray-100 rounded-lg w-fit">
<button
type="button"
onClick={() => setSlatSubMode('normal')}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
slatSubMode === 'normal'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}`}
>
</button>
<button
type="button"
onClick={() => setSlatSubMode('jointbar')}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
slatSubMode === 'jointbar'
? 'bg-blue-500 text-white shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}`}
>
</button>
</div>
<span className="text-xs text-gray-400">* </span>
</div>
)}
{/* 절곡 탭: 절곡/재공품 전환 토글 */}
{tab === 'bending' && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 p-1 bg-gray-100 rounded-lg w-fit">
<button
type="button"
onClick={() => setBendingSubMode('normal')}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
bendingSubMode === 'normal'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}`}
>
</button>
<button
type="button"
onClick={() => setBendingSubMode('wip')}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
bendingSubMode === 'wip'
? 'bg-orange-500 text-white shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}`}
>
</button>
</div>
<span className="text-xs text-gray-400">* </span>
</div>
)}
{/* 수주 정보 섹션 */}
<Card>
<CardContent className="p-4">
@@ -661,19 +829,32 @@ export default function WorkerScreen() {
{/* 하단 고정 버튼 - DetailActions 패턴 적용 */}
<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]'}`}>
<div className="flex gap-3">
<Button
variant="outline"
onClick={handleWorkLog}
className="flex-1 py-6 text-base font-medium"
>
</Button>
<Button
onClick={handleInspection}
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
>
</Button>
{hasWipItems ? (
// 재공품: 통합 버튼 1개
<Button
onClick={handleWipInspection}
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
>
</Button>
) : (
// 일반/조인트바: 버튼 2개
<>
<Button
variant="outline"
onClick={handleWorkLog}
className="flex-1 py-6 text-base font-medium"
>
</Button>
<Button
onClick={handleInspection}
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
>
</Button>
</>
)}
</div>
</div>
@@ -706,8 +887,9 @@ export default function WorkerScreen() {
open={isInspectionModalOpen}
onOpenChange={setIsInspectionModalOpen}
workOrderId={selectedOrder?.id || null}
processType={activeTab}
processType={hasWipItems ? 'bending_wip' : activeTab}
readOnly={false}
isJointBar={hasJointBarItems}
/>
<IssueReportModal

View File

@@ -41,16 +41,29 @@ export interface WorkItemData {
quantity: number; // 수량
processType: ProcessTab; // 공정 타입
steps: WorkStepData[]; // 공정 단계들
isWip?: boolean; // 재공품 여부
isJointBar?: boolean; // 조인트바 여부
// 스크린 전용
cuttingInfo?: CuttingInfo;
// 슬랫 전용
slatInfo?: SlatInfo;
// 슬랫 조인트바 전용
slatJointBarInfo?: SlatJointBarInfo;
// 절곡 전용
bendingInfo?: BendingInfo;
// 재공품 전용
wipInfo?: WipInfo;
// 자재 투입 목록
materialInputs?: MaterialListItem[];
}
// ===== 재공품 전용 정보 =====
export interface WipInfo {
drawingUrl?: string; // 도면 이미지 URL
specification: string; // 규격 (EGI 1.55T (W576))
lengthQuantity: string; // 길이별 수량 (4,000mm X 6개)
}
// ===== 절단 정보 (스크린 전용) =====
export interface CuttingInfo {
width: number; // 절단 폭 (mm)
@@ -64,6 +77,13 @@ export interface SlatInfo {
jointBar: number; // 조인트바 개수
}
// ===== 슬랫 조인트바 전용 정보 =====
export interface SlatJointBarInfo {
specification: string; // 규격 (예: EGI 1.6T)
length: number; // 길이 (mm)
quantity: number; // 수량
}
// ===== 절곡 전용 정보 =====
export interface BendingInfo {
drawingUrl?: string; // 도면 이미지 URL

View File

@@ -104,6 +104,15 @@ export function InspectionReportDocument({ data }: InspectionReportDocumentProps
return '합격';
}, [items, judgmentCoverage.covered]);
// 측정값 변경 핸들러
const handleMeasuredValueChange = useCallback((flatIdx: number, value: string) => {
setItems(prev => {
const next = [...prev];
next[flatIdx] = { ...next[flatIdx], measuredValue: value };
return next;
});
}, []);
// 판정 클릭 핸들러
const handleJudgmentClick = useCallback((flatIdx: number, value: '적합' | '부적합') => {
setItems(prev => {
@@ -349,6 +358,20 @@ export function InspectionReportDocument({ data }: InspectionReportDocumentProps
if (measuredValueCoverage.covered.has(flatIdx)) {
return null;
}
// 편집 가능한 측정값 셀 → input 렌더
if (row.editable) {
return (
<td className="border border-gray-400 px-0.5 py-0.5 text-center">
<input
type="text"
value={row.measuredValue || ''}
onChange={(e) => handleMeasuredValueChange(flatIdx, e.target.value)}
className="w-full h-full px-1 py-0.5 text-center text-[11px] border border-gray-300 rounded-sm focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-200"
placeholder=""
/>
</td>
);
}
return (
<td className="border border-gray-400 px-2 py-1 text-center">
{row.measuredValue || ''}

View File

@@ -430,10 +430,10 @@ export const mockReportInspectionItems: ReportInspectionItem[] = [
// 3. 재질
{ no: 3, category: '재질', criteria: 'WY-SC780 인쇄상태 확인', method: '', frequency: '' },
// 4. 치수(오픈사이즈) (4개 세부항목) — 항목4만 병합: 체크검사/전수검사 (4행)
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '길이', criteria: '수주 치수 ± 30mm', method: '체크검사', frequency: '전수검사', methodSpan: 4, freqSpan: 4 },
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '높이', criteria: '수주 치수 ± 30mm', method: '', frequency: '' },
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '가이드레일 간격', criteria: '10 ± 5mm (측정부위 @ 높이 100 이하)', method: '', frequency: '' },
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '하단막대 간격', criteria: '간격 (③+④)\n가이드레일과 하단마감재 측 사이 25mm 이내', method: '', frequency: '' },
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '길이', criteria: '수주 치수 ± 30mm', method: '체크검사', frequency: '전수검사', methodSpan: 4, freqSpan: 4, editable: true },
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '높이', criteria: '수주 치수 ± 30mm', method: '', frequency: '', editable: true },
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '가이드레일 간격', criteria: '10 ± 5mm (측정부위 @ 높이 100 이하)', method: '', frequency: '', editable: true },
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '하단막대 간격', criteria: '간격 (③+④)\n가이드레일과 하단마감재 측 사이 25mm 이내', method: '', frequency: '', editable: true },
// 5. 작동테스트 — 판정 없음
{ no: 5, category: '작동테스트', subCategory: '개폐성능', criteria: '작동 유무 확인\n(일부 및 완전폐쇄)', method: '', frequency: '', hideJudgment: true },
// 6. 내화시험 (3개 세부항목) — "비차열\n차열성" 3행 병합, 항목 6+7+8+9 검사방법/주기/판정 모두 병합 (10행)

View File

@@ -211,6 +211,7 @@ export interface ReportInspectionItem {
freqSpan?: number; // 검사주기 셀 rowSpan (크로스그룹 병합)
judgmentSpan?: number; // 판정 셀 rowSpan (크로스그룹 병합, 예: 항목6+7+8+9)
hideJudgment?: boolean; // 판정 표시 안함 (빈 셀 렌더)
editable?: boolean; // 측정값 입력 가능 여부
}
// 제품검사성적서

View File

@@ -0,0 +1,91 @@
'use client';
/**
* 메모 모달
* - 선택한 항목 정보 표시 (N건, 총 N개소)
* - 메모 textarea
* - 취소/작성 버튼 (일괄 적용)
*/
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
interface MemoModalProps {
isOpen: boolean;
onClose: () => void;
selectedCount: number;
totalLocations: number;
onSubmit: (memo: string) => void;
}
export function MemoModal({
isOpen,
onClose,
selectedCount,
totalLocations,
onSubmit,
}: MemoModalProps) {
const [memo, setMemo] = useState('');
const handleSubmit = () => {
onSubmit(memo);
setMemo('');
onClose();
};
const handleClose = () => {
setMemo('');
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* 선택 항목 정보 */}
<div className="flex items-center gap-4 text-sm text-muted-foreground bg-muted/50 rounded-lg px-4 py-3">
<span>: <strong className="text-foreground">{selectedCount}</strong></span>
<span className="text-border">|</span>
<span> : <strong className="text-foreground">{totalLocations}</strong></span>
</div>
{/* 메모 입력 */}
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Textarea
placeholder="메모를 입력하세요"
value={memo}
onChange={(e) => setMemo(e.target.value)}
rows={5}
className="resize-none"
/>
<p className="text-xs text-muted-foreground">
{selectedCount} .
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
</Button>
<Button onClick={handleSubmit}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,638 @@
'use client';
/**
* 실적신고 목록 페이지
*
* 탭 2개 (above-stats 위치):
* - 분기별 실적신고: 연도+분기 버튼 필터, 검색, 통계 카드 4개, 액션 버튼, 테이블
* - 누락체크: 설명 박스 + 누락 목록 테이블
*
* UniversalListPage 공통 템플릿 사용
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
FileText,
ClipboardList,
CheckCircle2,
XCircle,
MapPin,
Check,
Undo2,
Send,
Pencil,
Download,
AlertTriangle,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
UniversalListPage,
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
type StatCard,
type ListParams,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { MemoModal } from './MemoModal';
import {
getPerformanceReports,
getPerformanceReportStats,
getMissedReports,
confirmReports,
unconfirmReports,
distributeReports,
updateMemo,
} from './actions';
import { confirmStatusColorMap } from './mockData';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type {
PerformanceReportStats,
ReportListItem,
Quarter,
} from './types';
const ITEMS_PER_PAGE = 20;
export function PerformanceReportList() {
// ===== 통계 =====
const [statsData, setStatsData] = useState<PerformanceReportStats>({
totalCount: 0,
confirmedCount: 0,
unconfirmedCount: 0,
totalLocations: 0,
});
// ===== 연도/분기 필터 =====
const currentYear = new Date().getFullYear();
const [year, setYear] = useState(currentYear);
const [quarter, setQuarter] = useState<Quarter | '전체'>('전체');
// ===== 메모 모달 =====
const [isMemoModalOpen, setIsMemoModalOpen] = useState(false);
const [memoSelectedIds, setMemoSelectedIds] = useState<string[]>([]);
const [memoTotalLocations, setMemoTotalLocations] = useState(0);
// ===== 리프레시 트리거 =====
const [refreshKey, setRefreshKey] = useState(0);
// ===== 현재 탭 추적 (headerActions에서 사용) =====
const [currentTab, setCurrentTab] = useState('quarterly');
// ===== 현재 데이터 추적 (메모 모달 개소 계산용) =====
const [currentData, setCurrentData] = useState<ReportListItem[]>([]);
// ===== 연도 옵션 =====
const yearOptions = useMemo(() => {
const years = [];
for (let y = currentYear; y >= currentYear - 5; y--) {
years.push(y);
}
return years;
}, [currentYear]);
// ===== 통계 로드 =====
useEffect(() => {
const loadStats = async () => {
try {
const result = await getPerformanceReportStats({ year, quarter });
if (result.success && result.data) {
setStatsData(result.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PerformanceReportList] loadStats error:', error);
}
};
loadStats();
}, [year, quarter, refreshKey]);
// ===== 분기 버튼 클릭 =====
const handleQuarterChange = useCallback((q: Quarter | '전체') => {
setQuarter(q);
}, []);
// ===== 액션 핸들러 =====
const handleConfirm = useCallback(async (
selectedItems: Set<string>,
onClearSelection: () => void,
onRefresh: () => void
) => {
if (selectedItems.size === 0) {
toast.error('확정할 항목을 선택해주세요.');
return;
}
try {
const result = await confirmReports(Array.from(selectedItems));
if (result.success) {
toast.success(`${selectedItems.size}건이 확정되었습니다.`);
onClearSelection();
onRefresh();
setRefreshKey((k) => k + 1);
} else {
toast.error(result.error || '확정 처리에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error('서버 오류가 발생했습니다.');
}
}, []);
const handleUnconfirm = useCallback(async (
selectedItems: Set<string>,
onClearSelection: () => void,
onRefresh: () => void
) => {
if (selectedItems.size === 0) {
toast.error('확정 해제할 항목을 선택해주세요.');
return;
}
try {
const result = await unconfirmReports(Array.from(selectedItems));
if (result.success) {
toast.success(`${selectedItems.size}건의 확정이 해제되었습니다.`);
onClearSelection();
onRefresh();
setRefreshKey((k) => k + 1);
} else {
toast.error(result.error || '확정 해제에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error('서버 오류가 발생했습니다.');
}
}, []);
const handleDistribute = useCallback(async (
selectedItems: Set<string>,
onClearSelection: () => void,
onRefresh: () => void
) => {
if (selectedItems.size === 0) {
toast.error('배포할 항목을 선택해주세요.');
return;
}
try {
const result = await distributeReports(Array.from(selectedItems));
if (result.success) {
toast.success(`${selectedItems.size}건이 배포되었습니다.`);
onClearSelection();
onRefresh();
setRefreshKey((k) => k + 1);
} else {
toast.error(result.error || '배포에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error('서버 오류가 발생했습니다.');
}
}, []);
const handleOpenMemoModal = useCallback((selectedItems: Set<string>) => {
const ids = Array.from(selectedItems);
if (ids.length === 0) {
toast.error('메모를 작성할 항목을 선택해주세요.');
return;
}
setMemoSelectedIds(ids);
const totalLoc = currentData
.filter((r) => selectedItems.has(r.id))
.reduce((sum, r) => sum + r.locationCount, 0);
setMemoTotalLocations(totalLoc);
setIsMemoModalOpen(true);
}, [currentData]);
const handleMemoSubmit = useCallback(async (memo: string) => {
if (memoSelectedIds.length === 0) return;
try {
const result = await updateMemo(memoSelectedIds, memo);
if (result.success) {
toast.success(`${memoSelectedIds.length}건에 메모가 적용되었습니다.`);
setRefreshKey((k) => k + 1);
} else {
toast.error(result.error || '메모 저장에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error('서버 오류가 발생했습니다.');
}
}, [memoSelectedIds]);
const handleExcelDownload = useCallback(() => {
toast.info('확정건 엑셀 다운로드 기능은 API 연동 후 활성화됩니다.');
}, []);
// ===== 통계 카드 =====
const stats: StatCard[] = useMemo(
() => [
{
label: '전체',
value: statsData.totalCount,
icon: ClipboardList,
iconColor: 'text-gray-600',
},
{
label: '확정',
value: statsData.confirmedCount,
icon: CheckCircle2,
iconColor: 'text-green-600',
},
{
label: '미확정',
value: statsData.unconfirmedCount,
icon: XCircle,
iconColor: 'text-orange-600',
},
{
label: '총 개소',
value: statsData.totalLocations,
icon: MapPin,
iconColor: 'text-blue-600',
},
],
[statsData]
);
// ===== 연도/분기 필터 슬롯 (dateRangeSelector.extraActions) =====
const quarterFilterSlot = useMemo(
() => (
<div className="flex items-center gap-2 flex-wrap order-first">
<Select value={String(year)} onValueChange={(v) => setYear(Number(v))}>
<SelectTrigger className="w-[100px] h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{yearOptions.map((y) => (
<SelectItem key={y} value={String(y)}>
{y}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-1">
{(['전체', 'Q1', 'Q2', 'Q3', 'Q4'] as const).map((q) => (
<Button
key={q}
size="sm"
variant={quarter === q ? 'default' : 'outline'}
className="h-8 px-3 text-xs"
onClick={() => handleQuarterChange(q)}
>
{q === '전체' ? '전체' : `${q.replace('Q', '')}분기`}
</Button>
))}
</div>
</div>
),
[year, quarter, yearOptions, handleQuarterChange]
);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<ReportListItem> = useMemo(
() => ({
title: '실적신고 목록',
description: '분기별 실적신고 및 누락체크를 관리합니다',
icon: FileText,
basePath: '/quality/performance-reports',
idField: 'id',
detailMode: 'none' as const,
// API 액션
actions: {
getList: async (params?: ListParams) => {
try {
const tab = params?.tab || 'quarterly';
if (tab === 'missed') {
const result = await getMissedReports({
page: params?.page || 1,
size: params?.pageSize || ITEMS_PER_PAGE,
q: params?.search || undefined,
});
if (result.success) {
const mapped: ReportListItem[] = result.data.map((r) => ({
id: r.id,
qualityDocNumber: r.qualityDocNumber,
siteName: r.siteName,
client: r.client,
locationCount: r.locationCount,
memo: r.memo,
inspectionCompleteDate: r.inspectionCompleteDate,
}));
return {
success: true,
data: mapped,
totalCount: result.pagination.total,
totalPages: result.pagination.lastPage,
};
}
return { success: false, error: result.error };
}
// quarterly tab
const result = await getPerformanceReports({
page: params?.page || 1,
size: params?.pageSize || ITEMS_PER_PAGE,
q: params?.search || undefined,
year,
quarter,
});
if (result.success) {
// 통계 갱신
const statsResult = await getPerformanceReportStats({ year, quarter });
if (statsResult.success && statsResult.data) {
setStatsData(statsResult.data);
}
const mapped: ReportListItem[] = result.data.map((r) => ({
id: r.id,
qualityDocNumber: r.qualityDocNumber,
siteName: r.siteName,
client: r.client,
locationCount: r.locationCount,
memo: r.memo,
createdDate: r.createdDate,
requiredInfo: r.requiredInfo,
confirmStatus: r.confirmStatus,
confirmDate: r.confirmDate,
year: r.year,
quarter: r.quarter,
}));
return {
success: true,
data: mapped,
totalCount: result.pagination.total,
totalPages: result.pagination.lastPage,
};
}
return { success: false, error: result.error };
} catch (error) {
if (isNextRedirectError(error)) throw error;
return { success: false, error: '데이터 로드 중 오류가 발생했습니다.' };
}
},
},
// 탭
tabs: [
{ value: 'quarterly', label: '분기별 실적신고', count: 0 },
{ value: 'missed', label: '누락체크', count: 0 },
],
defaultTab: 'quarterly',
tabsPosition: 'above-stats',
// 탭별 컬럼
columnsPerTab: {
quarterly: [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'qualityDocNumber', label: '품질관리서 번호', className: 'min-w-[130px]' },
{ key: 'createdDate', label: '작성일', className: 'w-[100px]' },
{ key: 'siteName', label: '현장명', className: 'min-w-[120px]' },
{ key: 'client', label: '수주처', className: 'min-w-[80px]' },
{ key: 'locationCount', label: '개소', className: 'w-[60px] text-center' },
{ key: 'requiredInfo', label: '필수정보', className: 'w-[90px] text-center' },
{ key: 'confirmStatus', label: '확정상태', className: 'w-[80px] text-center' },
{ key: 'confirmDate', label: '확정일', className: 'w-[100px]' },
{ key: 'memo', label: '메모', className: 'min-w-[120px]' },
],
missed: [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'qualityDocNumber', label: '품질관리서 번호', className: 'min-w-[130px]' },
{ key: 'siteName', label: '현장명', className: 'min-w-[120px]' },
{ key: 'client', label: '수주처', className: 'min-w-[80px]' },
{ key: 'locationCount', label: '개소', className: 'w-[60px] text-center' },
{ key: 'inspectionCompleteDate', label: '제품검사완료일', className: 'w-[120px]' },
{ key: 'memo', label: '메모', className: 'min-w-[120px]' },
],
},
columns: [], // columnsPerTab 사용
// 날짜 범위 선택기 → 연도/분기 필터
dateRangeSelector: {
enabled: true,
hideDateInputs: true,
showPresets: false,
extraActions: quarterFilterSlot,
},
// 통계 (quarterly 탭만)
stats: currentTab === 'quarterly' ? stats : undefined,
// 검색
searchPlaceholder: '품질관리서 번호, 현장명, 수주처 검색...',
clientSideFiltering: false,
itemsPerPage: ITEMS_PER_PAGE,
// 체크박스 항상 표시
showCheckbox: true,
// 누락체크 탭: 경고 배너
beforeTableContent: currentTab === 'missed' ? (
<div className="flex items-start gap-3 p-4 rounded-lg border border-amber-200 bg-amber-50">
<AlertTriangle className="h-5 w-5 text-amber-600 mt-0.5 shrink-0" />
<div className="text-sm text-amber-800 space-y-1">
<p className="font-medium"> </p>
<p>
.
.
</p>
</div>
</div>
) : undefined,
// 헤더 액션 (선택 기반 버튼)
headerActions: ({ selectedItems, onClearSelection, onRefresh }) => {
if (currentTab !== 'quarterly') return null;
return (
<div className="flex items-center gap-2 flex-wrap">
{selectedItems.size > 0 && (
<>
<Button size="sm" onClick={() => handleConfirm(selectedItems, onClearSelection, onRefresh)}>
<Check className="h-4 w-4 mr-1" />
</Button>
<Button size="sm" variant="outline" onClick={() => handleUnconfirm(selectedItems, onClearSelection, onRefresh)}>
<Undo2 className="h-4 w-4 mr-1" />
</Button>
<Button size="sm" variant="outline" onClick={() => handleDistribute(selectedItems, onClearSelection, onRefresh)}>
<Send className="h-4 w-4 mr-1" />
</Button>
<Button size="sm" variant="outline" onClick={() => handleOpenMemoModal(selectedItems)}>
<Pencil className="h-4 w-4 mr-1" />
</Button>
</>
)}
<Button size="sm" variant="outline" onClick={handleExcelDownload}>
<Download className="h-4 w-4 mr-1" />
</Button>
</div>
);
},
// 데이터 변경 콜백 (메모 모달용)
onDataChange: (data) => {
setCurrentData(data);
},
// 테이블 행 렌더링
renderTableRow: (
item: ReportListItem,
_index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<ReportListItem>
) => {
// quarterly 탭 렌더링
if (item.createdDate !== undefined) {
const isRequiredMissing = item.requiredInfo && item.requiredInfo !== '입력완료';
return (
<TableRow
key={item.id}
className={handlers.isSelected ? 'bg-blue-50' : ''}
>
<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-medium">{item.qualityDocNumber}</TableCell>
<TableCell>{item.createdDate}</TableCell>
<TableCell>{item.siteName}</TableCell>
<TableCell>{item.client}</TableCell>
<TableCell className="text-center">{item.locationCount}</TableCell>
<TableCell className="text-center">
{item.requiredInfo && (
<span className={isRequiredMissing ? 'text-red-600 font-medium' : 'text-green-600'}>
{item.requiredInfo}
</span>
)}
</TableCell>
<TableCell className="text-center">
{item.confirmStatus && (
<Badge className={`text-xs ${confirmStatusColorMap[item.confirmStatus]} border-0`}>
{item.confirmStatus}
</Badge>
)}
</TableCell>
<TableCell>{item.confirmDate || '-'}</TableCell>
<TableCell className="text-muted-foreground text-sm truncate max-w-[200px]">
{item.memo || '-'}
</TableCell>
</TableRow>
);
}
// missed 탭 렌더링
return (
<TableRow
key={item.id}
className={handlers.isSelected ? 'bg-blue-50' : ''}
>
<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-medium">{item.qualityDocNumber}</TableCell>
<TableCell>{item.siteName}</TableCell>
<TableCell>{item.client}</TableCell>
<TableCell className="text-center">{item.locationCount}</TableCell>
<TableCell>{item.inspectionCompleteDate}</TableCell>
<TableCell className="text-muted-foreground text-sm truncate max-w-[200px]">
{item.memo || '-'}
</TableCell>
</TableRow>
);
},
// 모바일 카드 렌더링
renderMobileCard: (
item: ReportListItem,
_index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<ReportListItem>
) => {
const isQuarterly = item.createdDate !== undefined;
return (
<ListMobileCard
key={item.id}
id={item.id}
isSelected={handlers.isSelected}
onToggleSelection={handlers.onToggle}
headerBadges={
<>
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
<Badge variant="outline" className="text-xs">{item.qualityDocNumber}</Badge>
</>
}
title={item.siteName}
statusBadge={
isQuarterly && item.confirmStatus ? (
<Badge className={`text-xs ${confirmStatusColorMap[item.confirmStatus]} border-0`}>
{item.confirmStatus}
</Badge>
) : undefined
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="수주처" value={item.client} />
<InfoField label="개소" value={String(item.locationCount)} />
{isQuarterly ? (
<>
<InfoField label="작성일" value={item.createdDate || '-'} />
<InfoField label="필수정보" value={item.requiredInfo || '-'} />
<InfoField label="확정상태" value={item.confirmStatus || '-'} />
<InfoField label="확정일" value={item.confirmDate || '-'} />
</>
) : (
<InfoField label="제품검사완료일" value={item.inspectionCompleteDate || '-'} />
)}
<InfoField label="메모" value={item.memo || '-'} />
</div>
}
/>
);
},
}),
[stats, quarterFilterSlot, currentTab, year, quarter, refreshKey, handleConfirm, handleUnconfirm, handleDistribute, handleOpenMemoModal, handleExcelDownload]
);
return (
<>
<UniversalListPage
config={config}
onTabChange={(tab) => setCurrentTab(tab)}
/>
{/* 메모 모달 */}
<MemoModal
isOpen={isMemoModalOpen}
onClose={() => setIsMemoModalOpen(false)}
selectedCount={memoSelectedIds.length}
totalLocations={memoTotalLocations}
onSubmit={handleMemoSubmit}
/>
</>
);
}

View File

@@ -0,0 +1,410 @@
'use server';
/**
* 실적신고관리 Server Actions
*
* API Endpoints:
* - GET /api/v1/performance-reports - 분기별 실적신고 목록
* - GET /api/v1/performance-reports/stats - 통계
* - GET /api/v1/performance-reports/missed - 누락체크 목록
* - PATCH /api/v1/performance-reports/confirm - 선택 확정
* - PATCH /api/v1/performance-reports/unconfirm - 확정 해제
* - POST /api/v1/performance-reports/distribute - 배포
* - PATCH /api/v1/performance-reports/memo - 메모 일괄 적용
*/
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type {
PerformanceReport,
PerformanceReportStats,
MissedReport,
Quarter,
} from './types';
import {
mockPerformanceReports,
mockPerformanceReportStats,
mockMissedReports,
} from './mockData';
// 개발환경 Mock 데이터 fallback 플래그
const USE_MOCK_FALLBACK = true;
// ===== 페이지네이션 =====
interface PaginationMeta {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
}
const API_BASE = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/performance-reports`;
// ===== 분기별 실적신고 목록 조회 =====
export async function getPerformanceReports(params?: {
page?: number;
size?: number;
q?: string;
year?: number;
quarter?: Quarter | '전체';
}): Promise<{
success: boolean;
data: PerformanceReport[];
pagination: PaginationMeta;
error?: string;
__authError?: boolean;
}> {
const defaultPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
try {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
if (params?.size) searchParams.set('per_page', String(params.size));
if (params?.q) searchParams.set('q', params.q);
if (params?.year) searchParams.set('year', String(params.year));
if (params?.quarter && params.quarter !== '전체') {
searchParams.set('quarter', params.quarter);
}
const queryString = searchParams.toString();
const url = `${API_BASE}${queryString ? `?${queryString}` : ''}`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error || !response || !response.ok) {
if (USE_MOCK_FALLBACK) {
console.warn('[PerformanceReportActions] API 실패, Mock 데이터 사용');
let filtered = [...mockPerformanceReports];
if (params?.year) {
filtered = filtered.filter(i => i.year === params.year);
}
if (params?.quarter && params.quarter !== '전체') {
filtered = filtered.filter(i => i.quarter === params.quarter);
}
if (params?.q) {
const q = params.q.toLowerCase();
filtered = filtered.filter(i =>
i.siteName.toLowerCase().includes(q) ||
i.client.toLowerCase().includes(q) ||
i.qualityDocNumber.toLowerCase().includes(q)
);
}
const page = params?.page || 1;
const size = params?.size || 20;
const start = (page - 1) * size;
const paged = filtered.slice(start, start + size);
return {
success: true,
data: paged,
pagination: { currentPage: page, lastPage: Math.ceil(filtered.length / size), perPage: size, total: filtered.length },
};
}
const errMsg = error ? error.message : `API 오류: ${response?.status || 'no response'}`;
return { success: false, data: [], pagination: defaultPagination, error: errMsg, __authError: error?.code === 'UNAUTHORIZED' };
}
const result = await response.json();
if (!result.success) {
return { success: false, data: [], pagination: defaultPagination, error: result.message || '목록 조회 실패' };
}
return {
success: true,
data: result.data?.items || [],
pagination: {
currentPage: result.data?.current_page || 1,
lastPage: result.data?.last_page || 1,
perPage: result.data?.per_page || 20,
total: result.data?.total || 0,
},
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PerformanceReportActions] getPerformanceReports error:', error);
if (USE_MOCK_FALLBACK) {
return {
success: true,
data: mockPerformanceReports,
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: mockPerformanceReports.length },
};
}
return { success: false, data: [], pagination: defaultPagination, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 통계 조회 =====
export async function getPerformanceReportStats(params?: {
year?: number;
quarter?: Quarter | '전체';
}): Promise<{
success: boolean;
data?: PerformanceReportStats;
error?: string;
__authError?: boolean;
}> {
try {
const searchParams = new URLSearchParams();
if (params?.year) searchParams.set('year', String(params.year));
if (params?.quarter && params.quarter !== '전체') {
searchParams.set('quarter', params.quarter);
}
const queryString = searchParams.toString();
const url = `${API_BASE}/stats${queryString ? `?${queryString}` : ''}`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error || !response || !response.ok) {
if (USE_MOCK_FALLBACK) {
console.warn('[PerformanceReportActions] Stats API 실패, Mock 데이터 사용');
return { success: true, data: mockPerformanceReportStats };
}
const errMsg = error ? error.message : `API 오류: ${response?.status || 'no response'}`;
return { success: false, error: errMsg, __authError: error?.code === 'UNAUTHORIZED' };
}
const result = await response.json();
if (!result.success) {
return { success: false, error: result.message || '통계 조회 실패' };
}
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PerformanceReportActions] getPerformanceReportStats error:', error);
if (USE_MOCK_FALLBACK) return { success: true, data: mockPerformanceReportStats };
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 누락체크 목록 조회 =====
export async function getMissedReports(params?: {
page?: number;
size?: number;
q?: string;
}): Promise<{
success: boolean;
data: MissedReport[];
pagination: PaginationMeta;
error?: string;
__authError?: boolean;
}> {
const defaultPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
try {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
if (params?.size) searchParams.set('per_page', String(params.size));
if (params?.q) searchParams.set('q', params.q);
const queryString = searchParams.toString();
const url = `${API_BASE}/missed${queryString ? `?${queryString}` : ''}`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error || !response || !response.ok) {
if (USE_MOCK_FALLBACK) {
console.warn('[PerformanceReportActions] Missed API 실패, Mock 데이터 사용');
let filtered = [...mockMissedReports];
if (params?.q) {
const q = params.q.toLowerCase();
filtered = filtered.filter(i =>
i.siteName.toLowerCase().includes(q) ||
i.client.toLowerCase().includes(q) ||
i.qualityDocNumber.toLowerCase().includes(q)
);
}
const page = params?.page || 1;
const size = params?.size || 20;
const start = (page - 1) * size;
const paged = filtered.slice(start, start + size);
return {
success: true,
data: paged,
pagination: { currentPage: page, lastPage: Math.ceil(filtered.length / size), perPage: size, total: filtered.length },
};
}
const errMsg = error ? error.message : `API 오류: ${response?.status || 'no response'}`;
return { success: false, data: [], pagination: defaultPagination, error: errMsg, __authError: error?.code === 'UNAUTHORIZED' };
}
const result = await response.json();
if (!result.success) {
return { success: false, data: [], pagination: defaultPagination, error: result.message || '누락체크 조회 실패' };
}
return {
success: true,
data: result.data?.items || [],
pagination: {
currentPage: result.data?.current_page || 1,
lastPage: result.data?.last_page || 1,
perPage: result.data?.per_page || 20,
total: result.data?.total || 0,
},
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PerformanceReportActions] getMissedReports error:', error);
if (USE_MOCK_FALLBACK) {
return {
success: true,
data: mockMissedReports,
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: mockMissedReports.length },
};
}
return { success: false, data: [], pagination: defaultPagination, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 선택 확정 =====
export async function confirmReports(ids: string[]): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(`${API_BASE}/confirm`, {
method: 'PATCH',
body: JSON.stringify({ ids }),
});
if (error) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: '확정 처리에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: result.message || '확정 처리에 실패했습니다.' };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PerformanceReportActions] confirmReports error:', error);
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 확정 해제 =====
export async function unconfirmReports(ids: string[]): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(`${API_BASE}/unconfirm`, {
method: 'PATCH',
body: JSON.stringify({ ids }),
});
if (error) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: '확정 해제에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: result.message || '확정 해제에 실패했습니다.' };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PerformanceReportActions] unconfirmReports error:', error);
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 배포 =====
export async function distributeReports(ids: string[]): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(`${API_BASE}/distribute`, {
method: 'POST',
body: JSON.stringify({ ids }),
});
if (error) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: '배포에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: result.message || '배포에 실패했습니다.' };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PerformanceReportActions] distributeReports error:', error);
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 메모 일괄 적용 =====
export async function updateMemo(ids: string[], memo: string): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(`${API_BASE}/memo`, {
method: 'PATCH',
body: JSON.stringify({ ids, memo }),
});
if (error) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: '메모 저장에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: result.message || '메모 저장에 실패했습니다.' };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PerformanceReportActions] updateMemo error:', error);
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -0,0 +1,19 @@
/**
* 실적신고관리 컴포넌트 및 타입 export
*/
export * from './types';
export * from './mockData';
export { PerformanceReportList } from './PerformanceReportList';
export { MemoModal } from './MemoModal';
// Server Actions (API 연동)
export {
getPerformanceReports,
getPerformanceReportStats,
getMissedReports,
confirmReports,
unconfirmReports,
distributeReports,
updateMemo,
} from './actions';

View File

@@ -0,0 +1,195 @@
// 실적신고관리 Mock 데이터
import type {
PerformanceReport,
MissedReport,
PerformanceReportStats,
ConfirmStatus,
} from './types';
// ===== 상태별 색상 매핑 =====
export const confirmStatusColorMap: Record<ConfirmStatus, string> = {
: 'bg-green-100 text-green-800',
: 'bg-gray-100 text-gray-800',
};
// ===== Mock 데이터 - 분기별 실적신고 =====
export const mockPerformanceReports: PerformanceReport[] = [
{
id: '1',
qualityDocNumber: 'QD-2026-001',
createdDate: '2026-01-05',
siteName: '강남 센트럴 파크',
client: '삼성물산',
locationCount: 45,
requiredInfo: '입력완료',
confirmStatus: '확정',
confirmDate: '2026-01-10',
memo: '3차 검사 완료',
year: 2026,
quarter: 'Q1',
},
{
id: '2',
qualityDocNumber: 'QD-2026-002',
createdDate: '2026-01-08',
siteName: '서초 리버사이드',
client: '현대건설',
locationCount: 32,
requiredInfo: '입력완료',
confirmStatus: '확정',
confirmDate: '2026-01-12',
memo: '',
year: 2026,
quarter: 'Q1',
},
{
id: '3',
qualityDocNumber: 'QD-2026-003',
createdDate: '2026-01-12',
siteName: '판교 테크노밸리 2단지',
client: '대우건설',
locationCount: 78,
requiredInfo: '2건 누락',
confirmStatus: '미확정',
confirmDate: '',
memo: '추가 검사 필요',
year: 2026,
quarter: 'Q1',
},
{
id: '4',
qualityDocNumber: 'QD-2026-004',
createdDate: '2026-01-15',
siteName: '용산 파크타워',
client: 'GS건설',
locationCount: 56,
requiredInfo: '1건 누락',
confirmStatus: '미확정',
confirmDate: '',
memo: '',
year: 2026,
quarter: 'Q1',
},
{
id: '5',
qualityDocNumber: 'QD-2026-005',
createdDate: '2026-01-20',
siteName: '마포 리버뷰',
client: '포스코건설',
locationCount: 23,
requiredInfo: '입력완료',
confirmStatus: '확정',
confirmDate: '2026-01-25',
memo: '최종 검사 완료',
year: 2026,
quarter: 'Q1',
},
{
id: '6',
qualityDocNumber: 'QD-2026-006',
createdDate: '2026-01-25',
siteName: '송파 헬리오시티',
client: '롯데건설',
locationCount: 91,
requiredInfo: '미입력',
confirmStatus: '미확정',
confirmDate: '',
memo: '',
year: 2026,
quarter: 'Q1',
},
{
id: '7',
qualityDocNumber: 'QD-2026-007',
createdDate: '2026-01-28',
siteName: '잠실 엘스',
client: '삼성물산',
locationCount: 67,
requiredInfo: '입력완료',
confirmStatus: '미확정',
confirmDate: '',
memo: '공사 일시 중단',
year: 2026,
quarter: 'Q1',
},
];
// ===== Mock 데이터 - 누락체크 =====
export const mockMissedReports: MissedReport[] = [
{
id: 'm1',
qualityDocNumber: 'QD-2025-089',
siteName: '강동 그린파크',
client: '현대건설',
locationCount: 34,
inspectionCompleteDate: '2025-12-15',
memo: '',
},
{
id: 'm2',
qualityDocNumber: 'QD-2025-092',
siteName: '성북 힐스테이트',
client: 'GS건설',
locationCount: 28,
inspectionCompleteDate: '2025-12-20',
memo: '확인 필요',
},
{
id: 'm3',
qualityDocNumber: 'QD-2025-095',
siteName: '노원 래미안',
client: '삼성물산',
locationCount: 52,
inspectionCompleteDate: '2025-12-28',
memo: '',
},
{
id: 'm4',
qualityDocNumber: 'QD-2026-008',
siteName: '동작 자이',
client: 'GS건설',
locationCount: 19,
inspectionCompleteDate: '2026-01-05',
memo: '',
},
{
id: 'm5',
qualityDocNumber: 'QD-2026-009',
siteName: '관악 e편한세상',
client: '대림건설',
locationCount: 41,
inspectionCompleteDate: '2026-01-10',
memo: '서류 미비',
},
{
id: 'm6',
qualityDocNumber: 'QD-2026-010',
siteName: '은평 뉴타운',
client: '대우건설',
locationCount: 63,
inspectionCompleteDate: '2026-01-18',
memo: '',
},
{
id: 'm7',
qualityDocNumber: 'QD-2026-011',
siteName: '광진 현대프리미엄',
client: '현대건설',
locationCount: 37,
inspectionCompleteDate: '2026-01-22',
memo: '',
},
];
// ===== Mock 통계 =====
export const mockPerformanceReportStats: PerformanceReportStats = {
totalCount: 7,
confirmedCount: 3,
unconfirmedCount: 4,
totalLocations: 392,
};

View File

@@ -0,0 +1,67 @@
// 실적신고관리 타입 정의
// ===== 기본 열거 타입 =====
// 분기
export type Quarter = 'Q1' | 'Q2' | 'Q3' | 'Q4';
// 확정 상태
export type ConfirmStatus = '확정' | '미확정';
// ===== 메인 데이터 =====
// 분기별 실적신고 항목
export interface PerformanceReport {
id: string;
qualityDocNumber: string; // 품질관리서 번호
createdDate: string; // 작성일
siteName: string; // 현장명
client: string; // 수주처
locationCount: number; // 개소
requiredInfo: string; // 필수정보
confirmStatus: ConfirmStatus; // 확정 상태
confirmDate: string; // 확정일
memo: string; // 메모
year: number; // 연도
quarter: Quarter; // 분기
}
// 누락체크 항목
export interface MissedReport {
id: string;
qualityDocNumber: string; // 품질관리서 번호
siteName: string; // 현장명
client: string; // 수주처
locationCount: number; // 개소
inspectionCompleteDate: string; // 제품검사완료일
memo: string; // 메모
}
// ===== 통계 =====
export interface PerformanceReportStats {
totalCount: number; // 전체
confirmedCount: number; // 확정
unconfirmedCount: number; // 미확정
totalLocations: number; // 총 개소
}
// ===== 리스트 통합 아이템 (UniversalListPage 용) =====
export interface ReportListItem {
id: string;
qualityDocNumber: string;
siteName: string;
client: string;
locationCount: number;
memo: string;
// 분기별 실적신고 전용
createdDate?: string;
requiredInfo?: string;
confirmStatus?: ConfirmStatus;
confirmDate?: string;
year?: number;
quarter?: Quarter;
// 누락체크 전용
inspectionCompleteDate?: string;
}

View File

@@ -4,8 +4,10 @@ import { createContext, useContext, useEffect, useState, useCallback } from 'rea
import { usePathname } from 'next/navigation';
import { getRolePermissionMatrix, getPermissionMenuUrlMap } from '@/lib/permissions/actions';
import { buildMenuIdToUrlMap, convertMatrixToPermissionMap, findMatchingUrl, mergePermissionMaps } from '@/lib/permissions/utils';
import { ALL_DENIED_PERMS } from '@/lib/permissions/types';
import type { PermissionMap, PermissionAction } from '@/lib/permissions/types';
import { AccessDenied } from '@/components/common/AccessDenied';
import { stripLocalePrefix } from '@/lib/utils/locale';
interface PermissionContextType {
permissionMap: PermissionMap | null;
@@ -58,7 +60,7 @@ export function PermissionProvider({ children }: { children: React.ReactNode })
// (모든 권한 OFF → API가 해당 menuId를 생략 → "all denied"로 보완)
for (const [, url] of Object.entries(permMenuUrlMap)) {
if (url && !merged[url]) {
merged[url] = { view: false, create: false, update: false, delete: false, approve: false, export: false };
merged[url] = { ...ALL_DENIED_PERMS };
}
}
@@ -80,9 +82,10 @@ export function PermissionProvider({ children }: { children: React.ReactNode })
const can = useCallback((url: string, action: PermissionAction): boolean => {
if (!permissionMap) return true;
const perms = permissionMap[url];
if (!perms) return true;
return perms[action] ?? true;
const matchedUrl = findMatchingUrl(url, permissionMap);
if (!matchedUrl) return true;
const perms = permissionMap[matchedUrl];
return perms?.[action] ?? true;
}, [permissionMap]);
return (
@@ -98,7 +101,7 @@ export function PermissionProvider({ children }: { children: React.ReactNode })
const BYPASS_PATHS = ['/settings/permissions'];
function isGateBypassed(pathname: string): boolean {
const pathWithoutLocale = pathname.replace(/^\/(ko|en|ja)(\/|$)/, '/');
const pathWithoutLocale = stripLocalePrefix(pathname);
return BYPASS_PATHS.some(bp => pathWithoutLocale.startsWith(bp));
}

View File

@@ -106,6 +106,19 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
const sessionExpiredCountRef = useRef(0); // 연속 401 카운트
const isSessionExpiredRef = useRef(false); // 세션 만료로 중지된 상태
// 콜백을 ref로 저장하여 인터벌 리셋 방지
// (인라인 콜백이 매 렌더마다 새 참조를 생성해도 인터벌에 영향 없음)
const onMenuUpdatedRef = useRef(onMenuUpdated);
const onErrorRef = useRef(onError);
const onSessionExpiredRef = useRef(onSessionExpired);
// 콜백 ref를 최신 값으로 동기화
useEffect(() => {
onMenuUpdatedRef.current = onMenuUpdated;
onErrorRef.current = onError;
onSessionExpiredRef.current = onSessionExpired;
});
// 폴링 중지 (내부용)
const stopPolling = useCallback(() => {
if (intervalRef.current) {
@@ -114,7 +127,7 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
}
}, []);
// 메뉴 갱신 실행
// 메뉴 갱신 실행 (의존성: stopPolling만 — 안정적)
const executeRefresh = useCallback(async () => {
if (isPausedRef.current || isSessionExpiredRef.current) return;
@@ -125,7 +138,7 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
sessionExpiredCountRef.current = 0;
if (result.updated) {
onMenuUpdated?.();
onMenuUpdatedRef.current?.();
}
return;
}
@@ -140,16 +153,16 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
console.log('[Menu] 세션 만료로 폴링 중지');
isSessionExpiredRef.current = true;
stopPolling();
onSessionExpired?.();
onSessionExpiredRef.current?.();
}
return;
}
// 기타 에러 (네트워크 등) → 401 카운트 리셋하지 않음
if (result.error) {
onError?.(result.error);
onErrorRef.current?.(result.error);
}
}, [onMenuUpdated, onError, onSessionExpired, stopPolling]);
}, [stopPolling]);
// 수동 갱신 함수
const refresh = useCallback(async () => {

View File

@@ -3,6 +3,7 @@
import { usePathname } from 'next/navigation';
import { usePermissionContext } from '@/contexts/PermissionContext';
import { findMatchingUrl } from '@/lib/permissions/utils';
import { ALL_ALLOWED } from '@/lib/permissions/types';
import type { UsePermissionReturn } from '@/lib/permissions/types';
/**
@@ -25,31 +26,13 @@ export function usePermission(overrideUrl?: string): UsePermissionReturn {
const targetPath = overrideUrl || pathname;
if (isLoading || !permissionMap) {
return {
canView: true,
canCreate: true,
canUpdate: true,
canDelete: true,
canApprove: true,
canExport: true,
isLoading,
matchedUrl: null,
};
return { ...ALL_ALLOWED, isLoading };
}
const matchedUrl = findMatchingUrl(targetPath, permissionMap);
if (!matchedUrl) {
return {
canView: true,
canCreate: true,
canUpdate: true,
canDelete: true,
canApprove: true,
canExport: true,
isLoading: false,
matchedUrl: null,
};
return ALL_ALLOWED;
}
const perms = permissionMap[matchedUrl] || {};

View File

@@ -46,6 +46,7 @@ import CommandMenuSearch, { type CommandMenuSearchRef } from '@/components/layou
import { useTheme } from '@/contexts/ThemeContext';
import { useAuth } from '@/contexts/AuthContext';
import { deserializeMenuItems } from '@/lib/utils/menuTransform';
import { stripLocalePrefix } from '@/lib/utils/locale';
import { useMenuPolling } from '@/hooks/useMenuPolling';
import {
Select,
@@ -415,7 +416,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
if (!pathname || menuItems.length === 0) return;
// 경로 정규화 (로케일 제거)
const normalizedPath = pathname.replace(/^\/(ko|en|ja)/, '');
const normalizedPath = stripLocalePrefix(pathname);
// 메뉴 탐색 함수: 메인 메뉴와 서브메뉴 모두 탐색
// 경로 매칭: 정확히 일치하거나 하위 경로인 경우만 매칭
@@ -668,7 +669,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
// 모바일 레이아웃
if (isMobile) {
return (
<div className="flex flex-col bg-background min-h-screen" style={{ height: 'var(--app-height)' }}>
<div className="flex flex-col bg-background min-h-screen">
{/* 모바일 헤더 - sam-design 스타일 */}
<header className="clean-glass sticky top-0 z-40 px-1.5 py-1.5 m-1.5 min-[320px]:px-2 min-[320px]:py-2 min-[320px]:m-2 sm:px-4 sm:py-4 sm:m-3 rounded-2xl clean-shadow">
<div className="flex items-center justify-between">
@@ -942,7 +943,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
</header>
{/* 모바일 콘텐츠 */}
<main className="flex-1 overflow-y-auto px-3 overscroll-contain" style={{ WebkitOverflowScrolling: 'touch', touchAction: 'pan-y pinch-zoom' }}>
<main className="flex-1 px-3">
{children}
</main>

View File

@@ -1,5 +1,8 @@
export type PermissionAction = 'view' | 'create' | 'update' | 'delete' | 'approve' | 'export';
export const PERMISSION_ACTIONS: PermissionAction[] =
['view', 'create', 'update', 'delete', 'approve', 'export'];
/** flat 변환된 권한 맵 (프론트엔드 사용) */
export interface PermissionMap {
[url: string]: {
@@ -18,3 +21,14 @@ export interface UsePermissionReturn {
isLoading: boolean;
matchedUrl: string | null;
}
export const ALL_ALLOWED: UsePermissionReturn = {
canView: true, canCreate: true, canUpdate: true,
canDelete: true, canApprove: true, canExport: true,
isLoading: false, matchedUrl: null,
};
export const ALL_DENIED_PERMS: Record<PermissionAction, false> = {
view: false, create: false, update: false,
delete: false, approve: false, export: false,
};

View File

@@ -1,4 +1,6 @@
import type { PermissionMap, PermissionAction } from './types';
import { PERMISSION_ACTIONS } from './types';
import type { PermissionMap } from './types';
import { stripLocalePrefix } from '@/lib/utils/locale';
interface SerializableMenuItem {
id: string;
@@ -38,14 +40,13 @@ export function convertMatrixToPermissionMap(
menuIdToUrl: Record<string, string>
): PermissionMap {
const map: PermissionMap = {};
const actions: PermissionAction[] = ['view', 'create', 'update', 'delete', 'approve', 'export'];
for (const [menuId, perms] of Object.entries(permissions)) {
const url = menuIdToUrl[menuId];
if (!url) continue; // URL 매핑 없는 메뉴 스킵
map[url] = {};
for (const action of actions) {
for (const action of PERMISSION_ACTIONS) {
// API는 허용된 권한만 포함, 누락된 action = 비허용(false)
map[url][action] = perms[action] === true;
}
@@ -66,8 +67,7 @@ export function mergePermissionMaps(maps: PermissionMap[]): PermissionMap {
for (const url of allUrls) {
merged[url] = {};
const actions: PermissionAction[] = ['view', 'create', 'update', 'delete', 'approve', 'export'];
for (const action of actions) {
for (const action of PERMISSION_ACTIONS) {
const values = maps
.map(m => m[url]?.[action])
.filter((v): v is boolean => v !== undefined);
@@ -84,7 +84,7 @@ export function mergePermissionMaps(maps: PermissionMap[]): PermissionMap {
* Longest prefix match: 현재 경로에서 가장 길게 매칭되는 권한 URL 찾기
*/
export function findMatchingUrl(currentPath: string, permissionMap: PermissionMap): string | null {
const pathWithoutLocale = currentPath.replace(/^\/(ko|en|ja)(\/|$)/, '/');
const pathWithoutLocale = stripLocalePrefix(currentPath);
if (permissionMap[pathWithoutLocale]) {
return pathWithoutLocale;
@@ -100,12 +100,3 @@ export function findMatchingUrl(currentPath: string, permissionMap: PermissionMa
return null;
}
/**
* CRUD 라우트에서 현재 액션 추론
*/
export function inferActionFromPath(path: string): PermissionAction {
if (path.endsWith('/new') || path.endsWith('/create')) return 'create';
if (path.endsWith('/edit')) return 'update';
return 'view';
}

14
src/lib/utils/locale.ts Normal file
View File

@@ -0,0 +1,14 @@
import { locales } from '@/i18n/config';
const LOCALE_PREFIX_RE = new RegExp(`^\\/(${locales.join('|')})(\/|$)`);
const LOCALE_PREFIX_SLASH_RE = new RegExp(`^\\/(${locales.join('|')})\\/`);
/** URL에서 locale prefix 제거 (/ko/hr/... → /hr/...) */
export function stripLocalePrefix(path: string): string {
return path.replace(LOCALE_PREFIX_RE, '/');
}
/** path 내부의 locale prefix만 제거 (슬래시 필수) */
export function stripLocaleSlashPrefix(path: string): string {
return path.replace(LOCALE_PREFIX_SLASH_RE, '/');
}