feat(WEB): 공정관리/작업지시/작업자화면 기능 강화 및 템플릿 개선

- 공정관리: ProcessDetail/ProcessForm/ProcessList 개선, StepDetail/StepForm 신규 추가
- 작업지시: WorkOrderDetail/Edit/List UI 개선, 작업지시서 문서 추가
- 작업자화면: WorkerScreen 대폭 개선, MaterialInputModal/WorkLogModal 수정, WorkItemCard 신규
- 영업주문: 주문 상세 페이지 개선
- 입고관리: 상세/actions 수정
- 템플릿: IntegratedDetailTemplate/IntegratedListTemplateV2/UniversalListPage 기능 확장
- UI: confirm-dialog 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-29 22:56:01 +09:00
parent 106ce09482
commit 3fc63d0b3e
50 changed files with 5801 additions and 1377 deletions

View File

@@ -8,7 +8,7 @@
*/
import { useState, useEffect, useMemo } from 'react';
import { Check, X, ChevronDown, ChevronRight } from 'lucide-react';
import { Check, ChevronDown, ChevronRight } from 'lucide-react';
import { ContentSkeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@@ -270,16 +270,8 @@ export function AssigneeSelectModal({
</VisuallyHidden>
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b">
<div className="px-6 py-4 border-b">
<h2 className="text-lg font-semibold"> </h2>
<button
type="button"
onClick={() => onOpenChange(false)}
className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100"
>
<X className="h-4 w-4" />
<span className="sr-only"></span>
</button>
</div>
{/* 설명 텍스트 */}

View File

@@ -8,7 +8,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Play, CheckCircle2, Loader2, Undo2, Pencil } from 'lucide-react';
import { FileText, Play, CheckCircle2, Loader2, Undo2, ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
@@ -23,13 +23,12 @@ import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetai
import { workOrderConfig } from './workOrderConfig';
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
import { WorkLogModal } from '../WorkerScreen/WorkLogModal';
import { InspectionReportModal } from './documents';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { getWorkOrderById, updateWorkOrderStatus, updateWorkOrderItemStatus, type WorkOrderItemStatus } from './actions';
import { getWorkOrderById, updateWorkOrderStatus } from './actions';
import {
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_STATUS_COLORS,
ITEM_STATUS_LABELS,
ISSUE_STATUS_LABELS,
SCREEN_PROCESS_STEPS,
SLAT_PROCESS_STEPS,
@@ -39,8 +38,8 @@ import {
type ProcessStep,
} from './types';
// 공정 진행 단계 컴포넌트
function ProcessSteps({
// 공정 진행 단계 (wrapper 없이 pills만 렌더링)
function ProcessStepPills({
processType,
currentStep,
workSteps,
@@ -59,45 +58,26 @@ function ProcessSteps({
: BENDING_PROCESS_STEPS;
if (steps.length === 0) {
return (
<div className="bg-white border rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
<p className="text-gray-500"> .</p>
</div>
);
return <p className="text-gray-500"> .</p>;
}
return (
<div className="bg-white border rounded-lg p-6">
<h3 className="font-semibold mb-4"> ({steps.length})</h3>
<div className="flex items-center gap-2 flex-wrap">
{steps.map((step, index) => {
const isCompleted = index < currentStep;
const isCurrent = index === currentStep;
<div className="flex items-center gap-3 flex-wrap">
{steps.map((step, index) => {
const isCompleted = index < currentStep;
return (
<div key={step.key || `step-${index}`} className="flex items-center">
<div
className={`flex items-center gap-2 px-4 py-2 rounded-full border ${
isCompleted
? 'bg-gray-900 text-white border-gray-900'
: isCurrent
? 'bg-white border-gray-900 text-gray-900'
: 'bg-white border-gray-300 text-gray-500'
}`}
>
<span className="font-medium">{step.order}</span>
<span>{step.label}</span>
{isCompleted && (
<span className="text-xs bg-white text-gray-900 px-1.5 py-0.5 rounded">
</span>
)}
</div>
</div>
);
})}
</div>
return (
<div
key={step.key || `step-${index}`}
className="flex items-center gap-2 px-4 py-2 rounded-full bg-gray-900 text-white border border-gray-900"
>
<span>{step.label}</span>
{isCompleted && (
<span className="text-green-400 font-medium"></span>
)}
</div>
);
})}
</div>
);
}
@@ -206,10 +186,10 @@ interface WorkOrderDetailProps {
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 [updatingItemId, setUpdatingItemId] = useState<number | null>(null);
// 수정 모드로 이동 핸들러
const handleEdit = useCallback(() => {
@@ -264,49 +244,6 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
}
}, [order, orderId]);
// 품목 상태 변경 핸들러
const handleItemStatusChange = useCallback(async (itemId: number, newStatus: WorkOrderItemStatus) => {
if (!order) return;
setUpdatingItemId(itemId);
try {
const result = await updateWorkOrderItemStatus(orderId, itemId, newStatus);
if (result.success) {
// 로컬 상태 업데이트 (품목 + 작업지시 상태)
setOrder(prev => {
if (!prev) return prev;
return {
...prev,
status: result.workOrderStatus || prev.status,
items: prev.items.map(item =>
item.id === itemId ? { ...item, status: newStatus } : item
),
};
});
const statusLabels: Record<WorkOrderItemStatus, string> = {
waiting: '대기',
in_progress: '작업중',
completed: '완료',
};
toast.success(`품목 상태가 '${statusLabels[newStatus]}'(으)로 변경되었습니다.`);
// 작업지시 상태가 변경된 경우 추가 알림
if (result.workOrderStatusChanged && result.workOrderStatus) {
const workOrderStatusLabel = WORK_ORDER_STATUS_LABELS[result.workOrderStatus as keyof typeof WORK_ORDER_STATUS_LABELS] || result.workOrderStatus;
toast.info(`작업지시 상태가 '${workOrderStatusLabel}'(으)로 자동 변경되었습니다.`);
}
} else {
toast.error(result.error || '품목 상태 변경에 실패했습니다.');
}
} catch (error) {
console.error('[WorkOrderDetail] handleItemStatusChange error:', error);
toast.error('품목 상태 변경 중 오류가 발생했습니다.');
} finally {
setUpdatingItemId(null);
}
}, [order, orderId]);
// 커스텀 헤더 액션 (상태 변경 버튼, 작업일지 버튼)
const customHeaderActions = useMemo(() => {
if (!order) return null;
@@ -374,7 +311,14 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
)}
<Button variant="outline" onClick={() => setIsWorkLogOpen(true)}>
<FileText className="w-4 h-4 mr-1.5" />
</Button>
<Button
variant="outline"
onClick={() => setIsInspectionOpen(true)}
>
<ClipboardCheck className="w-4 h-4 mr-1.5" />
</Button>
</>
);
@@ -386,30 +330,31 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
return (
<div className="space-y-6">
{/* 기본 정보 */}
{/* 기본 정보 (기획서 4열 구성) */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
<div className="grid grid-cols-4 gap-y-4">
<div className="grid grid-cols-4 gap-x-6 gap-y-4">
{/* 1행: 작업번호 | 수주일 | 공정구분 | 로트번호 */}
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.workOrderNo}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.lotNo}</p>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.salesOrderDate || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.processName}</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>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.lotNo}</p>
</div>
{/* 2행: 수주처 | 현장명 | 수주 담당자 | 담당자 연락처 */}
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.client}</p>
</div>
<div>
@@ -417,11 +362,35 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
<p className="font-medium">{order.projectName}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.dueDate}</p>
<p className="text-sm text-muted-foreground mb-1"> </p>
<p className="font-medium">-</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="text-sm text-muted-foreground mb-1"> </p>
<p className="font-medium">-</p>
</div>
{/* 3행: 출고예정일 | 틀수 | 우선순위 | 부서 */}
<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 ?? '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.priorityLabel || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{order.department || '-'}</p>
</div>
{/* 4행: 생산 담당자 | 상태 | 비고 (colspan 2) */}
<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(', ')
@@ -429,120 +398,65 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">
{order.priority === 1 ? '1 (긴급)' : order.priority === 5 ? '5 (일반)' : order.priority || '-'}
</p>
<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 className="col-span-2">
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium whitespace-pre-wrap">{order.note || '-'}</p>
</div>
{order.note && (
<div className="col-span-4 mt-2 pt-4 border-t">
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium whitespace-pre-wrap">{order.note}</p>
</div>
)}
</div>
</div>
{/* 공정 진행 */}
<ProcessSteps
processType={order.processType}
currentStep={order.currentStep}
workSteps={order.workSteps}
/>
{/* 작업 품목 */}
{/* 공정 진행 + 작업 품목 (기획서 구조) */}
<div className="bg-white border rounded-lg p-6">
<h3 className="font-semibold mb-4"> ({order.items.length})</h3>
<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}` : ''}
{` ${order.items[0].quantity}${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 ? (
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-14">No</TableHead>
<TableHead className="w-20"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-28">/</TableHead>
<TableHead className="w-32"></TableHead>
<TableHead className="w-20 text-right"></TableHead>
<TableHead className="w-20"></TableHead>
<TableHead className="w-24 text-right"></TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{order.items.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.no}</TableCell>
<TableCell>
<Badge variant="outline">{ITEM_STATUS_LABELS[item.status]}</Badge>
</TableCell>
<TableCell>{order.lotNo}</TableCell>
<TableCell className="font-medium">{item.productName}</TableCell>
<TableCell>{item.floorCode}</TableCell>
<TableCell>{item.specification}</TableCell>
<TableCell className="text-right">{item.quantity}</TableCell>
<TableCell>
<div className="flex gap-1">
{item.status === 'waiting' && (
<Button
variant="outline"
size="sm"
onClick={() => handleItemStatusChange(item.id, 'in_progress')}
disabled={updatingItemId === item.id}
>
{updatingItemId === item.id ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<Play className="w-3 h-3 mr-1" />
)}
</Button>
)}
{item.status === 'in_progress' && (
<>
<Button
variant="outline"
size="sm"
onClick={() => handleItemStatusChange(item.id, 'completed')}
disabled={updatingItemId === item.id}
>
{updatingItemId === item.id ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<CheckCircle2 className="w-3 h-3 mr-1" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleItemStatusChange(item.id, 'waiting')}
disabled={updatingItemId === item.id}
className="text-muted-foreground hover:text-foreground"
>
{updatingItemId === item.id ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<Undo2 className="w-3 h-3 mr-1" />
)}
</Button>
</>
)}
{item.status === 'completed' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleItemStatusChange(item.id, 'in_progress')}
disabled={updatingItemId === item.id}
className="text-muted-foreground hover:text-foreground"
>
{updatingItemId === item.id ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<Undo2 className="w-3 h-3 mr-1" />
)}
</Button>
)}
</div>
</TableCell>
<TableCell>{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
@@ -589,12 +503,23 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
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}
/>
)}
</>

View File

@@ -193,19 +193,13 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
router.back();
};
// 선택된 공정의 코드 가져오기
const getSelectedProcessCode = (): string => {
const selectedProcess = processOptions.find(p => p.id === formData.processId);
return selectedProcess?.processCode || '-';
};
// 동적 config (작업지시 번호 포함)
const dynamicConfig = {
...workOrderEditConfig,
title: workOrder ? `작업지시 (${workOrder.workOrderNo})` : '작업지시',
};
// 폼 컨텐츠 렌더링
// 폼 컨텐츠 렌더링 (기획서 4열 그리드)
const renderFormContent = useCallback(() => (
<div className="space-y-6">
{/* Validation 에러 표시 */}
@@ -237,58 +231,28 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
</Alert>
)}
{/* 기본 정보 (읽기 전용) */}
<section className="bg-muted/30 border rounded-lg p-6">
{/* 기본 정보 (기획서 4열 구성) */}
<section className="bg-amber-50 border border-amber-200 rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
value={formData.client}
disabled
className="bg-muted"
/>
<div className="grid grid-cols-4 gap-x-6 gap-y-4">
{/* 1행: 작업번호(읽기) | 수주일(읽기) | 공정구분(셀렉트) | 로트번호(읽기) */}
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={workOrder?.workOrderNo || '-'} disabled className="bg-muted" />
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.projectName}
onChange={(e) => setFormData({ ...formData, projectName: e.target.value })}
className="bg-white"
/>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={workOrder?.salesOrderDate || '-'} disabled className="bg-muted" />
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.orderNo}
disabled
className="bg-muted"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.itemCount || '-'}
disabled
className="bg-muted"
/>
</div>
</div>
</section>
{/* 작업지시 정보 */}
<section className="bg-muted/30 border rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
<div className="space-y-4">
<div className="space-y-2">
<Label> *</Label>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"> *</Label>
<Select
value={formData.processId?.toString() || ''}
onValueChange={(value) => setFormData({ ...formData, processId: parseInt(value) })}
disabled={isLoadingProcesses}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder={isLoadingProcesses ? '로딩 중...' : '공정 선택하세요'} />
<SelectValue placeholder={isLoadingProcesses ? '로딩 중...' : '공정 선택'} />
</SelectTrigger>
<SelectContent>
{processOptions.map((process) => (
@@ -298,13 +262,37 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
: {getSelectedProcessCode()}
</p>
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={formData.orderNo || '-'} disabled className="bg-muted" />
</div>
<div className="space-y-2">
<Label> *</Label>
{/* 2행: 수주처(읽기) | 현장명(입력) | 수주 담당자(읽기) | 담당자 연락처(읽기) */}
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={formData.client} disabled className="bg-muted" />
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Input
value={formData.projectName}
onChange={(e) => setFormData({ ...formData, projectName: e.target.value })}
className="bg-white"
/>
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"> </Label>
<Input value="-" disabled className="bg-muted" />
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"> </Label>
<Input value="-" disabled className="bg-muted" />
</div>
{/* 3행: 출고예정일(입력) | 틀수(읽기) | 우선순위(셀렉트) | 부서(읽기) */}
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"> *</Label>
<Input
type="date"
value={formData.scheduledDate}
@@ -312,9 +300,12 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
className="bg-white"
/>
</div>
<div className="space-y-2">
<Label> (1=, 9=)</Label>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={workOrder?.shutterCount ?? '-'} disabled className="bg-muted" />
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Select
value={formData.priority.toString()}
onValueChange={(value) => setFormData({ ...formData, priority: parseInt(value) })}
@@ -325,15 +316,20 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
<SelectContent>
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
<SelectItem key={n} value={n.toString()}>
{n} {n === 5 ? '(일반)' : n === 1 ? '(긴급)' : ''}
{n} {n <= 3 ? '(긴급)' : n <= 6 ? '(우선)' : '(일반)'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={workOrder?.department || '-'} disabled className="bg-muted" />
</div>
<div className="space-y-2">
<Label> ( )</Label>
{/* 4행: 생산 담당자(선택) | 상태(읽기) | 비고(입력, colspan 2) */}
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"> </Label>
<div
onClick={() => setIsAssigneeModalOpen(true)}
className="flex min-h-10 w-full cursor-pointer items-center rounded-md border border-input bg-white px-3 py-2 text-sm ring-offset-background hover:bg-accent/50"
@@ -341,26 +337,28 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
{assigneeNames.length > 0 ? (
<span>{assigneeNames.join(', ')}</span>
) : (
<span className="text-muted-foreground"> (/)</span>
<span className="text-muted-foreground"> </span>
)}
</div>
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={workOrder ? (workOrder.status === 'waiting' ? '작업대기' : workOrder.status === 'in_progress' ? '작업중' : workOrder.status === 'completed' ? '작업완료' : workOrder.status) : '-'} disabled className="bg-muted" />
</div>
<div className="space-y-1 col-span-2">
<Label className="text-sm text-muted-foreground"></Label>
<Textarea
value={formData.note}
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
placeholder="특이사항이나 메모를 입력하세요"
rows={2}
className="bg-white"
/>
</div>
</div>
</section>
{/* 비고 */}
<section className="bg-muted/30 border rounded-lg p-6">
<h3 className="font-semibold mb-4"></h3>
<Textarea
value={formData.note}
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
placeholder="특이사항이나 메모를 입력하세요"
rows={4}
className="bg-white"
/>
</section>
</div>
), [formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, getSelectedProcessCode]);
), [formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, workOrder]);
return (
<>

View File

@@ -1,18 +1,19 @@
'use client';
/**
* 작업지시 목록 - UniversalListPage 마이그레이션
* 작업지시 목록 - 공정 기반 탭 구조
*
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
* - 서버 사이드 페이지네이션 (getWorkOrders API)
* - 통계 카드 (getWorkOrderStats API)
* - 탭 기반 상태 필터링
* 기획서 기반 전면 개편:
* - 탭: 공정 기반 3개 (스크린/슬랫/절곡) — 통계 카드 위에 배치
* - 필터: 상태 + 우선순위
* - 통계 카드 6개: 전체 작업 / 작업 대기 / 작업중 / 작업 완료 / 긴급 / 지연
* - 컬럼: 작업번호/수주일/출고예정일/로트번호/수주처/현장명/틀수/상태/우선순위/부서/비고
* - API: getProcessOptions로 공정 ID 매핑 후 processId로 필터링
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, FileText, Calendar, Users, CheckCircle2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { FileText, Clock, Loader, CheckCircle2, AlertTriangle, TimerOff } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -25,39 +26,104 @@ import {
type StatCard,
type ListParams,
} from '@/components/templates/UniversalListPage';
import type { FilterFieldConfig } from '@/components/molecules/MobileFilter';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { toast } from 'sonner';
import { getWorkOrders, getWorkOrderStats } from './actions';
import { getWorkOrders, getWorkOrderStats, getProcessOptions } from './actions';
import type { ProcessOption } from './actions';
import {
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_STATUS_COLORS,
type WorkOrder,
type WorkOrderStats,
type WorkOrderStatus,
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
// 탭 필터 정의
type TabFilter = 'all' | 'unassigned' | 'pending' | 'waiting' | 'in_progress' | 'completed';
// 페이지당 항목 수
const ITEMS_PER_PAGE = 20;
// 작업자 표시 포맷 (홍길동 외 2명)
function formatAssignees(item: WorkOrder): string {
if (item.assignees && item.assignees.length > 0) {
const primaryAssignee = item.assignees.find(a => a.isPrimary) || item.assignees[0];
const otherCount = item.assignees.length - 1;
if (otherCount > 0) {
return `${primaryAssignee.name}${otherCount}`;
}
return primaryAssignee.name;
}
return item.assignee || '-';
}
// 공정명 → 탭 value 매핑 (DB process_name 기준)
const PROCESS_NAME_TO_TAB: Record<string, string> = {
'스크린': 'screen',
'슬랫': 'slat',
'절곡': 'bending',
};
// 공정코드 → 탭 value 매핑 (DB process_code 기준, 이름 매핑 실패 시 폴백)
const PROCESS_CODE_TO_TAB: Record<string, string> = {
'screen': 'screen',
'slat': 'slat',
'bending': 'bending',
'SCREEN': 'screen',
'SLAT': 'slat',
'BENDING': 'bending',
};
// 우선순위 뱃지 색상
const PRIORITY_COLORS: Record<string, string> = {
'긴급': 'bg-red-100 text-red-700',
'우선': 'bg-orange-100 text-orange-700',
'일반': 'bg-gray-100 text-gray-700',
};
// 필터 설정: 상태 + 우선순위
const filterConfig: FilterFieldConfig[] = [
{
key: 'status',
label: '상태',
type: 'single',
options: [
{ value: 'waiting', label: '작업대기' },
{ value: 'in_progress', label: '진행중' },
{ value: 'completed', label: '작업완료' },
],
},
{
key: 'priority',
label: '우선순위',
type: 'single',
options: [
{ value: 'urgent', label: '긴급' },
{ value: 'priority', label: '우선' },
{ value: 'normal', label: '일반' },
],
},
];
export function WorkOrderList() {
const router = useRouter();
// ===== 공정 ID 매핑 (getProcessOptions) =====
const [processMap, setProcessMap] = useState<Record<string, number>>({});
const [processMapLoaded, setProcessMapLoaded] = useState(false);
useEffect(() => {
const loadProcessOptions = async () => {
try {
const result = await getProcessOptions();
if (result.success && result.data) {
const map: Record<string, number> = {};
result.data.forEach((process: ProcessOption) => {
// process_name 또는 process_code로 탭 매핑
const tabKeyByName = PROCESS_NAME_TO_TAB[process.processName];
const tabKeyByCode = PROCESS_CODE_TO_TAB[process.processCode];
const tabKey = tabKeyByName || tabKeyByCode;
if (tabKey) {
map[tabKey] = process.id;
}
});
setProcessMap(map);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkOrderList] loadProcessOptions error:', error);
} finally {
setProcessMapLoaded(true);
}
};
loadProcessOptions();
}, []);
// ===== 통계 데이터 (외부 관리 - 별도 API) =====
const [statsData, setStatsData] = useState<WorkOrderStats>({
total: 0,
@@ -97,46 +163,61 @@ export function WorkOrderList() {
router.push('/ko/production/work-orders?mode=new');
}, [router]);
// ===== 탭 옵션 (통계 데이터 기반) =====
// ===== 탭 옵션 (공정 기반 3개) — 카운트는 API 응답으로 동적 업데이트 =====
const [tabCounts, setTabCounts] = useState<Record<string, number>>({
screen: 0,
slat: 0,
bending: 0,
});
const tabs: TabOption[] = useMemo(
() => [
{ value: 'all', label: '전체', count: statsData.total },
{ value: 'unassigned', label: '미배정', count: statsData.unassigned, color: 'gray' },
{ value: 'pending', label: '승인대기', count: statsData.pending, color: 'orange' },
{ value: 'waiting', label: '작업대기', count: statsData.waiting, color: 'yellow' },
{ value: 'in_progress', label: '작업중', count: statsData.inProgress, color: 'blue' },
{ value: 'completed', label: '작업완료', count: statsData.completed, color: 'green' },
{ value: 'screen', label: '스크린 공정', count: tabCounts.screen },
{ value: 'slat', label: '슬랫 공정', count: tabCounts.slat },
{ value: 'bending', label: '절곡 공정', count: tabCounts.bending },
],
[statsData]
[tabCounts]
);
// ===== 통계 카드 =====
// ===== 통계 카드 6개 (기획서 기반) =====
const stats: StatCard[] = useMemo(
() => [
{
label: '전체',
label: '전체 작업',
value: statsData.total,
icon: FileText,
iconColor: 'text-gray-600',
},
{
label: '미착수',
label: '작업 대기',
value: statsData.waiting + statsData.unassigned + statsData.pending,
icon: Calendar,
iconColor: 'text-orange-600',
icon: Clock,
iconColor: 'text-yellow-600',
},
{
label: '작업중',
value: statsData.inProgress,
icon: Users,
icon: Loader,
iconColor: 'text-blue-600',
},
{
label: '작업완료',
label: '작업 완료',
value: statsData.completed,
icon: CheckCircle2,
iconColor: 'text-green-600',
},
{
label: '긴급',
value: 0, // TODO: API에서 긴급 건수 제공 시 연동
icon: AlertTriangle,
iconColor: 'text-red-600',
},
{
label: '지연',
value: 0, // TODO: API에서 지연 건수 제공 시 연동
icon: TimerOff,
iconColor: 'text-orange-600',
},
],
[statsData]
);
@@ -157,15 +238,45 @@ export function WorkOrderList() {
actions: {
getList: async (params?: ListParams) => {
try {
// 탭 → processId 매핑
const tabValue = params?.tab || 'screen';
const processId = processMap[tabValue];
// 해당 공정이 DB에 없으면 빈 목록 반환
if (!processId) {
return {
success: true,
data: [],
totalCount: 0,
totalPages: 0,
};
}
// 필터 값 추출
const statusFilter = params?.filters?.status as string | undefined;
const priorityFilter = params?.filters?.priority as string | undefined;
const result = await getWorkOrders({
page: params?.page || 1,
perPage: params?.pageSize || ITEMS_PER_PAGE,
status: params?.tab === 'all' ? undefined : (params?.tab as TabFilter),
processId,
status: statusFilter && statusFilter !== 'all'
? (statusFilter as WorkOrderStatus)
: undefined,
priority: priorityFilter && priorityFilter !== 'all'
? priorityFilter
: undefined,
search: params?.search || undefined,
});
if (result.success) {
// 통계도 다시 로드 (탭 변경 시 최신 데이터 반영)
// 현재 탭의 카운트 업데이트
setTabCounts((prev) => ({
...prev,
[tabValue]: result.pagination.total,
}));
// 통계도 다시 로드
const statsResult = await getWorkOrderStats();
if (statsResult.success && statsResult.data) {
setStatsData(statsResult.data);
@@ -186,21 +297,20 @@ export function WorkOrderList() {
},
},
// 테이블 컬럼
// 테이블 컬럼 (기획서 기반 12개)
columns: [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'workOrderNo', label: '작업지시번호', className: 'min-w-[140px]' },
{ key: 'processType', label: '공정', className: 'w-[80px]' },
{ key: 'lotNo', label: '로트번호', className: 'min-w-[120px]' },
{ key: 'orderDate', label: '지시일', className: 'w-[100px]' },
{ key: 'isAssigned', label: '배정', className: 'w-[60px] text-center' },
{ key: 'hasWork', label: '작업', className: 'w-[60px] text-center' },
{ key: 'isStarted', label: '시작', className: 'w-[60px] text-center' },
{ key: 'status', label: '작업상태', className: 'w-[100px]' },
{ key: 'priority', label: '현장순위', className: 'w-[80px] text-center' },
{ key: 'assignee', label: '작업자', className: 'w-[80px]' },
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
{ key: 'workOrderNo', label: '작업번호', className: 'min-w-[140px]' },
{ key: 'salesOrderDate', label: '수주일', className: 'w-[100px]' },
{ key: 'shipmentDate', label: '출고예정일', className: 'w-[110px]' },
{ key: 'lotNo', label: '로트번호', className: 'min-w-[120px]' },
{ key: 'client', label: '수주처', className: 'min-w-[120px]' },
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
{ key: 'shutterCount', label: '틀수', className: 'w-[70px] text-center' },
{ key: 'status', label: '상태', className: 'w-[90px]' },
{ key: 'priority', label: '우선순위', className: 'w-[80px]' },
{ key: 'department', label: '부서', className: 'w-[90px]' },
{ key: 'note', label: '비고', className: 'min-w-[120px]' },
],
// 서버 사이드 페이지네이션
@@ -208,7 +318,7 @@ export function WorkOrderList() {
itemsPerPage: ITEMS_PER_PAGE,
// 검색
searchPlaceholder: '작업지시번호, 주처, 현장명 검색...',
searchPlaceholder: '작업번호, 주처, 현장명 검색...',
searchFilter: (item: WorkOrder, search: string) => {
const s = search.toLowerCase();
return (
@@ -220,20 +330,32 @@ export function WorkOrderList() {
);
},
// 설정
tabs,
defaultTab: 'all',
// 필터 설정 (상태 + 우선순위)
filterConfig,
initialFilters: {
status: 'all',
priority: 'all',
},
// 통계 카드
// 탭 설정 (공정 기반) — 통계 카드 위에 배치
tabs,
defaultTab: 'screen',
tabsPosition: 'above-stats',
// 통계 카드 (6개)
stats,
// 헤더 액션 (등록 버튼)
headerActions: () => (
<Button onClick={handleCreate}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
),
// 날짜 범위 선택기
dateRangeSelector: {
enabled: true,
showPresets: true,
},
// 등록 버튼 (발주에서 넘어오는 형태로 변경 예정)
// createButton: {
// label: '등록',
// onClick: handleCreate,
// },
// 테이블 행 렌더링
renderTableRow: (
@@ -256,23 +378,24 @@ export function WorkOrderList() {
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{item.workOrderNo}</TableCell>
<TableCell>{item.processName}</TableCell>
<TableCell>{item.salesOrderDate}</TableCell>
<TableCell>{item.shipmentDate}</TableCell>
<TableCell>{item.lotNo}</TableCell>
<TableCell>{item.orderDate}</TableCell>
<TableCell className="text-center">{item.isAssigned ? 'Y' : '-'}</TableCell>
<TableCell className="text-center">
{item.status !== 'unassigned' && item.status !== 'pending' ? 'Y' : '-'}
</TableCell>
<TableCell className="text-center">{item.isStarted ? 'Y' : '-'}</TableCell>
<TableCell>{item.client}</TableCell>
<TableCell className="max-w-[200px] truncate">{item.projectName}</TableCell>
<TableCell className="text-center">{item.shutterCount ?? '-'}</TableCell>
<TableCell>
<Badge className={`${WORK_ORDER_STATUS_COLORS[item.status]} border-0`}>
{WORK_ORDER_STATUS_LABELS[item.status]}
</Badge>
</TableCell>
<TableCell className="text-center">{item.priority}</TableCell>
<TableCell>{formatAssignees(item)}</TableCell>
<TableCell className="max-w-[200px] truncate">{item.projectName}</TableCell>
<TableCell>{item.shipmentDate}</TableCell>
<TableCell>
<Badge className={`${PRIORITY_COLORS[item.priorityLabel] || 'bg-gray-100 text-gray-700'} border-0`}>
{item.priorityLabel}
</Badge>
</TableCell>
<TableCell>{item.department}</TableCell>
<TableCell className="max-w-[150px] truncate">{item.note || '-'}</TableCell>
</TableRow>
);
},
@@ -309,21 +432,32 @@ export function WorkOrderList() {
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="공정" value={item.processName} />
<InfoField label="수주처" value={item.client} />
<InfoField label="로트번호" value={item.lotNo} />
<InfoField label="발주처" value={item.client} />
<InfoField label="작업자" value={formatAssignees(item)} />
<InfoField label="지시일" value={item.orderDate} />
<InfoField label="수주일" value={item.salesOrderDate} />
<InfoField label="출고예정일" value={item.shipmentDate} />
<InfoField label="현장순위" value={item.priority} />
<InfoField label="틀수" value={item.shutterCount ?? '-'} />
<InfoField label="우선순위" value={item.priorityLabel} />
<InfoField label="부서" value={item.department} />
<InfoField label="비고" value={item.note || '-'} />
</div>
}
/>
);
},
}),
[tabs, stats, handleRowClick, handleCreate]
[tabs, stats, processMap, handleRowClick]
);
// processMap 로딩 완료 전에는 UniversalListPage를 마운트하지 않음
// (초기 fetch에서 processId가 undefined로 전달되어 전체 데이터가 반환되는 문제 방지)
if (!processMapLoaded) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return <UniversalListPage config={config} />;
}

View File

@@ -48,6 +48,8 @@ export async function getWorkOrders(params?: {
perPage?: number;
status?: WorkOrderStatus | 'all';
processId?: number | 'all'; // 공정 ID (FK → processes.id)
processType?: 'screen' | 'slat' | 'bending'; // 공정 타입 필터
priority?: string; // 우선순위 필터 (urgent/priority/normal)
search?: string;
startDate?: string;
endDate?: string;
@@ -74,6 +76,12 @@ export async function getWorkOrders(params?: {
if (params?.processId && params.processId !== 'all') {
searchParams.set('process_id', String(params.processId));
}
if (params?.processType) {
searchParams.set('process_type', params.processType);
}
if (params?.priority && params.priority !== 'all') {
searchParams.set('priority', params.priority);
}
if (params?.search) searchParams.set('search', params.search);
if (params?.startDate) searchParams.set('start_date', params.startDate);
if (params?.endDate) searchParams.set('end_date', params.endDate);

View File

@@ -0,0 +1,481 @@
'use client';
/**
* 절곡 중간검사 성적서 문서 콘텐츠
*
* 기획서 기준:
* - 헤더: "중간검사성적서 (절곡)" + 결재란
* - 기본정보: 제품명/슬랫, 규격/절곡, 수주처, 현장명 | 제품LOT NO, 로트크기, 검사일자, 검사자
* + 제품명/KWE01, 마감유형/소니자감
* - ■ 중간검사 기준서: 2섹션 (가이드레일류 + 연기차단재)
* - ■ 중간검사 DATA: 분류, 제품명, 타입, 절곡상태결모양(양호/불량),
* 길이(도면치수/측정값입력), 너비(도면치수/측정값입력),
* 간격(포인트/도면치수/측정값입력), 판정(자동)
* - 부적합 내용 / 종합판정(자동)
*/
import { useState, useCallback, useMemo } from 'react';
import type { WorkOrder } from '../types';
interface BendingInspectionContentProps {
data: WorkOrder;
readOnly?: boolean;
}
type CheckStatus = '양호' | '불량' | null;
interface GapPoint {
point: string; // ①②③④⑤
designValue: string; // 도면치수
measured: string; // 측정값 (입력)
}
interface ProductRow {
id: string;
category: string;
productName: string;
productType: string;
bendingStatus: CheckStatus;
lengthDesign: string;
lengthMeasured: string;
widthDesign: string;
widthMeasured: string;
gapPoints: GapPoint[];
}
const INITIAL_PRODUCTS: Omit<ProductRow, 'bendingStatus' | 'lengthMeasured' | 'widthMeasured'>[] = [
{
id: 'guide-rail', category: 'KWE01', productName: '가이드레일', productType: '벽면형',
lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '①', designValue: '30', measured: '' },
{ point: '②', designValue: '80', measured: '' },
{ point: '③', designValue: '45', measured: '' },
{ point: '④', designValue: '40', measured: '' },
{ point: '⑤', designValue: '34', measured: '' },
],
},
{
id: 'case', category: 'KWE01', productName: '케이스', productType: '500X380',
lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '①', designValue: '380', measured: '' },
{ point: '②', designValue: '50', measured: '' },
{ point: '③', designValue: '240', measured: '' },
{ point: '④', designValue: '50', measured: '' },
],
},
{
id: 'bottom-finish', category: 'KWE01', productName: '하단마감재', productType: '60X40',
lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '②', designValue: '60', measured: '' },
{ point: '②', designValue: '64', measured: '' },
],
},
{
id: 'bottom-l-bar', category: 'KWE01', productName: '하단L-BAR', productType: '17X60',
lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '①', designValue: '17', measured: '' },
],
},
{
id: 'smoke-w50', category: 'KWE01', productName: '연기차단재', productType: 'W50\n가이드레일용',
lengthDesign: '3000', widthDesign: '',
gapPoints: [
{ point: '①', designValue: '50', measured: '' },
{ point: '②', designValue: '12', measured: '' },
],
},
{
id: 'smoke-w80', category: 'KWE01', productName: '연기차단재', productType: 'W80\n케이스용',
lengthDesign: '3000', widthDesign: '',
gapPoints: [
{ point: '①', designValue: '80', measured: '' },
{ point: '②', designValue: '12', measured: '' },
],
},
];
export function BendingInspectionContent({ data: order, readOnly = false }: BendingInspectionContentProps) {
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 [products, setProducts] = useState<ProductRow[]>(() =>
INITIAL_PRODUCTS.map(p => ({
...p,
bendingStatus: null,
lengthMeasured: '',
widthMeasured: '',
gapPoints: p.gapPoints.map(gp => ({ ...gp })),
}))
);
const [inadequateContent, setInadequateContent] = useState('');
const handleStatusChange = useCallback((productId: string, value: CheckStatus) => {
if (readOnly) return;
setProducts(prev => prev.map(p =>
p.id === productId ? { ...p, bendingStatus: value } : p
));
}, [readOnly]);
const handleInputChange = useCallback((productId: string, field: 'lengthMeasured' | 'widthMeasured', value: string) => {
if (readOnly) return;
setProducts(prev => prev.map(p =>
p.id === productId ? { ...p, [field]: value } : p
));
}, [readOnly]);
const handleGapMeasuredChange = useCallback((productId: string, gapIndex: number, value: string) => {
if (readOnly) return;
setProducts(prev => prev.map(p => {
if (p.id !== productId) return p;
const newGapPoints = p.gapPoints.map((gp, i) =>
i === gapIndex ? { ...gp, measured: value } : gp
);
return { ...p, gapPoints: newGapPoints };
}));
}, [readOnly]);
// 행별 판정 자동 계산
const getProductJudgment = useCallback((product: ProductRow): '적' | '부' | null => {
if (product.bendingStatus === '불량') return '부';
if (product.bendingStatus === '양호') return '적';
return null;
}, []);
// 종합판정 자동 계산
const overallResult = useMemo(() => {
const judgments = products.map(getProductJudgment);
if (judgments.some(j => j === '부')) return '불합격';
if (judgments.every(j => j === '적')) return '합격';
return null;
}, [products, getProductJudgment]);
const inputClass = 'w-full text-center border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs';
// 전체 행 수 계산 (간격 포인트 수 합계)
const totalRows = products.reduce((sum, p) => sum + p.gapPoints.length, 0);
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">
: {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"></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>
</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.items?.length || 0} </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>
<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">KWE01</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"></td>
</tr>
</tbody>
</table>
{/* ===== 중간검사 기준서 (1) 가이드레일/케이스/하단마감재/하단L-BAR ===== */}
<div className="mb-1 font-bold text-sm"> </div>
<table className="w-full table-fixed border-collapse text-xs mb-4">
<colgroup>
<col style={{width: '80px'}} />
<col style={{width: '180px'}} />
<col style={{width: '52px'}} />
<col style={{width: '52px'}} />
<col />
<col style={{width: '68px'}} />
<col style={{width: '78px'}} />
<col style={{width: '110px'}} />
</colgroup>
<tbody>
{/* 헤더 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-bold text-center align-middle" rowSpan={4}>
<br/><br/><br/> L-BAR
</td>
<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" 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 p-2 text-center text-gray-500 align-middle text-xs" rowSpan={3}>
<br/>
</td>
<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>
{/* ===== 중간검사 기준서 (2) 연기차단재 ===== */}
<table className="w-full table-fixed border-collapse text-xs mb-6">
<colgroup>
<col style={{width: '80px'}} />
<col style={{width: '180px'}} />
<col style={{width: '52px'}} />
<col style={{width: '52px'}} />
<col />
<col style={{width: '68px'}} />
<col style={{width: '78px'}} />
<col style={{width: '110px'}} />
</colgroup>
<tbody>
{/* 헤더 */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-bold text-center align-middle" rowSpan={6}>
</td>
<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" 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>
{/* 겉모양 | 절곡상태 (row 1) */}
<tr>
<td className="border border-gray-400 p-2 text-center text-gray-300 align-middle" rowSpan={5}>
<div className="h-32 flex items-center justify-center"> </div>
</td>
<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 font-medium" rowSpan={2}></td>
<td className="border border-gray-400 px-2 py-1" rowSpan={2}> </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 text-center" rowSpan={5}>n = 1, c = 0</td>
<td className="border border-gray-400 px-2 py-1">KS F 4510 5.1</td>
</tr>
{/* 겉모양 | 절곡상태 (row 2 - 관련규정 분리) */}
<tr>
<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 bg-gray-50" rowSpan={3}><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={3}></td>
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={3}></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">W50 : 50 ± 5<br/>W80 : 80 ± 5</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>
</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-14" rowSpan={2}></th>
<th className="border border-gray-400 p-1" rowSpan={2}></th>
<th className="border border-gray-400 p-1 w-16" 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-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-10"></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>
{products.map((product) => {
const judgment = getProductJudgment(product);
const rowCount = product.gapPoints.length;
return product.gapPoints.map((gap, gapIdx) => (
<tr key={`${product.id}-${gapIdx}`}>
{/* 첫 번째 간격 행에만 rowSpan 적용 */}
{gapIdx === 0 && (
<>
<td className="border border-gray-400 p-1 text-center font-medium bg-gray-50" rowSpan={rowCount}>{product.category}</td>
<td className="border border-gray-400 p-1" rowSpan={rowCount}>{product.productName}</td>
<td className="border border-gray-400 p-1 text-center whitespace-pre-line" rowSpan={rowCount}>{product.productType}</td>
{/* 절곡상태 - 양호/불량 체크 */}
<td className="border border-gray-400 p-1" rowSpan={rowCount}>
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={product.bendingStatus === '양호'}
onChange={() => handleStatusChange(product.id, product.bendingStatus === '양호' ? null : '양호')}
disabled={readOnly}
className="w-3 h-3"
/>
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={product.bendingStatus === '불량'}
onChange={() => handleStatusChange(product.id, product.bendingStatus === '불량' ? null : '불량')}
disabled={readOnly}
className="w-3 h-3"
/>
</label>
</div>
</td>
{/* 길이 */}
<td className="border border-gray-400 p-1 text-center" rowSpan={rowCount}>{product.lengthDesign}</td>
<td className="border border-gray-400 p-1" rowSpan={rowCount}>
<input type="text" value={product.lengthMeasured} onChange={(e) => handleInputChange(product.id, 'lengthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 너비 */}
<td className="border border-gray-400 p-1 text-center" rowSpan={rowCount}>{product.widthDesign || 'N/A'}</td>
<td className="border border-gray-400 p-1" rowSpan={rowCount}>
<input type="text" value={product.widthMeasured} onChange={(e) => handleInputChange(product.id, 'widthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
</>
)}
{/* 간격 - 포인트별 개별 행 */}
<td className="border border-gray-400 p-1 text-center">{gap.point}</td>
<td className="border border-gray-400 p-1 text-center">{gap.designValue}</td>
<td className="border border-gray-400 p-1">
<input type="text" value={gap.measured} onChange={(e) => handleGapMeasuredChange(product.id, gapIdx, e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 판정 - 자동 (첫 행에만) */}
{gapIdx === 0 && (
<td className={`border border-gray-400 p-1 text-center font-bold ${
judgment === '적' ? 'text-blue-600' : judgment === '부' ? 'text-red-600' : 'text-gray-300'
}`} rowSpan={rowCount}>
{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-top"> </td>
<td className="border border-gray-400 px-3 py-2" colSpan={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>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2" colSpan={3}></td>
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm ${
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
}`}>
{overallResult || '합격'}
</td>
</tr>
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,212 @@
'use client';
/**
* 절곡 작업일지 문서 콘텐츠
*
* 기획서 기준 구성:
* - 헤더: "작업일지 (절곡)" + 문서번호/작성일자 + 결재란(결재|작성/승인/승인/승인)
* - 신청업체(수주일,수주처,담당자,연락처) / 신청내용(현장명,작업일자,제품LOT NO,생산담당자,출고예정일)
* - 제품명 / 재질 / 마감 / 유형 테이블
* - 작업내역: 유형명, 세부품명, 재질, 입고 & 생산 LOT NO, 길이/규격, 수량
* - 생산량 합계 [kg]: SUS / EGI
*/
import type { WorkOrder } from '../types';
import { SectionHeader } from '@/components/document-system';
interface BendingWorkLogContentProps {
data: WorkOrder;
}
export function BendingWorkLogContent({ data: order }: BendingWorkLogContentProps) {
const today = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
const fullDate = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const documentNo = order.workOrderNo || 'ABC123';
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
const items = order.items || [];
const formattedDueDate = order.dueDate !== '-'
? new Date(order.dueDate).toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '')
: '-';
// 빈 행 수 (기획서에 여러 빈 행 표시)
const EMPTY_ROWS = 4;
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">
: {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"></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>
</tbody>
</table>
</div>
{/* ===== 신청업체 / 신청내용 ===== */}
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr>
<th className="border border-gray-400 bg-gray-100 px-3 py-2 text-center" colSpan={2}></th>
<th className="border border-gray-400 bg-gray-100 px-3 py-2 text-center" colSpan={2}></th>
</tr>
</thead>
<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.salesOrderDate || '-'}</td>
<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.projectName}</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">{primaryAssignee}</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.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">{primaryAssignee}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium" colSpan={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">{formattedDueDate}</td>
</tr>
</tbody>
</table>
{/* ===== 제품 정보 ===== */}
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-2"></th>
<th className="border border-gray-400 p-2"></th>
<th className="border border-gray-400 p-2"></th>
<th className="border border-gray-400 p-2"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
</tr>
</tbody>
</table>
{/* ===== 작업내역 ===== */}
<SectionHeader variant="dark"></SectionHeader>
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-2"></th>
<th className="border border-gray-400 p-2"></th>
<th className="border border-gray-400 p-2"></th>
<th className="border border-gray-400 p-2"> &amp; LOT NO</th>
<th className="border border-gray-400 p-2">/</th>
<th className="border border-gray-400 p-2 w-16"></th>
</tr>
</thead>
<tbody>
{items.length > 0 ? (
items.map((item, idx) => (
<tr key={item.id}>
<td className="border border-gray-400 p-2">{item.productName}</td>
<td className="border border-gray-400 p-2">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">{order.lotNo}</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">{item.quantity}</td>
</tr>
))
) : (
Array.from({ length: EMPTY_ROWS }).map((_, idx) => (
<tr key={idx}>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
</tr>
))
)}
</tbody>
</table>
{/* ===== 생산량 합계 [kg] ===== */}
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-2"> [kg]</th>
<th className="border border-gray-400 p-2">SUS</th>
<th className="border border-gray-400 p-2">EGI</th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
</tr>
<tr>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
</tr>
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,156 @@
'use client';
/**
* 중간검사 성적서 모달
*
* DocumentViewer 껍데기 안에 공정별 검사 성적서 콘텐츠를 표시
* - screen: ScreenInspectionContent
* - slat: SlatInspectionContent
* - bending: BendingInspectionContent
*/
import { useState, useEffect } from 'react';
import { Loader2 } from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { getWorkOrderById } from '../actions';
import type { WorkOrder, ProcessType } from '../types';
import { ScreenInspectionContent } from './ScreenInspectionContent';
import { SlatInspectionContent } from './SlatInspectionContent';
import { BendingInspectionContent } from './BendingInspectionContent';
const PROCESS_LABELS: Record<ProcessType, string> = {
screen: '스크린',
slat: '슬랫',
bending: '절곡',
};
interface InspectionReportModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workOrderId: string | null;
processType?: ProcessType;
}
export function InspectionReportModal({
open,
onOpenChange,
workOrderId,
processType = 'screen',
}: InspectionReportModalProps) {
const [order, setOrder] = useState<WorkOrder | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 목업 WorkOrder 생성
const createMockOrder = (id: string, pType: ProcessType): WorkOrder => ({
id,
workOrderNo: 'KD-WO-260129-01',
lotNo: 'KD-SA-260129-01',
processId: 1,
processName: pType === 'slat' ? '슬랫' : pType === 'bending' ? '절곡' : '스크린',
processCode: pType,
processType: pType,
status: 'in_progress',
client: '(주)경동',
projectName: '서울 강남 현장',
dueDate: '2026-02-05',
assignee: '홍길동',
assignees: [{ id: '1', name: '홍길동', isPrimary: true }],
orderDate: '2026-01-20',
scheduledDate: '2026-01-29',
shipmentDate: '2026-02-05',
salesOrderDate: '2026-01-15',
isAssigned: true,
isStarted: true,
priority: 3,
priorityLabel: '긴급',
shutterCount: 12,
department: '생산부',
items: [
{ id: '1', no: 1, status: 'in_progress', productName: '와이어 스크린', floorCode: '1층/FSS-01', specification: '8,260 X 8,350', quantity: 2, unit: 'EA' },
{ id: '2', no: 2, status: 'waiting', productName: '메쉬 스크린', floorCode: '2층/FSS-03', specification: '6,400 X 5,200', quantity: 4, unit: 'EA' },
{ id: '3', no: 3, status: 'completed', productName: '광폭 와이어', floorCode: '3층/FSS-05', specification: '12,000 X 4,500', quantity: 1, unit: 'EA' },
],
currentStep: { key: 'cutting', label: '절단', order: 2 },
completedSteps: ['material_input'],
totalProgress: 25,
issues: [],
memo: '',
createdAt: '2026-01-20T09:00:00',
updatedAt: '2026-01-29T14:00:00',
});
useEffect(() => {
if (open && workOrderId) {
// 목업 ID인 경우 API 호출 생략
if (workOrderId.startsWith('mock-')) {
setOrder(createMockOrder(workOrderId, processType));
setError(null);
return;
}
setIsLoading(true);
setError(null);
getWorkOrderById(workOrderId)
.then((result) => {
if (result.success && result.data) {
setOrder(result.data);
} else {
setError(result.error || '데이터를 불러올 수 없습니다.');
}
})
.catch(() => {
setError('서버 오류가 발생했습니다.');
})
.finally(() => {
setIsLoading(false);
});
} else if (!open) {
setOrder(null);
setError(null);
}
}, [open, workOrderId, processType]);
if (!workOrderId) return null;
const processLabel = PROCESS_LABELS[processType] || '스크린';
const subtitle = order ? `${processLabel} 생산부서` : undefined;
const renderContent = () => {
if (!order) return null;
switch (processType) {
case 'screen':
return <ScreenInspectionContent data={order} />;
case 'slat':
return <SlatInspectionContent data={order} />;
case 'bending':
return <BendingInspectionContent data={order} />;
default:
return <ScreenInspectionContent data={order} />;
}
};
return (
<DocumentViewer
title="중간검사 성적서"
subtitle={subtitle}
preset="inspection"
open={open}
onOpenChange={onOpenChange}
>
{isLoading ? (
<div className="flex items-center justify-center h-64 bg-white">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
) : error || !order ? (
<div className="flex flex-col items-center justify-center h-64 gap-4 bg-white">
<p className="text-muted-foreground">{error || '데이터를 불러올 수 없습니다.'}</p>
</div>
) : (
renderContent()
)}
</DocumentViewer>
);
}

View File

@@ -0,0 +1,383 @@
'use client';
/**
* 스크린 중간검사 성적서 문서 콘텐츠
*
* 기획서 기준:
* - 헤더: "중간검사성적서 (스크린)" + 결재란
* - 기본정보: 제품명/스크린, 규격/와이어 글라스 코팅직물, 수주처, 현장명 | 제품LOT NO, 로트크기, 검사일자, 검사자
* - ■ 중간검사 기준서: 도해 + 검사항목/검사기준/검사방법/검사주기/관련규정
* 가공상태, 재봉상태, 조립상태, 치수(길이/높이/간격)
* - ■ 중간검사 DATA: No, 가공상태결모양(양호/불량), 재봉상태결모양(양호/불량), 조립상태(양호/불량),
* 길이(도면치수/측정값입력), 나비(도면치수/측정값입력), 간격(기준치/OK·NG선택), 판정(자동)
* - 부적합 내용 / 종합판정(자동)
*/
import { useState, useCallback, useMemo } from 'react';
import type { WorkOrder } from '../types';
interface ScreenInspectionContentProps {
data: WorkOrder;
readOnly?: boolean;
}
type CheckStatus = '양호' | '불량' | null;
type GapResult = 'OK' | 'NG' | null;
interface InspectionRow {
id: number;
processStatus: CheckStatus; // 가공상태 결모양
sewingStatus: CheckStatus; // 재봉상태 결모양
assemblyStatus: CheckStatus; // 조립상태
lengthDesign: string; // 길이 도면치수 (표시용)
lengthMeasured: string; // 길이 측정값 (입력)
widthDesign: string; // 나비 도면치수 (표시용)
widthMeasured: string; // 나비 측정값 (입력)
gapStandard: string; // 간격 기준치 (표시용)
gapResult: GapResult; // 간격 측정값 (OK/NG 선택)
}
const DEFAULT_ROW_COUNT = 6;
export function ScreenInspectionContent({ data: order, readOnly = false }: ScreenInspectionContentProps) {
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,
sewingStatus: null,
assemblyStatus: null,
lengthDesign: '7,400',
lengthMeasured: '',
widthDesign: '2,950',
widthMeasured: '',
gapStandard: '400 이하',
gapResult: null,
}))
);
const [inadequateContent, setInadequateContent] = useState('');
const handleStatusChange = useCallback((rowId: number, field: 'processStatus' | 'sewingStatus' | '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: 'lengthMeasured' | 'widthMeasured', value: string) => {
if (readOnly) return;
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, [field]: value } : row
));
}, [readOnly]);
const handleGapChange = useCallback((rowId: number, value: GapResult) => {
if (readOnly) return;
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, gapResult: value } : row
));
}, [readOnly]);
// 행별 판정 자동 계산
const getRowJudgment = useCallback((row: InspectionRow): '적' | '부' | null => {
const { processStatus, sewingStatus, assemblyStatus, gapResult } = row;
// 하나라도 불량 or NG → 부
if (processStatus === '불량' || sewingStatus === '불량' || assemblyStatus === '불량' || gapResult === 'NG') {
return '부';
}
// 모두 양호 + OK → 적
if (processStatus === '양호' && sewingStatus === '양호' && assemblyStatus === '양호' && gapResult === 'OK') {
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]);
// 체크박스 렌더 (양호/불량)
const renderCheckStatus = (rowId: number, field: 'processStatus' | 'sewingStatus' | '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">
<input
type="checkbox"
checked={value === '양호'}
onChange={() => handleStatusChange(rowId, field, value === '양호' ? null : '양호')}
disabled={readOnly}
className="w-3 h-3"
/>
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={value === '불량'}
onChange={() => handleStatusChange(rowId, field, value === '불량' ? null : '불량')}
disabled={readOnly}
className="w-3 h-3"
/>
</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">
: {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"></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>
</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.items?.length || 0} </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>
{/* ===== 중간검사 기준서 ===== */}
<div className="mb-1 font-bold text-sm"> </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/4" 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" colSpan={2}></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"></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" colSpan={2}></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"></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" colSpan={2}></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"></td>
<td className="border border-gray-400 px-2 py-1">KS F 4510<br/>n = 1, c = 0</td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium bg-gray-50" rowSpan={3}><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"></td>
<td className="border border-gray-400 px-2 py-1 text-center"></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"></td>
<td className="border border-gray-400 px-2 py-1"> + 40</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"></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"></td>
<td className="border border-gray-400 px-2 py-1">400 </td>
<td className="border border-gray-400 px-2 py-1 text-center">GONO </td>
<td className="border border-gray-400 px-2 py-1 text-center"></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 w-16" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-1 w-16" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-1 w-16" rowSpan={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 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>
</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, 'sewingStatus', row.sewingStatus)}
{/* 조립상태 - 양호/불량 체크 */}
{renderCheckStatus(row.id, 'assemblyStatus', row.assemblyStatus)}
{/* 길이 - 도면치수 표시 + 측정값 입력 */}
<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.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>
{/* 간격 - 기준치 표시 + OK/NG 선택 */}
<td className="border border-gray-400 p-1 text-center">{row.gapStandard}</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">
<input
type="checkbox"
checked={row.gapResult === 'OK'}
onChange={() => handleGapChange(row.id, row.gapResult === 'OK' ? null : 'OK')}
disabled={readOnly}
className="w-3 h-3"
/>
OK
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={row.gapResult === 'NG'}
onChange={() => handleGapChange(row.id, row.gapResult === 'NG' ? null : 'NG')}
disabled={readOnly}
className="w-3 h-3"
/>
NG
</label>
</div>
</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-top"> </td>
<td className="border border-gray-400 px-3 py-2" colSpan={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>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2" colSpan={3}></td>
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm ${
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
}`}>
{overallResult || '합격'}
</td>
</tr>
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,219 @@
'use client';
/**
* 스크린 작업일지 문서 콘텐츠
*
* 기획서 스크린샷 기준 구성:
* - 헤더: "작업일지 (스크린)" + 문서번호/작성일자 + 결재란(작성/승인/승인/승인)
* - 신청업체(수주일,수주처,담당자,연락처) / 신청내용(현장명,작업일자,제품LOT NO,생산담당자,출고예정일)
* - 작업내역 테이블: No, 입고 LOT NO, 제품명, 부호, 제작사이즈(가로/세로), 나머지 높이,
* 규격(매수)(1220/900/600/400/300), 제작, 재단 사항, 잔량, 완료
* - 합계
* - 내화실 입고 LOT NO
* - 비고
*/
import type { WorkOrder } from '../types';
import { SectionHeader } from '@/components/document-system';
interface ScreenWorkLogContentProps {
data: WorkOrder;
}
export function ScreenWorkLogContent({ data: order }: ScreenWorkLogContentProps) {
const today = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
const fullDate = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const documentNo = order.workOrderNo || 'ABC123';
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
const items = order.items || [];
const formattedDueDate = order.dueDate !== '-'
? new Date(order.dueDate).toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '')
: '-';
// 규격 사이즈 컬럼
const SCREEN_SIZES = ['1220', '900', '600', '400', '300'];
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">
: {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"></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>
</tbody>
</table>
</div>
{/* ===== 신청업체 / 신청내용 ===== */}
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr>
<th className="border border-gray-400 bg-gray-100 px-3 py-2 text-center" colSpan={2}></th>
<th className="border border-gray-400 bg-gray-100 px-3 py-2 text-center" colSpan={2}></th>
</tr>
</thead>
<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.salesOrderDate || '-'}</td>
<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.projectName}</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">{primaryAssignee}</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.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">{primaryAssignee}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium" colSpan={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">{formattedDueDate}</td>
</tr>
</tbody>
</table>
{/* ===== 작업내역 ===== */}
<SectionHeader variant="dark"></SectionHeader>
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-2 w-8" rowSpan={2}>No.</th>
<th className="border border-gray-400 p-2 w-20" rowSpan={2}> LOT<br/>NO</th>
<th className="border border-gray-400 p-2" rowSpan={2}></th>
<th className="border border-gray-400 p-2 w-12" rowSpan={2}></th>
<th className="border border-gray-400 p-2" colSpan={2}></th>
<th className="border border-gray-400 p-2 w-12" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-2" colSpan={5}> ()</th>
<th className="border border-gray-400 p-2 w-14" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-2 w-12" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-2 w-14" rowSpan={2}><br/></th>
</tr>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-2 w-12"></th>
<th className="border border-gray-400 p-2 w-12"></th>
{SCREEN_SIZES.map(size => (
<th key={size} className="border border-gray-400 p-1 w-10">{size}</th>
))}
</tr>
</thead>
<tbody>
{items.length > 0 ? (
items.map((item, idx) => (
<tr key={item.id}>
<td className="border border-gray-400 p-2 text-center">{idx + 1}</td>
<td className="border border-gray-400 p-2 text-center">{order.lotNo}</td>
<td className="border border-gray-400 p-2">{item.productName}</td>
<td className="border border-gray-400 p-2 text-center">{item.floorCode}</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
{SCREEN_SIZES.map(size => (
<td key={size} className="border border-gray-400 p-1 text-center">-</td>
))}
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
</tr>
))
) : (
<tr>
<td colSpan={15} className="border border-gray-400 p-4 text-center text-gray-400">
.
</td>
</tr>
)}
{/* 합계 행 */}
<tr className="bg-gray-50 font-medium">
<td className="border border-gray-400 p-2 text-center" colSpan={4}></td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
{SCREEN_SIZES.map(size => (
<td key={size} className="border border-gray-400 p-1 text-center">-</td>
))}
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
</tr>
</tbody>
</table>
{/* ===== 내화실 입고 LOT NO ===== */}
<table className="w-full border-collapse text-xs mb-6">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-40"> LOT NO</td>
<td className="border border-gray-400 px-3 py-2 min-h-[32px]">&nbsp;</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-40 align-top"></td>
<td className="border border-gray-400 px-3 py-3 min-h-[60px]">
{order.note || ''}
</td>
</tr>
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,342 @@
'use client';
/**
* 슬랫 중간검사 성적서 문서 콘텐츠
*
* 기획서 기준:
* - 헤더: "중간검사성적서 (슬랫)" + 결재란
* - 기본정보: 제품명/슬랫, 규격/EGI 1.6T, 수주처, 현장명 | 제품LOT NO, 로트크기, 검사일자, 검사자
* - ■ 중간검사 기준서: 도해 + 검사항목/검사기준/검사방법/검사주기/관련규정
* 가공상태, 결모양, 조립상태, 치수(높이/길이)
* - ■ 중간검사 DATA: No, 가공상태결모양(양호/불량), 조립상태결모양(양호/불량),
* ①높이(기준치/측정값입력), ②높이(기준치/측정값입력),
* 길이(엔드락제외)(도면치수/측정값입력), 판정(자동)
* - 부적합 내용 / 종합판정(자동)
*/
import { useState, useCallback, useMemo } from 'react';
import type { WorkOrder } from '../types';
interface SlatInspectionContentProps {
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; // 길이 측정값 (입력)
}
const DEFAULT_ROW_COUNT = 6;
export function SlatInspectionContent({ data: order, readOnly = false }: SlatInspectionContentProps) {
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: '16.5 ± 1',
height1Measured: '',
height2Standard: '14.5 ± 1',
height2Measured: '',
lengthDesign: '0',
lengthMeasured: '',
}))
);
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;
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, [field]: value } : 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]);
// 체크박스 렌더 (양호/불량)
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">
<input
type="checkbox"
checked={value === '양호'}
onChange={() => handleStatusChange(rowId, field, value === '양호' ? null : '양호')}
disabled={readOnly}
className="w-3 h-3"
/>
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={value === '불량'}
onChange={() => handleStatusChange(rowId, field, value === '불량' ? null : '불량')}
disabled={readOnly}
className="w-3 h-3"
/>
</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">
: {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"></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>
</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">EGI 1.6T</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?.length || 0} </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>
{/* ===== 중간검사 기준서 ===== */}
<div className="mb-1 font-bold text-sm"> </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={7}>
<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={3}></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" colSpan={2}></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"></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" colSpan={2} rowSpan={2}></td>
<td className="border border-gray-400 px-2 py-1" rowSpan={2}> <br/> <br/> <br/> </td>
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={2}>n = 1, c = 0</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"></td>
</tr>
{/* 치수 > 높이 > ① */}
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium bg-gray-50 text-center" rowSpan={3}><br/>(mm)</td>
<td className="border border-gray-400 px-2 py-1 font-medium text-center" rowSpan={2}></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">16.5 ± 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={3}></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 text-center"></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 text-center"></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">() ± 4</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-16" rowSpan={2}><br/></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={2}> (mm)<br/><span className="font-normal text-gray-500">( )</span></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>
</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 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-top"> </td>
<td className="border border-gray-400 px-3 py-2" colSpan={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>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2" colSpan={3}></td>
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm ${
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
}`}>
{overallResult || '합격'}
</td>
</tr>
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,197 @@
'use client';
/**
* 슬랫 작업일지 문서 콘텐츠
*
* 기획서 기준 구성:
* - 헤더: "작업일지 (슬랫)" + 문서번호/작성일자 + 결재란(결재|작성/승인/승인/승인)
* - 신청업체(수주일,수주처,담당자,연락처) / 신청내용(현장명,작업일자,제품LOT NO,생산담당자,출고예정일)
* - 작업내역: No, 입고 LOT NO, 방화유리 수량, 제품명,
* 제작사이즈(mm)-미미제외(가로/세로/매수(세로)), 조인트바 수량, 코일 사용량, 설치홈/부호
* - 생산량 합계[m²] / 조인트바 합계
* - 비고
*/
import type { WorkOrder } from '../types';
import { SectionHeader } from '@/components/document-system';
interface SlatWorkLogContentProps {
data: WorkOrder;
}
export function SlatWorkLogContent({ data: order }: SlatWorkLogContentProps) {
const today = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
const fullDate = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const documentNo = order.workOrderNo || 'ABC123';
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
const items = order.items || [];
const formattedDueDate = order.dueDate !== '-'
? new Date(order.dueDate).toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '')
: '-';
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">
: {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"></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>
</tbody>
</table>
</div>
{/* ===== 신청업체 / 신청내용 ===== */}
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr>
<th className="border border-gray-400 bg-gray-100 px-3 py-2 text-center" colSpan={2}></th>
<th className="border border-gray-400 bg-gray-100 px-3 py-2 text-center" colSpan={2}></th>
</tr>
</thead>
<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.salesOrderDate || '-'}</td>
<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.projectName}</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">{primaryAssignee}</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.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">{primaryAssignee}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium" colSpan={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">{formattedDueDate}</td>
</tr>
</tbody>
</table>
{/* ===== 작업내역 ===== */}
<SectionHeader variant="dark"></SectionHeader>
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-2 w-8" rowSpan={2}>No.</th>
<th className="border border-gray-400 p-2 w-24" rowSpan={2}> LOT<br/>NO</th>
<th className="border border-gray-400 p-2 w-16" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-2" rowSpan={2}></th>
<th className="border border-gray-400 p-2" colSpan={3}>(mm) - </th>
<th className="border border-gray-400 p-2 w-16" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-2 w-16" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-2 w-16" rowSpan={2}>/<br/></th>
</tr>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-2 w-14"></th>
<th className="border border-gray-400 p-2 w-14"></th>
<th className="border border-gray-400 p-2 w-14"><br/>()</th>
</tr>
</thead>
<tbody>
{items.length > 0 ? (
items.map((item, idx) => (
<tr key={item.id}>
<td className="border border-gray-400 p-2 text-center">{idx + 1}</td>
<td className="border border-gray-400 p-2 text-center">{order.lotNo}</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2">{item.productName}</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
</tr>
))
) : (
<tr>
<td colSpan={10} className="border border-gray-400 p-4 text-center text-gray-400">
.
</td>
</tr>
)}
</tbody>
</table>
{/* ===== 생산량 합계 / 조인트바 합계 ===== */}
<table className="w-full border-collapse text-xs mb-6">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-36"> [m²]</td>
<td className="border border-gray-400 px-3 py-2"></td>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-36"> </td>
<td className="border border-gray-400 px-3 py-2"></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-40 align-top"></td>
<td className="border border-gray-400 px-3 py-3 min-h-[60px]">
{order.note || ''}
</td>
</tr>
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,12 @@
// 작업일지 문서 (공정별)
export { ScreenWorkLogContent } from './ScreenWorkLogContent';
export { SlatWorkLogContent } from './SlatWorkLogContent';
export { BendingWorkLogContent } from './BendingWorkLogContent';
// 중간검사 성적서 문서 (공정별)
export { ScreenInspectionContent } from './ScreenInspectionContent';
export { SlatInspectionContent } from './SlatInspectionContent';
export { BendingInspectionContent } from './BendingInspectionContent';
// 모달
export { InspectionReportModal } from './InspectionReportModal';

View File

@@ -110,6 +110,7 @@ export interface WorkOrderItem {
floorCode: string; // 층/부호
specification: string; // 규격
quantity: number;
unit: string; // 단위
}
// 전개도 상세 (절곡용)
@@ -180,6 +181,12 @@ export interface WorkOrder {
// 우선순위
priority: number; // 1~9 (1=긴급, 9=낮음)
priorityLabel: string; // 우선순위 라벨 (긴급/우선/일반)
// 수주 관련
salesOrderDate: string; // 수주일
shutterCount: number | null; // 틀수
department: string; // 부서명
// 품목
items: WorkOrderItem[];
@@ -306,9 +313,12 @@ export interface WorkOrderApi {
updated_at: string;
deleted_at: string | null;
// Relations
priority: number | null;
shutter_count: number | null;
sales_order?: {
id: number;
order_no: string;
order_date?: string;
client?: { id: number; name: string };
};
process?: {
@@ -370,6 +380,14 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
return mapping[name] || 'screen';
};
// 우선순위 매핑 (1~3: 긴급, 4~6: 우선, 7~9: 일반)
const priorityValue = api.priority ?? 5;
const getPriorityLabel = (p: number): string => {
if (p <= 3) return '긴급';
if (p <= 6) return '우선';
return '일반';
};
return {
id: String(api.id),
workOrderNo: api.work_order_no,
@@ -397,7 +415,11 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
shipmentDate: api.scheduled_date || '-',
isAssigned: api.assignee_id !== null || assignees.length > 0,
isStarted: ['in_progress', 'completed', 'shipped'].includes(api.status),
priority: 5, // Default priority
priority: priorityValue,
priorityLabel: getPriorityLabel(priorityValue),
salesOrderDate: api.sales_order?.order_date || api.created_at.split('T')[0],
shutterCount: api.shutter_count ?? null,
department: api.team?.name || '-',
currentStep: getStatusStep(api.status),
items: (api.items || []).map((item, idx) => ({
id: String(item.id),
@@ -407,6 +429,7 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
floorCode: '-',
specification: item.specification || '-',
quantity: item.quantity,
unit: item.unit || '-',
})),
bendingDetails: api.bending_detail ? transformBendingDetail(api.bending_detail) : undefined,
issues: (api.issues || []).map(issue => ({