- 검사관리 수주처 선택 UI + client_id 연동 + 타입 에러 수정 - 제품검사 요청서/성적서 동적 렌더링 + Lazy Snapshot - 품질관리 Mock→API 전환 + 수주선택 모달 발주처 연동 - 생산지시 Create/Detail/Edit 제품코드 표시 추가 - 배차 상세/수정 그리드 레이아웃 개선 - 자재/수주 상세 뷰 보강
663 lines
25 KiB
TypeScript
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}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
} |