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 => ({

View File

@@ -1,13 +1,11 @@
'use client';
/**
* 자재투입 모달
* API 연동 완료 (2025-12-26)
* 자재투입 모달 (기획서 기반)
*
* 기획 화면에 맞춘 레이아웃:
* - FIFO 순위 설명 (1 최우선, 2 차선, 3+ 대기)
* - ① 자재 선택 (BOM 기준) 테이블
* - 취소 / 투입 등록 버튼 (전체 너비)
* 기획서 변경: BOM 체크박스 → 투입수량 입력 테이블
* 컬럼: 로트번호 | 품목명 | 수량 | 단위 | 투입 수량 (input, 숫자만)
* 하단: 취소 / 투입
*/
import { useState, useEffect, useCallback } from 'react';
@@ -20,8 +18,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
@@ -54,20 +51,48 @@ export function MaterialInputModal({
isCompletionFlow = false,
onSaveMaterials,
}: MaterialInputModalProps) {
const [selectedMaterials, setSelectedMaterials] = useState<Set<string>>(new Set());
const [materials, setMaterials] = useState<MaterialForInput[]>([]);
const [inputQuantities, setInputQuantities] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// 목업 자재 데이터 (기획서 기반 10행)
const MOCK_MATERIALS: MaterialForInput[] = Array.from({ length: 10 }, (_, i) => ({
id: 100 + i,
materialCode: '123123',
materialName: '품목명',
unit: 'm',
currentStock: 500,
fifoRank: i + 1,
}));
// API로 자재 목록 로드
const loadMaterials = useCallback(async () => {
if (!order) return;
setIsLoading(true);
try {
// 목업 아이템인 경우 목업 자재 데이터 사용
if (order.id.startsWith('mock-')) {
setMaterials(MOCK_MATERIALS);
const initialQuantities: Record<string, string> = {};
MOCK_MATERIALS.forEach((m) => {
initialQuantities[String(m.id)] = '';
});
setInputQuantities(initialQuantities);
setIsLoading(false);
return;
}
const result = await getMaterialsForWorkOrder(order.id);
if (result.success) {
setMaterials(result.data);
// 초기 투입 수량 비우기
const initialQuantities: Record<string, string> = {};
result.data.forEach((m) => {
initialQuantities[String(m.id)] = '';
});
setInputQuantities(initialQuantities);
} else {
toast.error(result.error || '자재 목록 조회에 실패했습니다.');
}
@@ -87,47 +112,50 @@ export function MaterialInputModal({
}
}, [open, order, loadMaterials]);
const handleToggleMaterial = (materialId: string) => {
setSelectedMaterials((prev) => {
const next = new Set(prev);
if (next.has(materialId)) {
next.delete(materialId);
} else {
next.add(materialId);
}
return next;
});
// 투입 수량 변경 핸들러 (숫자만 허용)
const handleQuantityChange = (materialId: string, value: string) => {
// 숫자만 허용
const numericValue = value.replace(/[^0-9]/g, '');
setInputQuantities((prev) => ({
...prev,
[materialId]: numericValue,
}));
};
// 투입 등록
const handleSubmit = async () => {
if (!order) return;
// 투입 수량이 입력된 항목 필터
const materialsWithQuantity = materials.filter((m) => {
const qty = inputQuantities[String(m.id)];
return qty && parseInt(qty) > 0;
});
if (materialsWithQuantity.length === 0) {
toast.error('투입 수량을 입력해주세요.');
return;
}
setIsSubmitting(true);
try {
// 선택된 자재 ID 배열
const materialIds = materials
.filter((m) => selectedMaterials.has(String(m.id)))
.map((m) => m.id);
const materialIds = materialsWithQuantity.map((m) => m.id);
const result = await registerMaterialInput(order.id, materialIds);
if (result.success) {
toast.success('자재 투입이 등록되었습니다.');
// onSaveMaterials 콜백 호출 (기존 호환성)
// onSaveMaterials 콜백 호출
if (onSaveMaterials) {
const selectedMaterialList: MaterialInput[] = materials
.filter((m) => selectedMaterials.has(String(m.id)))
.map((m) => ({
id: String(m.id),
materialCode: m.materialCode,
materialName: m.materialName,
unit: m.unit,
currentStock: m.currentStock,
fifoRank: m.fifoRank,
}));
onSaveMaterials(order.id, selectedMaterialList);
const savedList: MaterialInput[] = materialsWithQuantity.map((m) => ({
id: String(m.id),
lotNo: '', // API에서 가져올 필드
materialName: m.materialName,
quantity: m.currentStock,
unit: m.unit,
inputQuantity: parseInt(inputQuantities[String(m.id)] || '0'),
}));
onSaveMaterials(order.id, savedList);
}
resetAndClose();
@@ -147,13 +175,12 @@ export function MaterialInputModal({
}
};
// 취소
const handleCancel = () => {
resetAndClose();
};
const resetAndClose = () => {
setSelectedMaterials(new Set());
setInputQuantities({});
onOpenChange(false);
};
@@ -164,100 +191,79 @@ export function MaterialInputModal({
<DialogContent className="max-w-2xl p-0 gap-0">
{/* 헤더 */}
<DialogHeader className="p-6 pb-4">
<DialogTitle className="text-xl font-semibold"> </DialogTitle>
<DialogTitle className="text-xl font-semibold"> </DialogTitle>
</DialogHeader>
<div className="px-6 pb-6 space-y-6">
{/* FIFO 순위 설명 */}
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<span className="text-sm font-medium text-gray-700">FIFO :</span>
<div className="flex items-center gap-4">
<span className="flex items-center gap-1.5">
<Badge className="bg-gray-900 hover:bg-gray-900 text-white rounded-full w-6 h-6 flex items-center justify-center p-0 text-xs">
1
</Badge>
<span className="text-sm text-gray-600"></span>
</span>
<span className="flex items-center gap-1.5">
<Badge className="bg-gray-900 hover:bg-gray-900 text-white rounded-full w-6 h-6 flex items-center justify-center p-0 text-xs">
2
</Badge>
<span className="text-sm text-gray-600"></span>
</span>
<span className="flex items-center gap-1.5">
<Badge className="bg-gray-900 hover:bg-gray-900 text-white rounded-full w-6 h-6 flex items-center justify-center p-0 text-xs">
3+
</Badge>
<span className="text-sm text-gray-600"></span>
</span>
{/* 자재 목록 테이블 */}
{isLoading ? (
<ContentSkeleton type="table" rows={4} />
) : materials.length === 0 ? (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-gray-500">
.
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
{/* 자재 선택 섹션 */}
<div>
<h3 className="text-sm font-medium text-gray-900 mb-3">
(BOM )
</h3>
{isLoading ? (
<ContentSkeleton type="table" rows={4} />
) : materials.length === 0 ? (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-gray-500">
.
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{materials.map((material) => (
<TableRow key={material.id}>
<TableCell className="text-center text-sm">
{material.materialCode}
</TableCell>
<TableCell className="text-center text-sm">
{material.materialName}
</TableCell>
<TableCell className="text-center text-sm">
{material.currentStock.toLocaleString()}
</TableCell>
<TableCell className="text-center text-sm">
{material.unit}
</TableCell>
<TableCell className="text-center">
<Input
type="text"
inputMode="numeric"
placeholder="0"
value={inputQuantities[String(material.id)] || ''}
onChange={(e) =>
handleQuantityChange(String(material.id), e.target.value)
}
className="w-20 mx-auto text-center h-8 text-sm"
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{materials.map((material) => (
<TableRow key={material.id}>
<TableCell className="text-center font-medium">
{material.materialCode}
</TableCell>
<TableCell className="text-center">{material.materialName}</TableCell>
<TableCell className="text-center">{material.unit}</TableCell>
<TableCell className="text-center">
{material.currentStock.toLocaleString()}
</TableCell>
<TableCell className="text-center">
<Checkbox
checked={selectedMaterials.has(String(material.id))}
onCheckedChange={() => handleToggleMaterial(String(material.id))}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
))}
</TableBody>
</Table>
</div>
)}
{/* 버튼 영역 */}
<div className="flex gap-3">
@@ -271,8 +277,8 @@ export function MaterialInputModal({
</Button>
<Button
onClick={handleSubmit}
disabled={selectedMaterials.size === 0 || isSubmitting}
className="flex-1 py-6 text-base font-medium bg-gray-400 hover:bg-gray-500 disabled:bg-gray-300"
disabled={isSubmitting}
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
>
{isSubmitting ? (
<>
@@ -280,7 +286,7 @@ export function MaterialInputModal({
...
</>
) : (
'투입 등록'
'투입'
)}
</Button>
</div>

View File

@@ -0,0 +1,313 @@
'use client';
/**
* 작업 아이템 카드 컴포넌트 (기획서 기반)
*
* 공통: 번호 + 품목코드(품목명) + 층/부호, 제작사이즈, 진척률바, pills, 자재투입목록(토글)
* 스크린: 절단정보 (폭 X 장)
* 슬랫: 길이 / 슬랫매수 / 조인트바
* 절곡: 도면(IMG) + 공통사항 + 세부부품
*/
import { useState, useCallback } from 'react';
import { ChevronDown, ChevronUp, Pencil, Trash2, ImageIcon } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { cn } from '@/lib/utils';
import type {
WorkItemData,
WorkStepData,
MaterialListItem,
} from './types';
interface WorkItemCardProps {
item: WorkItemData;
onStepClick: (itemId: string, step: WorkStepData) => void;
onEditMaterial: (itemId: string, material: MaterialListItem) => void;
onDeleteMaterial: (itemId: string, materialId: string) => void;
}
export function WorkItemCard({
item,
onStepClick,
onEditMaterial,
onDeleteMaterial,
}: WorkItemCardProps) {
const [isMaterialListOpen, setIsMaterialListOpen] = useState(false);
// 진척률 계산
const completedSteps = item.steps.filter((s) => s.isCompleted).length;
const totalSteps = item.steps.length;
const progressPercent = totalSteps > 0 ? (completedSteps / totalSteps) * 100 : 0;
const handleStepClick = useCallback(
(step: WorkStepData) => {
onStepClick(item.id, step);
},
[item.id, onStepClick]
);
return (
<Card className="bg-white shadow-sm border border-gray-200">
<CardContent className="p-4 space-y-3">
{/* 헤더: 번호 + 품목코드(품목명) + 층/부호 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex items-center justify-center w-7 h-7 rounded-full bg-gray-900 text-white text-sm font-bold">
{item.itemNo}
</span>
<span className="text-sm font-semibold text-gray-900">
{item.itemCode} ({item.itemName})
</span>
</div>
<span className="text-sm text-gray-500">
{item.floor} / {item.code}
</span>
</div>
{/* 제작 사이즈 */}
<div className="flex items-center gap-2 text-sm text-gray-700">
<span className="text-gray-500"> </span>
<span className="font-medium">
{item.width.toLocaleString()} X {item.height.toLocaleString()} mm
</span>
<span className="font-medium">{item.quantity}</span>
</div>
{/* 공정별 추가 정보 */}
{item.processType === 'screen' && item.cuttingInfo && (
<ScreenCuttingInfo
width={item.cuttingInfo.width}
sheets={item.cuttingInfo.sheets}
/>
)}
{item.processType === 'slat' && item.slatInfo && (
<SlatExtraInfo
length={item.slatInfo.length}
slatCount={item.slatInfo.slatCount}
jointBar={item.slatInfo.jointBar}
/>
)}
{item.processType === 'bending' && item.bendingInfo && (
<BendingExtraInfo info={item.bendingInfo} />
)}
{/* 진척률 프로그래스 바 */}
<div className="space-y-1">
<Progress value={progressPercent} className="h-2" />
<p className="text-xs text-gray-500 text-right">
{completedSteps}/{totalSteps}
</p>
</div>
{/* 공정 단계 pills */}
<div className="flex flex-wrap gap-2">
{item.steps.map((step) => (
<button
key={step.id}
onClick={() => handleStepClick(step)}
className={cn(
'px-3 py-1.5 rounded-md text-xs font-medium transition-colors',
'bg-gray-900 text-white hover:bg-gray-800'
)}
>
{step.name}
{step.isCompleted && (
<span className="ml-1 text-green-400"></span>
)}
</button>
))}
</div>
{/* 자재 투입 목록 (토글) */}
<div>
<button
onClick={() => setIsMaterialListOpen(!isMaterialListOpen)}
className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900"
>
{isMaterialListOpen ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{isMaterialListOpen && (
<div className="mt-2 border rounded-lg overflow-hidden">
{(!item.materialInputs || item.materialInputs.length === 0) ? (
<div className="py-6 text-center text-sm text-gray-500">
.
</div>
) : (
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="text-center text-xs"></TableHead>
<TableHead className="text-center text-xs"></TableHead>
<TableHead className="text-center text-xs"></TableHead>
<TableHead className="text-center text-xs"></TableHead>
<TableHead className="text-center text-xs"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{item.materialInputs.map((mat) => (
<TableRow key={mat.id}>
<TableCell className="text-center text-xs">{mat.lotNo}</TableCell>
<TableCell className="text-center text-xs">{mat.itemName}</TableCell>
<TableCell className="text-center text-xs">{mat.quantity.toLocaleString()}</TableCell>
<TableCell className="text-center text-xs">{mat.unit}</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => onEditMaterial(item.id, mat)}
>
<Pencil className="h-3.5 w-3.5 text-gray-500" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => onDeleteMaterial(item.id, mat.id)}
>
<Trash2 className="h-3.5 w-3.5 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)}
</div>
</CardContent>
</Card>
);
}
// ===== 스크린 전용: 절단정보 =====
function ScreenCuttingInfo({ width, sheets }: { width: number; sheets: number }) {
return (
<div className="p-3 bg-blue-50 rounded-lg border border-blue-100">
<p className="text-xs text-gray-500 mb-1"></p>
<p className="text-sm font-medium text-gray-900">
{width.toLocaleString()}mm X {sheets}
</p>
</div>
);
}
// ===== 슬랫 전용: 길이/매수/조인트바 =====
function SlatExtraInfo({
length,
slatCount,
jointBar,
}: {
length: number;
slatCount: number;
jointBar: number;
}) {
return (
<div className="flex gap-2">
<Badge variant="outline" className="text-xs px-2.5 py-1 border-gray-300">
{length.toLocaleString()}mm
</Badge>
<Badge variant="outline" className="text-xs px-2.5 py-1 border-gray-300">
{slatCount}
</Badge>
<Badge variant="outline" className="text-xs px-2.5 py-1 border-gray-300">
{jointBar}
</Badge>
</div>
);
}
// ===== 절곡 전용: 도면 + 공통사항 + 세부부품 =====
import type { BendingInfo } from './types';
function BendingExtraInfo({ info }: { info: BendingInfo }) {
return (
<div className="space-y-3">
{/* 도면 + 공통사항 (가로 배치) */}
<div className="flex gap-3">
{/* 도면 이미지 */}
<div className="flex-shrink-0 w-24 h-24 border rounded-lg bg-gray-50 flex items-center justify-center overflow-hidden">
{info.drawingUrl ? (
<img
src={info.drawingUrl}
alt="도면"
className="w-full h-full object-contain"
/>
) : (
<div className="flex flex-col items-center gap-1 text-gray-400">
<ImageIcon className="h-6 w-6" />
<span className="text-[10px]"></span>
</div>
)}
</div>
{/* 공통사항 */}
<div className="flex-1 space-y-1.5">
<p className="text-xs font-medium text-gray-500"></p>
<div className="space-y-1">
<div className="flex gap-2 text-xs">
<span className="text-gray-500 w-14"></span>
<span className="text-gray-900 font-medium">{info.common.kind}</span>
</div>
<div className="flex gap-2 text-xs">
<span className="text-gray-500 w-14"></span>
<span className="text-gray-900 font-medium">{info.common.type}</span>
</div>
{info.common.lengthQuantities.map((lq, i) => (
<div key={i} className="flex gap-2 text-xs">
<span className="text-gray-500 w-14">{i === 0 ? '길이별 수량' : ''}</span>
<span className="text-gray-900 font-medium">
{lq.length.toLocaleString()}mm X {lq.quantity}
</span>
</div>
))}
</div>
</div>
</div>
{/* 세부부품 */}
{info.detailParts.length > 0 && (
<div>
<p className="text-xs font-medium text-gray-500 mb-1.5">
({info.detailParts.length})
</p>
<div className="border rounded-lg divide-y divide-gray-100">
{info.detailParts.map((part, i) => (
<div key={i} className="p-2.5 space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-gray-900">{part.partName}</span>
<span className="text-gray-500">{part.material}</span>
</div>
<p className="text-xs text-gray-600">
{part.barcyInfo}
</p>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -4,31 +4,85 @@
* 작업일지 모달
*
* document-system 통합 버전 (2026-01-22)
* 공정별 작업일지 지원 (2026-01-29)
* - DocumentViewer 사용
* - WorkLogContent로 문서 본문
* - 공정 타입에 따라 스크린/슬랫/절곡 작업일지
* - processType 미지정 시 기존 WorkLogContent (범용) 사용
*/
import { useState, useEffect } from 'react';
import { Loader2 } from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { getWorkOrderById } from '../WorkOrders/actions';
import type { WorkOrder } from '../WorkOrders/types';
import type { WorkOrder, ProcessType } from '../WorkOrders/types';
import { WorkLogContent } from './WorkLogContent';
import {
ScreenWorkLogContent,
SlatWorkLogContent,
BendingWorkLogContent,
} from '../WorkOrders/documents';
interface WorkLogModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workOrderId: string | null;
processType?: ProcessType;
}
export function WorkLogModal({ open, onOpenChange, workOrderId }: WorkLogModalProps) {
export function WorkLogModal({ open, onOpenChange, workOrderId, processType }: WorkLogModalProps) {
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 || 'screen',
processType: pType || 'screen',
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',
});
// 모달 열릴 때 데이터 fetch
useEffect(() => {
if (open && workOrderId) {
// 목업 ID인 경우 API 호출 생략
if (workOrderId.startsWith('mock-')) {
setOrder(createMockOrder(workOrderId, processType));
setError(null);
return;
}
setIsLoading(true);
setError(null);
@@ -51,13 +105,32 @@ export function WorkLogModal({ open, onOpenChange, workOrderId }: WorkLogModalPr
setOrder(null);
setError(null);
}
}, [open, workOrderId]);
}, [open, workOrderId, processType]);
if (!workOrderId) return null;
// 로딩/에러 상태는 DocumentViewer 내부에서 처리
const subtitle = order ? `${order.processName} 생산부서` : undefined;
// 공정 타입에 따라 콘텐츠 분기
const renderContent = () => {
if (!order) return null;
// processType prop 또는 order의 processType 사용
const type = processType || order.processType;
switch (type) {
case 'screen':
return <ScreenWorkLogContent data={order} />;
case 'slat':
return <SlatWorkLogContent data={order} />;
case 'bending':
return <BendingWorkLogContent data={order} />;
default:
return <WorkLogContent data={order} />;
}
};
return (
<DocumentViewer
title="작업일지"
@@ -75,7 +148,7 @@ export function WorkLogModal({ open, onOpenChange, workOrderId }: WorkLogModalPr
<p className="text-muted-foreground">{error || '데이터를 불러올 수 없습니다.'}</p>
</div>
) : (
<WorkLogContent data={order} />
renderContent()
)}
</DocumentViewer>
);

View File

@@ -59,11 +59,20 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
? Math.ceil((today.getTime() - due.getTime()) / (1000 * 60 * 60 * 24))
: undefined;
// process_type → processCode/processName 매핑
const processTypeMap: Record<string, { code: string; name: string }> = {
screen: { code: 'screen', name: '스크린' },
slat: { code: 'slat', name: '슬랫' },
bending: { code: 'bending', name: '절곡' },
};
const processInfo = processTypeMap[api.process_type] || { code: api.process_type, name: api.process_type };
return {
id: String(api.id),
orderNo: api.work_order_no,
productName,
process: api.process_type,
processCode: processInfo.code,
processName: processInfo.name,
client: api.sales_order?.client?.name || '-',
projectName: api.project_name || '-',
assignees: api.assignee ? [api.assignee.name] : [],

View File

@@ -1,19 +1,24 @@
'use client';
/**
* 작업자 화면 메인 컴포넌트
* API 연동 완료 (2025-12-26)
* 작업자 화면 메인 컴포넌트 (기획서 기반 전면 개편)
*
* 기능:
* - 상단 통계 카드 4개 (할당/작업중/완료/긴급)
* - 내 작업 목록 카드 리스트
* - 각 작업 카드별 버튼 (전량완료/공정상세/자재투입/작업일지/이슈보고)
* 구조:
* - 상단: 페이지 제목
* - 탭: 스크린/슬랫/절곡 (디폴트: 스크린)
* - 상태 카드 4개 (할일/작업중/완료/긴급)
* - 수주 정보 섹션 (읽기 전용)
* - 작업 정보 섹션 (생산담당자 셀렉트 + 생산일자)
* - 작업 목록 (WorkItemCard 나열)
* - 하단 고정 버튼 (작업일지보기 / 중간검사하기)
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle } from 'lucide-react';
import { ContentSkeleton } from '@/components/ui/skeleton';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
@@ -21,24 +26,178 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { PageLayout } from '@/components/organisms/PageLayout';
import { toast } from 'sonner';
import { getMyWorkOrders, completeWorkOrder } from './actions';
import type { WorkOrder } from '../ProductionDashboard/types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type { WorkerStats, CompletionToastInfo, MaterialInput } from './types';
import { WorkCard } from './WorkCard';
import type {
WorkerStats,
CompletionToastInfo,
MaterialInput,
ProcessTab,
WorkItemData,
WorkStepData,
MaterialListItem,
} from './types';
import { PROCESS_TAB_LABELS } from './types';
import { WorkItemCard } from './WorkItemCard';
import { CompletionConfirmDialog } from './CompletionConfirmDialog';
import { CompletionToast } from './CompletionToast';
import { MaterialInputModal } from './MaterialInputModal';
import { WorkLogModal } from './WorkLogModal';
import { IssueReportModal } from './IssueReportModal';
import { WorkCompletionResultDialog } from './WorkCompletionResultDialog';
import { InspectionReportModal } from '../WorkOrders/documents';
// ===== 목업 데이터 =====
const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
screen: [
{
id: 'mock-s1', itemNo: 1, itemCode: 'KWWS03', itemName: '와이어', floor: '1층', code: 'FSS-01',
width: 8260, height: 8350, quantity: 2, processType: 'screen',
cuttingInfo: { width: 1210, sheets: 8 },
steps: [
{ id: 's1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 's1-2', name: '절단', isMaterialInput: false, isCompleted: true },
{ id: 's1-3', name: '미싱', isMaterialInput: false, isCompleted: false },
{ id: 's1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'm1', lotNo: 'LOT-2026-001', itemName: '스크린 원단 A', quantity: 500, unit: 'm' },
{ id: 'm2', lotNo: 'LOT-2026-002', itemName: '와이어 B', quantity: 120, unit: 'EA' },
],
},
{
id: 'mock-s2', itemNo: 2, itemCode: 'KWWS05', itemName: '메쉬', floor: '2층', code: 'FSS-03',
width: 6400, height: 5200, quantity: 4, processType: 'screen',
cuttingInfo: { width: 1600, sheets: 4 },
steps: [
{ id: 's2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 's2-2', name: '절단', isMaterialInput: false, isCompleted: false },
{ id: 's2-3', name: '미싱', isMaterialInput: false, isCompleted: false },
{ id: 's2-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
{
id: 'mock-s3', itemNo: 3, itemCode: 'KWWS08', itemName: '와이어(광폭)', floor: '3층', code: 'FSS-05',
width: 12000, height: 4500, quantity: 1, processType: 'screen',
cuttingInfo: { width: 2400, sheets: 5 },
steps: [
{ id: 's3-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 's3-2', name: '절단', isMaterialInput: false, isCompleted: true },
{ id: 's3-3', name: '미싱', isMaterialInput: false, isCompleted: true },
{ id: 's3-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'm3', lotNo: 'LOT-2026-005', itemName: '광폭 원단', quantity: 300, unit: 'm' },
],
},
],
slat: [
{
id: 'mock-l1', itemNo: 1, itemCode: 'KQTS01', itemName: '슬랫코일', floor: '1층', code: 'FSS-01',
width: 8260, height: 8350, quantity: 2, processType: 'slat',
slatInfo: { length: 3910, slatCount: 40, jointBar: 4 },
steps: [
{ id: 'l1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 'l1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'l1-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'm4', lotNo: 'LOT-2026-010', itemName: '슬랫 코일 A', quantity: 200, unit: 'kg' },
],
},
{
id: 'mock-l2', itemNo: 2, itemCode: 'KQTS03', itemName: '슬랫코일(광폭)', floor: '2층', code: 'FSS-02',
width: 10500, height: 6200, quantity: 3, processType: 'slat',
slatInfo: { length: 5200, slatCount: 55, jointBar: 6 },
steps: [
{ id: 'l2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'l2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'l2-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
],
bending: [
{
id: 'mock-b1', itemNo: 1, itemCode: 'KWWS03', itemName: '가이드레일', floor: '1층', code: 'FSS-01',
width: 0, height: 0, quantity: 6, processType: 'bending',
bendingInfo: {
common: {
kind: '벽면형 120X70', type: '벽면형',
lengthQuantities: [{ length: 4000, quantity: 6 }, { length: 3000, quantity: 6 }],
},
detailParts: [
{ partName: '엘바', material: 'EGI 1.6T', barcyInfo: '16 I 75' },
{ partName: '하장바', material: 'EGI 1.6T', barcyInfo: '16|75|16|75|16(A각)' },
],
},
steps: [
{ id: 'b1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 'b1-2', name: '절단', isMaterialInput: false, isCompleted: true },
{ id: 'b1-3', name: '절곡', isMaterialInput: false, isCompleted: false },
{ id: 'b1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
{
id: 'mock-b2', itemNo: 2, itemCode: 'KWWS10', itemName: '천정레일', floor: '2층', code: 'FSS-04',
width: 0, height: 0, quantity: 4, processType: 'bending',
bendingInfo: {
drawingUrl: '',
common: {
kind: '천정형 80X60', type: '천정형',
lengthQuantities: [{ length: 3500, quantity: 4 }],
},
detailParts: [
{ partName: '상장바', material: 'STS 1.2T', barcyInfo: '12 I 60' },
],
},
steps: [
{ id: 'b2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'b2-2', name: '절단', isMaterialInput: false, isCompleted: false },
{ id: 'b2-3', name: '절곡', isMaterialInput: false, isCompleted: false },
{ id: 'b2-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
],
};
// 하드코딩된 공정별 단계 폴백
const PROCESS_STEPS: Record<ProcessTab, { name: string; isMaterialInput: boolean }[]> = {
screen: [
{ name: '자재투입', isMaterialInput: true },
{ name: '절단', isMaterialInput: false },
{ name: '미싱', isMaterialInput: false },
{ name: '포장완료', isMaterialInput: false },
],
slat: [
{ name: '자재투입', isMaterialInput: true },
{ name: '포밍/절단', isMaterialInput: false },
{ name: '포장완료', isMaterialInput: false },
],
bending: [
{ name: '자재투입', isMaterialInput: true },
{ name: '절단', isMaterialInput: false },
{ name: '절곡', isMaterialInput: false },
{ name: '포장완료', isMaterialInput: false },
],
};
export default function WorkerScreen() {
// ===== 상태 관리 =====
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<ProcessTab>('screen');
// 작업 정보
const [productionManagerId, setProductionManagerId] = useState('');
const [productionDate, setProductionDate] = useState('');
// 공정별 step 완료 상태: { [itemId-stepName]: boolean }
const [stepCompletionMap, setStepCompletionMap] = useState<Record<string, boolean>>({});
// 데이터 로드
const loadData = useCallback(async () => {
@@ -68,6 +227,7 @@ export default function WorkerScreen() {
const [isCompletionDialogOpen, setIsCompletionDialogOpen] = useState(false);
const [isMaterialModalOpen, setIsMaterialModalOpen] = useState(false);
const [isWorkLogModalOpen, setIsWorkLogModalOpen] = useState(false);
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
const [isIssueReportModalOpen, setIsIssueReportModalOpen] = useState(false);
// 전량완료 흐름 상태
@@ -83,72 +243,154 @@ export default function WorkerScreen() {
// 완료 토스트 상태
const [toastInfo, setToastInfo] = useState<CompletionToastInfo | null>(null);
// 정렬 상태
const [sortBy, setSortBy] = useState<'dueDate' | 'latest'>('dueDate');
// ===== 통계 계산 =====
const stats: WorkerStats = useMemo(() => {
return {
assigned: workOrders.length,
inProgress: workOrders.filter((o) => o.status === 'inProgress').length,
completed: 0, // 완료된 것은 목록에서 제외되므로 0
urgent: workOrders.filter((o) => o.isUrgent).length,
};
}, [workOrders]);
// ===== 정렬된 작업 목록 =====
const sortedWorkOrders = useMemo(() => {
return [...workOrders].sort((a, b) => {
if (sortBy === 'dueDate') {
// 납기일순 (가까운 날짜 먼저)
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
} else {
// 최신등록순 (최근 ID가 더 큼 = 최근 등록)
return b.id.localeCompare(a.id);
// ===== 탭별 필터링된 작업 =====
const filteredWorkOrders = useMemo(() => {
// process_type 기반 필터링
return workOrders.filter((order) => {
// WorkOrder의 processCode/processName으로 매칭
const processName = (order.processName || '').toLowerCase();
switch (activeTab) {
case 'screen':
return processName.includes('스크린') || processName === 'screen';
case 'slat':
return processName.includes('슬랫') || processName === 'slat';
case 'bending':
return processName.includes('절곡') || processName === 'bending';
default:
return true;
}
});
}, [workOrders, sortBy]);
}, [workOrders, activeTab]);
// ===== 통계 계산 (탭별) =====
const stats: WorkerStats = useMemo(() => {
return {
assigned: filteredWorkOrders.length,
inProgress: filteredWorkOrders.filter((o) => o.status === 'inProgress').length,
completed: 0,
urgent: filteredWorkOrders.filter((o) => o.isUrgent).length,
};
}, [filteredWorkOrders]);
// ===== WorkOrder → WorkItemData 변환 + 목업 =====
const workItems: WorkItemData[] = useMemo(() => {
const apiItems: WorkItemData[] = filteredWorkOrders.map((order, index) => {
const stepsTemplate = PROCESS_STEPS[activeTab];
const steps: WorkStepData[] = stepsTemplate.map((st, si) => {
const stepKey = `${order.id}-${st.name}`;
return {
id: `${order.id}-step-${si}`,
name: st.name,
isMaterialInput: st.isMaterialInput,
isCompleted: stepCompletionMap[stepKey] || false,
};
});
return {
id: order.id,
itemNo: index + 1,
itemCode: order.orderNo || '-',
itemName: order.productName || '-',
floor: '-',
code: '-',
width: 0,
height: 0,
quantity: order.quantity || 0,
processType: activeTab,
steps,
materialInputs: [],
};
});
// 목업 데이터 합치기 (API 데이터 뒤에 번호 이어서)
const mockItems = MOCK_ITEMS[activeTab].map((item, i) => ({
...item,
itemNo: apiItems.length + i + 1,
}));
return [...apiItems, ...mockItems];
}, [filteredWorkOrders, activeTab, stepCompletionMap]);
// ===== 수주 정보 (첫 번째 작업 기반 표시) =====
const orderInfo = useMemo(() => {
const first = filteredWorkOrders[0];
if (!first) return null;
return {
orderDate: first.createdAt ? new Date(first.createdAt).toLocaleDateString('ko-KR') : '-',
lotNo: '-',
siteName: first.projectName || '-',
client: first.client || '-',
salesManager: first.assignees?.[0] || '-',
managerPhone: '-',
shippingDate: first.dueDate ? new Date(first.dueDate).toLocaleDateString('ko-KR') : '-',
};
}, [filteredWorkOrders]);
// ===== 핸들러 =====
// 전량완료 버튼 클릭
const handleComplete = useCallback(
(order: WorkOrder) => {
setSelectedOrder(order);
// 이미 투입된 자재가 있으면 바로 완료 결과 팝업
const savedMaterials = inputMaterialsMap.get(order.id);
if (savedMaterials && savedMaterials.length > 0) {
// LOT 번호 생성
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
setCompletionLotNo(lotNo);
setIsCompletionResultOpen(true);
// pill 클릭 핸들러
const handleStepClick = useCallback(
(itemId: string, step: WorkStepData) => {
if (step.isMaterialInput) {
// 자재투입 → 자재 투입 모달 열기
const order = workOrders.find((o) => o.id === itemId);
if (order) {
setSelectedOrder(order);
setIsMaterialModalOpen(true);
} else {
// 목업 아이템인 경우 합성 WorkOrder 생성
const mockItem = workItems.find((item) => item.id === itemId);
if (mockItem) {
const syntheticOrder: WorkOrder = {
id: mockItem.id,
orderNo: mockItem.itemCode,
productName: mockItem.itemName,
processCode: mockItem.processType,
processName: PROCESS_TAB_LABELS[mockItem.processType],
client: '-',
projectName: '-',
assignees: [],
quantity: mockItem.quantity,
dueDate: '',
priority: 5,
status: 'waiting',
isUrgent: false,
isDelayed: false,
createdAt: '',
};
setSelectedOrder(syntheticOrder);
setIsMaterialModalOpen(true);
}
}
} else {
// 자재 투입이 필요합니다 팝업
setIsCompletionDialogOpen(true);
// 기타 → 완료/미완료 토글
const stepKey = `${itemId}-${step.name}`;
setStepCompletionMap((prev) => ({
...prev,
[stepKey]: !prev[stepKey],
}));
}
},
[inputMaterialsMap]
[workOrders, workItems]
);
// "자재 투입이 필요합니다" 팝업에서 확인 클릭 → MaterialInputModal 열기
const handleCompletionConfirm = useCallback(() => {
setIsCompletionFlow(true);
setIsMaterialModalOpen(true);
}, []);
// 자재 수정 핸들러
const handleEditMaterial = useCallback(
(itemId: string, material: MaterialListItem) => {
console.log('[WorkerScreen] editMaterial:', itemId, material);
// 추후 구현
},
[]
);
// MaterialInputModal에서 투입 등록/건너뛰기 후 → 작업 완료 결과 팝업 표시
const handleWorkCompletion = useCallback(() => {
if (!selectedOrder) return;
// LOT 번호 생성
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
setCompletionLotNo(lotNo);
// 완료 결과 팝업 표시
setIsCompletionResultOpen(true);
setIsCompletionFlow(false);
}, [selectedOrder]);
// 자재 삭제 핸들러
const handleDeleteMaterial = useCallback(
(itemId: string, materialId: string) => {
console.log('[WorkerScreen] deleteMaterial:', itemId, materialId);
// 추후 구현
},
[]
);
// 자재 저장 핸들러
const handleSaveMaterials = useCallback((orderId: string, materials: MaterialInput[]) => {
@@ -157,14 +399,35 @@ export default function WorkerScreen() {
next.set(orderId, materials);
return next;
});
// 자재투입 step 완료로 마킹
const stepKey = `${orderId}-자재투입`;
setStepCompletionMap((prev) => ({
...prev,
[stepKey]: true,
}));
}, []);
// 완료 결과 팝업에서 확인 → API 완료 처리 후 목록에서 제거
// 완료 확인 → MaterialInputModal 열기
const handleCompletionConfirm = useCallback(() => {
setIsCompletionFlow(true);
setIsMaterialModalOpen(true);
}, []);
// MaterialInputModal 완료 후 → 작업 완료 결과 팝업
const handleWorkCompletion = useCallback(() => {
if (!selectedOrder) return;
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
setCompletionLotNo(lotNo);
setIsCompletionResultOpen(true);
setIsCompletionFlow(false);
}, [selectedOrder]);
// 완료 결과 팝업 확인 → API 완료 처리
const handleCompletionResultConfirm = useCallback(async () => {
if (!selectedOrder) return;
try {
// API로 완료 처리
const materials = inputMaterialsMap.get(selectedOrder.id);
const result = await completeWorkOrder(
selectedOrder.id,
@@ -177,15 +440,11 @@ export default function WorkerScreen() {
if (result.success) {
toast.success('작업이 완료되었습니다.');
// 투입된 자재 맵에서도 제거
setInputMaterialsMap((prev) => {
const next = new Map(prev);
next.delete(selectedOrder.id);
return next;
});
// 목록에서 제거
setWorkOrders((prev) => prev.filter((o) => o.id !== selectedOrder.id));
} else {
toast.error(result.error || '작업 완료 처리에 실패했습니다.');
@@ -200,26 +459,53 @@ export default function WorkerScreen() {
}
}, [selectedOrder, inputMaterialsMap]);
const handleProcessDetail = useCallback((order: WorkOrder) => {
setSelectedOrder(order);
// 공정상세는 카드 내 토글로 처리 (Phase 4에서 구현)
console.log('[공정상세] 토글:', order.orderNo);
}, []);
// 하단 버튼용 합성 WorkOrder (API 데이터 없을 때 목업 폴백)
const getTargetOrder = useCallback((): WorkOrder | null => {
const apiTarget = filteredWorkOrders[0];
if (apiTarget) return apiTarget;
const handleMaterialInput = useCallback((order: WorkOrder) => {
setSelectedOrder(order);
setIsMaterialModalOpen(true);
}, []);
// 목업 아이템으로 폴백
const mockItem = workItems[0];
if (!mockItem) return null;
return {
id: mockItem.id,
orderNo: mockItem.itemCode,
productName: mockItem.itemName,
processCode: mockItem.processType,
processName: PROCESS_TAB_LABELS[mockItem.processType],
client: '-',
projectName: '-',
assignees: [],
quantity: mockItem.quantity,
dueDate: '',
priority: 5,
status: 'waiting',
isUrgent: false,
isDelayed: false,
createdAt: '',
};
}, [filteredWorkOrders, workItems]);
const handleWorkLog = useCallback((order: WorkOrder) => {
setSelectedOrder(order);
setIsWorkLogModalOpen(true);
}, []);
// 하단 버튼 핸들러
const handleWorkLog = useCallback(() => {
const target = getTargetOrder();
if (target) {
setSelectedOrder(target);
setIsWorkLogModalOpen(true);
} else {
toast.error('표시할 작업이 없습니다.');
}
}, [getTargetOrder]);
const handleIssueReport = useCallback((order: WorkOrder) => {
setSelectedOrder(order);
setIsIssueReportModalOpen(true);
}, []);
const handleInspection = useCallback(() => {
const target = getTargetOrder();
if (target) {
setSelectedOrder(target);
setIsInspectionModalOpen(true);
} else {
toast.error('표시할 작업이 없습니다.');
}
}, [getTargetOrder]);
return (
<PageLayout>
@@ -234,75 +520,158 @@ export default function WorkerScreen() {
</div>
<div>
<h1 className="text-2xl font-bold"> </h1>
<p className="text-sm text-muted-foreground"> .</p>
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title="할일"
value={stats.assigned}
icon={<ClipboardList className="h-4 w-4" />}
variant="default"
/>
<StatCard
title="작업중"
value={stats.inProgress}
icon={<PlayCircle className="h-4 w-4" />}
variant="blue"
/>
<StatCard
title="완료"
value={stats.completed}
icon={<CheckCircle2 className="h-4 w-4" />}
variant="green"
/>
<StatCard
title="긴급"
value={stats.urgent}
icon={<AlertTriangle className="h-4 w-4" />}
variant="red"
/>
</div>
{/* 공정별 탭 */}
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as ProcessTab)}
>
<TabsList className="w-full">
{(['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => (
<TabsTrigger key={tab} value={tab} className="flex-1">
{PROCESS_TAB_LABELS[tab]}
</TabsTrigger>
))}
</TabsList>
{/* 작업 목록 */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold"> </h2>
<Select value={sortBy} onValueChange={(value: 'dueDate' | 'latest') => setSortBy(value)}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dueDate"></SelectItem>
<SelectItem value="latest"></SelectItem>
</SelectContent>
</Select>
</div>
{isLoading ? (
<ContentSkeleton type="cards" rows={4} />
) : sortedWorkOrders.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
.
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{sortedWorkOrders.map((order) => (
<WorkCard
key={order.id}
order={order}
onComplete={handleComplete}
onProcessDetail={handleProcessDetail}
onMaterialInput={handleMaterialInput}
onWorkLog={handleWorkLog}
onIssueReport={handleIssueReport}
/>
))}
</div>
)}
{/* 탭 내용은 공통 (탭별 필터링만 다름) */}
{(['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => (
<TabsContent key={tab} value={tab}>
<div className="space-y-6 mt-4">
{/* 상태 카드 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title="할일"
value={stats.assigned}
icon={<ClipboardList className="h-4 w-4" />}
variant="default"
/>
<StatCard
title="작업중"
value={stats.inProgress}
icon={<PlayCircle className="h-4 w-4" />}
variant="blue"
/>
<StatCard
title="완료"
value={stats.completed}
icon={<CheckCircle2 className="h-4 w-4" />}
variant="green"
/>
<StatCard
title="긴급"
value={stats.urgent}
icon={<AlertTriangle className="h-4 w-4" />}
variant="red"
/>
</div>
{/* 수주 정보 섹션 */}
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3"> </h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3">
<InfoField label="수주일" value={orderInfo?.orderDate} />
<InfoField label="로트번호" value={orderInfo?.lotNo} />
<InfoField label="현장명" value={orderInfo?.siteName} />
<InfoField label="수주처" value={orderInfo?.client} />
<InfoField label="수주 담당자" value={orderInfo?.salesManager} />
<InfoField label="담당자 연락처" value={orderInfo?.managerPhone} />
<InfoField label="출고예정일" value={orderInfo?.shippingDate} />
</div>
</CardContent>
</Card>
{/* 작업 정보 섹션 */}
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3"> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-sm text-gray-600"> </Label>
<Select
value={productionManagerId}
onValueChange={setProductionManagerId}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{/* 담당자 목록 - 현재 작업 담당자들 */}
{Array.from(
new Set(
filteredWorkOrders.flatMap((o) => o.assignees || []).filter(Boolean)
)
).map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm text-gray-600"></Label>
<Input
type="date"
value={productionDate}
onChange={(e) => setProductionDate(e.target.value)}
/>
</div>
</div>
</CardContent>
</Card>
{/* 작업 목록 */}
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3"> </h3>
{isLoading ? (
<ContentSkeleton type="cards" rows={4} />
) : workItems.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
.
</CardContent>
</Card>
) : (
<div className="space-y-4">
{workItems.map((item) => (
<WorkItemCard
key={item.id}
item={item}
onStepClick={handleStepClick}
onEditMaterial={handleEditMaterial}
onDeleteMaterial={handleDeleteMaterial}
/>
))}
</div>
)}
</div>
</div>
</TabsContent>
))}
</Tabs>
</div>
{/* 하단 고정 버튼 */}
<div className="sticky bottom-0 border-t border-gray-200 pt-4 pb-2 z-10">
<div className="flex gap-3">
<Button
variant="outline"
onClick={handleWorkLog}
className="flex-1 py-6 text-base font-medium"
>
</Button>
<Button
onClick={handleInspection}
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
>
</Button>
</div>
</div>
@@ -328,6 +697,14 @@ export default function WorkerScreen() {
open={isWorkLogModalOpen}
onOpenChange={setIsWorkLogModalOpen}
workOrderId={selectedOrder?.id || null}
processType={activeTab}
/>
<InspectionReportModal
open={isInspectionModalOpen}
onOpenChange={setIsInspectionModalOpen}
workOrderId={selectedOrder?.id || null}
processType={activeTab}
/>
<IssueReportModal
@@ -375,3 +752,17 @@ function StatCard({ title, value, icon, variant }: StatCardProps) {
</Card>
);
}
interface InfoFieldProps {
label: string;
value?: string;
}
function InfoField({ label, value }: InfoFieldProps) {
return (
<div>
<p className="text-xs text-gray-500">{label}</p>
<p className="text-sm font-medium text-gray-900 mt-0.5">{value || '-'}</p>
</div>
);
}

View File

@@ -1,8 +1,116 @@
// 작업자 화면 타입 정의
import type { WorkOrder, ProcessType } from '../ProductionDashboard/types';
import type { WorkOrder } from '../ProductionDashboard/types';
// 작업자 작업 아이템 (WorkOrder 확장)
// ===== 공정 탭 =====
export type ProcessTab = 'screen' | 'slat' | 'bending';
export const PROCESS_TAB_LABELS: Record<ProcessTab, string> = {
screen: '스크린 공정',
slat: '슬랫 공정',
bending: '절곡 공정',
};
// ===== 수주 정보 =====
export interface OrderInfo {
orderDate: string; // 수주일
lotNo: string; // 로트번호
siteName: string; // 현장명
client: string; // 수주처
salesManager: string; // 수주담당자
managerPhone: string; // 담당자연락처
shippingDate: string; // 출고예정일
}
// ===== 작업 정보 =====
export interface WorkInfo {
productionManagerId: string; // 생산 담당자 ID
productionDate: string; // 생산일자
}
// ===== 작업 아이템 (카드 1개 단위) =====
export interface WorkItemData {
id: string;
itemNo: number; // 번호 (1, 2, 3...)
itemCode: string; // 품목코드 (KWWS03)
itemName: string; // 품목명 (와이어)
floor: string; // 층 (1층)
code: string; // 부호 (FSS-01)
width: number; // 폭 (mm)
height: number; // 높이 (mm)
quantity: number; // 수량
processType: ProcessTab; // 공정 타입
steps: WorkStepData[]; // 공정 단계들
// 스크린 전용
cuttingInfo?: CuttingInfo;
// 슬랫 전용
slatInfo?: SlatInfo;
// 절곡 전용
bendingInfo?: BendingInfo;
// 자재 투입 목록
materialInputs?: MaterialListItem[];
}
// ===== 절단 정보 (스크린 전용) =====
export interface CuttingInfo {
width: number; // 절단 폭 (mm)
sheets: number; // 장 수
}
// ===== 슬랫 전용 정보 =====
export interface SlatInfo {
length: number; // 길이 (mm)
slatCount: number; // 슬랫 매수
jointBar: number; // 조인트바 개수
}
// ===== 절곡 전용 정보 =====
export interface BendingInfo {
drawingUrl?: string; // 도면 이미지 URL
common: BendingCommonInfo; // 공통사항
detailParts: BendingDetailPart[]; // 세부부품
}
export interface BendingCommonInfo {
kind: string; // 종류 (벽면형 120X70)
type: string; // 유형 (벽면형)
lengthQuantities: { length: number; quantity: number }[]; // 길이별 수량
}
export interface BendingDetailPart {
partName: string; // 부품명 (엘바, 하장바)
material: string; // 재질 (EGI 1.6T)
barcyInfo: string; // 바아시 정보
}
// ===== 공정 단계 (pill) =====
export interface WorkStepData {
id: string;
name: string; // 단계명 (자재투입, 절단, 미싱, 포장완료)
isMaterialInput: boolean; // 자재투입 단계 여부
isCompleted: boolean; // 완료 여부
}
// ===== 자재 투입 목록 항목 =====
export interface MaterialListItem {
id: string;
lotNo: string; // 로트번호
itemName: string; // 품목명
quantity: number; // 수량
unit: string; // 단위
}
// ===== 자재 투입 모달 항목 =====
export interface MaterialInput {
id: string;
lotNo: string; // 로트번호
materialName: string; // 품목명
quantity: number; // 수량
unit: string; // 단위
inputQuantity: number; // 투입 수량 (사용자 입력)
}
// ===== 작업자 작업 아이템 (WorkOrder 확장) =====
export interface WorkerWorkItem extends WorkOrder {
processDetail?: ProcessDetail;
}
@@ -29,22 +137,12 @@ export interface ProcessStep {
// 공정 단계 상세 항목
export interface ProcessStepItem {
id: string;
itemNo: string; // #1, #2
location: string; // 1층 1호-A
isPriority: boolean; // 선행 생산
spec: string; // W2500 × H3000
material: string; // 자재: 절곡판
lot: string; // LOT-절곡-2025-001
}
// 자재 투입 정보
export interface MaterialInput {
id: string;
materialCode: string;
materialName: string;
unit: string;
currentStock: number;
fifoRank: number; // FIFO 순위 (1: 최우선, 2: 차선, 3+: 대기)
itemNo: string;
location: string;
isPriority: boolean;
spec: string;
material: string;
lot: string;
}
// 이슈 유형
@@ -71,4 +169,4 @@ export interface CompletionToastInfo {
orderNo: string;
quantity: number;
lotNo: string;
}
}