Files
sam-react-prod/src/components/production/WorkOrders/WorkOrderDetail.tsx
권혁성 dcaca59685 feat: 품질관리·생산·출하 개선 — 검사관리·생산지시·배차·문서스냅샷
- 검사관리 수주처 선택 UI + client_id 연동 + 타입 에러 수정
- 제품검사 요청서/성적서 동적 렌더링 + Lazy Snapshot
- 품질관리 Mock→API 전환 + 수주선택 모달 발주처 연동
- 생산지시 Create/Detail/Edit 제품코드 표시 추가
- 배차 상세/수정 그리드 레이아웃 개선
- 자재/수주 상세 뷰 보강
2026-03-10 11:35:45 +09:00

663 lines
25 KiB
TypeScript

'use client';
/**
* 작업지시 상세 페이지
* API 연동 완료 (2025-12-26)
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { FileText, Play, CheckCircle2, Loader2, Undo2, ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { workOrderConfig } from './workOrderConfig';
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
import { WorkLogModal } from '../WorkerScreen/WorkLogModal';
import { InspectionReportModal } from './documents';
import { toast } from 'sonner';
import { getWorkOrderById, updateWorkOrderStatus } from './actions';
import {
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_STATUS_COLORS,
ISSUE_STATUS_LABELS,
SCREEN_PROCESS_STEPS,
SLAT_PROCESS_STEPS,
BENDING_PROCESS_STEPS,
type WorkOrder,
type ProcessType,
type ProcessStep,
} from './types';
// 수량 포맷팅 (EA, 개 등 개수 단위는 정수, m, kg 등은 소수점 유지)
function formatQuantity(quantity: number | string, unit: string): string {
const num = typeof quantity === 'string' ? Number(quantity) : quantity;
if (isNaN(num)) return String(quantity);
const countableUnits = ['EA', 'ea', '개', '대', '세트', 'SET', 'set', 'PCS', 'pcs'];
if (countableUnits.includes(unit) || unit === '-') {
return String(Math.floor(num));
}
// 소수점이 있으면 표시, 없으면 정수로
return Number.isInteger(num) ? String(num) : num.toFixed(2);
}
// 공정 진행 단계 (wrapper 없이 pills만 렌더링)
function ProcessStepPills({
processType,
currentStep,
workSteps,
}: {
processType: ProcessType;
currentStep: number;
workSteps?: ProcessStep[];
}) {
// 동적 workSteps 우선 사용, 없으면 하드코딩 폴백
const steps = workSteps && workSteps.length > 0
? workSteps
: processType === 'screen'
? SCREEN_PROCESS_STEPS
: processType === 'slat'
? SLAT_PROCESS_STEPS
: BENDING_PROCESS_STEPS;
if (steps.length === 0) {
return <p className="text-gray-500"> .</p>;
}
return (
<div className="flex flex-col sm:flex-row sm:flex-wrap gap-2">
{steps.map((step, index) => {
const isCompleted = index < currentStep;
return (
<div
key={step.key || `step-${index}`}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
isCompleted
? 'bg-emerald-500 text-white'
: 'bg-gray-800 text-white'
}`}
>
<span>{step.label}</span>
{isCompleted && (
<span className="font-semibold"></span>
)}
</div>
);
})}
</div>
);
}
// 전개도 상세정보 컴포넌트 (절곡용)
function BendingDetailsSection({ order }: { order: WorkOrder }) {
if (!order.bendingDetails || order.bendingDetails.length === 0) {
return null;
}
return (
<div className="bg-white border rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
<div className="space-y-4">
{order.bendingDetails.map((detail) => (
<div key={detail.id} className="border rounded-lg overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between bg-gray-100 px-4 py-2">
<div className="flex items-center gap-2">
<span className="font-semibold">{detail.code}</span>
<span className="font-medium">{detail.name}</span>
<span className="text-sm text-muted-foreground">{detail.material}</span>
</div>
<span className="text-sm">: {Math.floor(detail.quantity)}</span>
</div>
{/* 상세 정보 */}
<div className="grid grid-cols-5 gap-4 p-4">
<div>
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="font-medium">{detail.developWidth}</p>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="font-medium">{detail.length}</p>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="font-medium">{detail.weight}</p>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="font-medium">{detail.note}</p>
</div>
<div className="row-span-2 flex items-center justify-center border rounded bg-gray-50">
{/* 전개도 이미지 placeholder */}
<div className="text-center p-4">
<div className="w-24 h-16 border-2 border-dashed border-gray-300 rounded flex items-center justify-center mb-1">
<span className="text-xs text-muted-foreground"></span>
</div>
<span className="text-xs text-muted-foreground">{detail.developDimension}</span>
</div>
</div>
<div className="col-span-4">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="font-medium">{detail.developDimension}</p>
</div>
</div>
</div>
))}
</div>
</div>
);
}
// 이슈 섹션 컴포넌트
function IssueSection({ order }: { order: WorkOrder }) {
if (!order.issues || order.issues.length === 0) {
return null;
}
return (
<div className="bg-white border rounded-lg p-6">
<h3 className="font-semibold mb-4"> ({order.issues.length})</h3>
<div className="space-y-3">
{order.issues.map((issue) => (
<div key={issue.id} className="flex items-start gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
<Badge
variant="outline"
className={`shrink-0 ${
issue.status === 'processing'
? 'bg-yellow-100 text-yellow-700 border-yellow-300'
: issue.status === 'resolved'
? 'bg-green-100 text-green-700 border-green-300'
: 'bg-gray-100 text-gray-700 border-gray-300'
}`}
>
{ISSUE_STATUS_LABELS[issue.status]}
</Badge>
<div>
<span className="font-medium">{issue.type}</span>
<span className="mx-2 text-muted-foreground">·</span>
<span>{issue.description}</span>
</div>
</div>
))}
</div>
</div>
);
}
interface WorkOrderDetailProps {
orderId: string;
}
export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
const router = useRouter();
const [isWorkLogOpen, setIsWorkLogOpen] = useState(false);
const [isInspectionOpen, setIsInspectionOpen] = useState(false);
const [order, setOrder] = useState<WorkOrder | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isStatusUpdating, setIsStatusUpdating] = useState(false);
// 수정 모드로 이동 핸들러
const handleEdit = useCallback(() => {
router.push(`/production/work-orders/${orderId}?mode=edit`);
}, [router, orderId]);
// API에서 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getWorkOrderById(orderId);
if (result.success && result.data) {
const orderData = result.data;
// 품목이 없으면 목업 데이터 추가 (개발/테스트용)
if (!orderData.items || orderData.items.length === 0) {
orderData.items = [
{
id: 'mock-1',
no: 1,
status: 'waiting',
productName: 'KWW503 (와이어)',
floorCode: '-',
specification: '8,260 X 8,350 mm',
quantity: 500,
unit: 'm',
orderNodeId: null,
orderNodeName: '',
},
{
id: 'mock-2',
no: 2,
status: 'waiting',
productName: '스크린 원단',
floorCode: '-',
specification: '1,200 X 2,400 mm',
quantity: 100,
unit: 'EA',
orderNodeId: null,
orderNodeName: '',
},
];
}
setOrder(orderData);
} else {
toast.error(result.error || '작업지시 조회에 실패했습니다.');
}
} catch (error) {
console.error('[WorkOrderDetail] loadData error:', error);
toast.error('데이터 로드 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
}, [orderId]);
useEffect(() => {
loadData();
}, [loadData]);
// 상태 변경 핸들러
const handleStatusChange = useCallback(async (newStatus: 'waiting' | 'in_progress' | 'completed') => {
if (!order) return;
setIsStatusUpdating(true);
try {
const result = await updateWorkOrderStatus(orderId, newStatus);
if (result.success && result.data) {
invalidateDashboard('production');
setOrder(result.data);
const statusLabels = {
waiting: '작업대기',
in_progress: '작업중',
completed: '작업완료',
};
toast.success(`상태가 '${statusLabels[newStatus]}'(으)로 변경되었습니다.`);
} else {
toast.error(result.error || '상태 변경에 실패했습니다.');
}
} catch (error) {
console.error('[WorkOrderDetail] handleStatusChange error:', error);
toast.error('상태 변경 중 오류가 발생했습니다.');
} finally {
setIsStatusUpdating(false);
}
}, [order, orderId]);
// 커스텀 헤더 액션 (상태 변경 버튼, 작업일지 버튼)
const customHeaderActions = useMemo(() => {
if (!order) return null;
return (
<>
{/* 상태 변경 버튼 */}
{order.status === 'waiting' && (
<Button
onClick={() => handleStatusChange('in_progress')}
disabled={isStatusUpdating}
className="bg-green-600 hover:bg-green-700"
size="sm"
>
{isStatusUpdating ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4 md:mr-2" />
)}
<span className="hidden md:inline"> </span>
</Button>
)}
{order.status === 'in_progress' && (
<>
<Button
variant="outline"
onClick={() => handleStatusChange('waiting')}
disabled={isStatusUpdating}
className="text-muted-foreground hover:text-foreground"
size="sm"
>
{isStatusUpdating ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Undo2 className="w-4 h-4 md:mr-2" />
)}
<span className="hidden md:inline"> </span>
</Button>
<Button
onClick={() => handleStatusChange('completed')}
disabled={isStatusUpdating}
className="bg-purple-600 hover:bg-purple-700"
size="sm"
>
{isStatusUpdating ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 md:mr-2" />
)}
<span className="hidden md:inline"> </span>
</Button>
</>
)}
{order.status === 'completed' && (
<Button
variant="outline"
onClick={() => handleStatusChange('in_progress')}
disabled={isStatusUpdating}
className="text-orange-600 hover:text-orange-700 border-orange-300 hover:bg-orange-50"
size="sm"
>
{isStatusUpdating ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Undo2 className="w-4 h-4 md:mr-2" />
)}
<span className="hidden md:inline"></span>
</Button>
)}
<Button variant="outline" onClick={() => setIsWorkLogOpen(true)} size="sm">
<FileText className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"> </span>
</Button>
<Button
variant="outline"
onClick={() => setIsInspectionOpen(true)}
size="sm"
>
<ClipboardCheck className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"> </span>
</Button>
</>
);
}, [order, isStatusUpdating, handleStatusChange]);
// 폼 내용 렌더링
const renderFormContent = () => {
if (!order) return null;
return (
<div className="space-y-6">
{/* 기본 정보 (기획서 4열 구성) */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 md:p-6">
<h3 className="font-semibold mb-4"> </h3>
<div className="max-h-[360px] overflow-y-auto md:max-h-none md:overflow-visible">
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-4 md:gap-x-6 gap-y-4">
{/* 1행: 작업번호 | 수주일 | 공정 | 구분 */}
<div className="min-w-0">
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium text-xs sm:text-sm break-all">{order.workOrderNo}</p>
</div>
<div className="min-w-0">
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.salesOrderDate || '-'}</p>
</div>
<div className="min-w-0">
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.processName}</p>
</div>
<div className="min-w-0">
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.processName !== '-' ? order.processName : '-'}</p>
</div>
{/* 2행: 로트번호 | 수주처 | 현장명 | 수주 담당자 */}
<div className="min-w-0">
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium text-xs sm:text-sm break-all">{order.lotNo}</p>
</div>
<div className="min-w-0">
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium break-words">{order.client}</p>
</div>
<div className="min-w-0">
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium break-words">{order.projectName}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"> </p>
<p className="font-medium">{order.salesOrderWriter || '-'}</p>
</div>
{/* 3행: 담당자 연락처 | 출고예정일 | 틀수 | 우선순위 */}
<div>
<p className="text-sm text-muted-foreground mb-1"> </p>
<p className="font-medium">{order.clientContact || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.shipmentDate || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"> ()</p>
<p className="font-medium">{order.shutterCount != null ? `${Math.floor(order.shutterCount)}개소` : '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.priorityLabel || '-'}</p>
</div>
{/* 4행: 부서 | 생산 담당자 | 상태 | 비고 */}
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.department || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"> </p>
<p className="font-medium">
{order.assignees && order.assignees.length > 0
? order.assignees.map(a => a.name).join(', ')
: order.assignee}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<Badge className={`${WORK_ORDER_STATUS_COLORS[order.status]} border-0`}>
{WORK_ORDER_STATUS_LABELS[order.status]}
</Badge>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium whitespace-pre-wrap">{order.note || '-'}</p>
</div>
</div>
</div>
</div>
{/* 공정 진행 + 작업 품목 (기획서 구조) */}
<div className="bg-white border rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
{/* 공정 단계 박스 (inner bordered box) */}
<div className="border rounded-lg p-4 mb-6">
{/* 품목 정보 헤더 (항상 표시) */}
<p className="font-semibold mb-3">
{order.items.length > 0 ? (
<>
{order.items[0].productName}
{order.items[0].specification !== '-' ? ` ${order.items[0].specification}` : ''}
{` ${formatQuantity(order.items[0].quantity, order.items[0].unit)}${order.items[0].unit !== '-' ? order.items[0].unit : '개'}`}
</>
) : (
<>
{order.processCode} ({order.processName})
</>
)}
</p>
{/* 공정 단계 pills */}
<ProcessStepPills
processType={order.processType}
currentStep={order.currentStep}
workSteps={order.workSteps}
/>
</div>
{/* 작업 품목 - 개소별 그룹 */}
{order.items.length > 0 ? (
<div className="space-y-6">
{/* 개소별 품목 그룹 */}
<div>
<h4 className="text-sm font-semibold text-muted-foreground mb-2"> </h4>
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-36"></TableHead>
<TableHead></TableHead>
<TableHead className="w-24 text-right"></TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(() => {
// 개소(층/부호)별로 그룹화
const nodeGroups = new Map<string, { label: string; items: typeof order.items }>();
// order_node_id 기반 그룹핑 (floor_code/symbol_code는 표시용)
for (const item of order.items) {
const floorLabel = item.floorCode !== '-' ? item.floorCode : '';
const key = item.orderNodeId != null ? String(item.orderNodeId) : (floorLabel || 'none');
const label = floorLabel || item.orderNodeName || `개소 ${nodeGroups.size + 1}`;
if (!nodeGroups.has(key)) {
nodeGroups.set(key, { label, items: [] });
}
nodeGroups.get(key)!.items.push(item);
}
const rows: React.ReactNode[] = [];
for (const [key, group] of nodeGroups) {
group.items.forEach((item, idx) => {
rows.push(
<TableRow key={`node-${key}-${item.id}`} className={idx === 0 ? 'border-t-2' : ''}>
{idx === 0 && (
<TableCell rowSpan={group.items.length} className="align-top font-medium bg-muted/30">
{group.label}
</TableCell>
)}
<TableCell>{item.productName}</TableCell>
<TableCell className="text-right">{formatQuantity(item.quantity, item.unit)}</TableCell>
<TableCell>{item.unit}</TableCell>
</TableRow>
);
});
}
return rows;
})()}
</TableBody>
</Table>
</div>
{/* 품목별 합산 그룹 */}
<div>
<h4 className="text-sm font-semibold text-muted-foreground mb-2"> </h4>
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-12 text-center">No</TableHead>
<TableHead></TableHead>
<TableHead className="w-24 text-right"></TableHead>
<TableHead className="w-20"></TableHead>
<TableHead className="w-20 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(() => {
// 품목명+단위 기준으로 중복 합산
const itemMap = new Map<string, { productName: string; totalQty: number; unit: string; nodeCount: number }>();
for (const item of order.items) {
const key = `${item.productName}||${item.unit}`;
if (!itemMap.has(key)) {
itemMap.set(key, { productName: item.productName, totalQty: 0, unit: item.unit, nodeCount: 0 });
}
const entry = itemMap.get(key)!;
entry.totalQty += Number(item.quantity);
entry.nodeCount += 1;
}
let no = 0;
return Array.from(itemMap.values()).map((entry) => {
no++;
return (
<TableRow key={`sum-${entry.productName}-${entry.unit}`}>
<TableCell className="text-center">{no}</TableCell>
<TableCell className="font-medium">{entry.productName}</TableCell>
<TableCell className="text-right">{formatQuantity(entry.totalQty, entry.unit)}</TableCell>
<TableCell>{entry.unit}</TableCell>
<TableCell className="text-right">{entry.nodeCount}</TableCell>
</TableRow>
);
});
})()}
</TableBody>
</Table>
</div>
</div>
) : (
<p className="text-muted-foreground text-center py-8">
.
</p>
)}
</div>
{/* 절곡 전용: 전개도 상세정보 */}
{order.processType === 'bending' && <BendingDetailsSection order={order} />}
{/* 이슈 섹션 */}
<IssueSection order={order} />
</div>
);
};
// 데이터 없음
if (!isLoading && !order) {
return (
<ServerErrorPage
title="작업지시를 불러올 수 없습니다"
message="작업지시를 찾을 수 없습니다."
showBackButton={true}
showHomeButton={true}
/>
);
}
return (
<>
<IntegratedDetailTemplate
config={workOrderConfig}
mode="view"
initialData={{}}
itemId={orderId}
isLoading={isLoading}
headerActions={customHeaderActions}
onEdit={handleEdit}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
{/* 작업일지 모달 (공정별) */}
{order && (
<WorkLogModal
open={isWorkLogOpen}
onOpenChange={setIsWorkLogOpen}
workOrderId={order.id}
processType={order.processType}
/>
)}
{/* 중간검사 성적서 모달 (공정별) */}
{order && (
<InspectionReportModal
open={isInspectionOpen}
onOpenChange={setIsInspectionOpen}
workOrderId={order.id}
processType={order.processType}
/>
)}
</>
);
}