feat(MES): 작업일지 모달 API 연동 및 버그 수정

- WorkLogModal: API 연동으로 실제 작업지시 데이터 표시
  - getWorkOrderById action 사용
  - 로딩/에러 상태 처리
  - workStats 자동 계산 (완료/진행중/대기)
- types.ts: item.status 하드코딩 버그 수정
  - 실제 API 응답값 사용하도록 변경
- WorkOrderDetail: 작업지시 취소 버튼 및 모달 prop 정리
- WorkerScreen: WorkLogModal prop 변경 (order → workOrderId)
This commit is contained in:
2026-01-14 15:39:07 +09:00
parent 87b7aa9d67
commit ebc7320eeb
4 changed files with 219 additions and 123 deletions

View File

@@ -7,7 +7,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, List, AlertTriangle, Play, CheckCircle2, Loader2 } from 'lucide-react';
import { FileText, List, AlertTriangle, Play, CheckCircle2, Loader2, Undo2 } from 'lucide-react';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -323,25 +323,6 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
);
}
// 작업일지용 WorkOrder 변환 (기존 WorkLogModal 타입에 맞춤)
const workLogOrder = {
id: order.id,
orderNo: order.workOrderNo,
productName: order.items[0]?.productName || '-',
client: order.client,
projectName: order.projectName,
dueDate: order.dueDate,
quantity: order.items.reduce((sum, item) => sum + item.quantity, 0),
progress: order.currentStep * 20, // 대략적인 진행률
process: order.processType as 'screen' | 'slat' | 'bending',
assignees: order.assignees && order.assignees.length > 0
? order.assignees.map(a => a.name)
: [order.assignee],
instruction: order.note || '',
status: 'in_progress' as const,
priority: order.priority <= 3 ? 'high' : order.priority <= 6 ? 'medium' : 'low',
};
return (
<PageLayout>
{/* 헤더 */}
@@ -469,36 +450,70 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
<TableCell>{item.specification}</TableCell>
<TableCell className="text-right">{item.quantity}</TableCell>
<TableCell>
{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>
)}
<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>
</TableRow>
))}
@@ -522,7 +537,7 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
<WorkLogModal
open={isWorkLogOpen}
onOpenChange={setIsWorkLogOpen}
order={workLogOrder}
workOrderId={order.id}
/>
</PageLayout>
);

View File

@@ -238,6 +238,7 @@ export interface WorkOrderItemApi {
quantity: number;
unit: string | null;
sort_order: number;
status: 'waiting' | 'in_progress' | 'completed';
created_at: string;
updated_at: string;
}
@@ -401,7 +402,7 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
items: (api.items || []).map((item, idx) => ({
id: String(item.id),
no: idx + 1,
status: 'waiting' as ItemStatus,
status: (item.status as ItemStatus) || 'waiting',
productName: item.item_name,
floorCode: '-',
specification: item.specification || '-',

View File

@@ -5,9 +5,11 @@
*
* - 헤더: sam-design 작업일지 스타일
* - 내부 문서: 스크린샷 기준 작업일지 양식
* - API 연동 완료 (2025-01-14)
*/
import { Printer, X } from 'lucide-react';
import { useState, useEffect } from 'react';
import { Printer, X, Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
@@ -16,21 +18,114 @@ import {
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { Button } from '@/components/ui/button';
import { printArea } from '@/lib/print-utils';
import type { WorkOrder } from '../ProductionDashboard/types';
import { PROCESS_LABELS } from '../ProductionDashboard/types';
import { getWorkOrderById } from '../WorkOrders/actions';
import type { WorkOrder, WorkOrderItem } from '../WorkOrders/types';
import { ITEM_STATUS_LABELS } from '../WorkOrders/types';
interface WorkLogModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
order: WorkOrder | null;
workOrderId: string | null;
}
export function WorkLogModal({ open, onOpenChange, order }: WorkLogModalProps) {
// 작업 통계 타입
interface WorkStats {
orderQty: number;
completedQty: number;
inProgressQty: number;
waitingQty: number;
progress: number;
}
// 품목 데이터에서 작업 통계 계산
function calculateWorkStats(items: WorkOrderItem[]): WorkStats {
const orderQty = items.length;
const completedQty = items.filter(i => i.status === 'completed').length;
const inProgressQty = items.filter(i => i.status === 'in_progress').length;
const waitingQty = items.filter(i => i.status === 'waiting').length;
const progress = orderQty > 0 ? Math.round((completedQty / orderQty) * 100) : 0;
return {
orderQty,
completedQty,
inProgressQty,
waitingQty,
progress,
};
}
export function WorkLogModal({ open, onOpenChange, workOrderId }: WorkLogModalProps) {
const [order, setOrder] = useState<WorkOrder | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 모달 열릴 때 데이터 fetch
useEffect(() => {
if (open && workOrderId) {
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]);
const handlePrint = () => {
printArea({ title: '작업일지 인쇄' });
};
if (!order) return null;
if (!workOrderId) return null;
// 로딩 상태
if (isLoading) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
<VisuallyHidden>
<DialogTitle> </DialogTitle>
</VisuallyHidden>
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
</DialogContent>
</Dialog>
);
}
// 에러 상태
if (error || !order) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
<VisuallyHidden>
<DialogTitle> </DialogTitle>
</VisuallyHidden>
<div className="flex flex-col items-center justify-center h-64 gap-4">
<p className="text-muted-foreground">{error || '데이터를 불러올 수 없습니다.'}</p>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
</div>
</DialogContent>
</Dialog>
);
}
const today = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
@@ -38,42 +133,30 @@ export function WorkLogModal({ open, onOpenChange, order }: WorkLogModalProps) {
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
const documentNo = `WL-${order.process.toUpperCase().slice(0, 3)}`;
const lotNo = `KD-TS-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
const documentNo = `WL-${order.processCode.toUpperCase().slice(0, 3)}`;
// 샘플 품목 데이터 (스크린샷 기준)
const items = [
{ no: 1, name: '스크린 사타 (표준형)', location: '1층/A-01', spec: '3000×2500', qty: 1, status: '대기' },
{ no: 2, name: '스크린 사타 (표준형)', location: '2층/A-02', spec: '3000×2500', qty: 1, status: '대기' },
{ no: 3, name: '스크린 사타 (표준형)', location: '3층/A-03', spec: '-', qty: '-', status: '대기' },
];
// 품목 데이터
const items = order.items || [];
// 작업내역 데이터 (스크린샷 기준)
const workStats = {
workType: '필름 스크린',
workWidth: '1016mm',
general: 3,
ironing: 3,
sandblast: 3,
packing: 1,
orderQty: 3,
completedQty: 1,
progress: 33,
};
// 작업 통계 계산
const workStats = calculateWorkStats(items);
// 주 담당자
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
{/* 접근성을 위한 숨겨진 타이틀 */}
<VisuallyHidden>
<DialogTitle> - {order.orderNo}</DialogTitle>
<DialogTitle> - {order.workOrderNo}</DialogTitle>
</VisuallyHidden>
{/* 모달 헤더 - sam-design 스타일 (인쇄 시 숨김) */}
<div className="print-hidden flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
<div className="flex items-center gap-3">
<span className="font-semibold text-lg"></span>
<span className="text-sm text-muted-foreground">
{PROCESS_LABELS[order.process]}
{order.processName}
</span>
<span className="text-sm text-muted-foreground">
({documentNo})
@@ -109,7 +192,7 @@ export function WorkLogModal({ open, onOpenChange, order }: WorkLogModalProps) {
<div className="flex-1 flex flex-col items-center justify-center p-3 border-r border-gray-300">
<h1 className="text-xl font-bold tracking-widest mb-1"> </h1>
<p className="text-xs text-gray-500">{documentNo}</p>
<p className="text-sm font-medium mt-1">{PROCESS_LABELS[order.process]} </p>
<p className="text-sm font-medium mt-1">{order.processName} </p>
</div>
{/* 우측: 결재라인 */}
@@ -131,7 +214,7 @@ export function WorkLogModal({ open, onOpenChange, order }: WorkLogModalProps) {
{/* 두 번째 행: 이름 + 날짜 */}
<tr>
<td className="w-16 p-2 text-center border-r border-b border-gray-300">
<div>{order.assignees[0] || '-'}</div>
<div>{primaryAssignee}</div>
<div className="text-[10px] text-gray-500">
{new Date().toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }).replace('. ', '/').replace('.', '')}
</div>
@@ -180,7 +263,7 @@ export function WorkLogModal({ open, onOpenChange, order }: WorkLogModalProps) {
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
LOT NO.
</div>
<div className="flex-1 p-3 text-sm flex items-center">{lotNo}</div>
<div className="flex-1 p-3 text-sm flex items-center">{order.lotNo}</div>
</div>
</div>
@@ -191,18 +274,18 @@ export function WorkLogModal({ open, onOpenChange, order }: WorkLogModalProps) {
</div>
<div className="flex-1 p-3 text-sm flex items-center">
{new Date(order.dueDate).toLocaleDateString('ko-KR', {
{order.dueDate !== '-' ? new Date(order.dueDate).toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '')}
}).replace(/\. /g, '-').replace('.', '') : '-'}
</div>
</div>
<div className="flex">
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
</div>
<div className="flex-1 p-3 text-sm flex items-center">W- x H-</div>
<div className="flex-1 p-3 text-sm flex items-center">{order.workOrderNo}</div>
</div>
</div>
</div>
@@ -213,56 +296,43 @@ export function WorkLogModal({ open, onOpenChange, order }: WorkLogModalProps) {
<div className="grid grid-cols-12 border-b border-gray-300 bg-gray-100">
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300">No</div>
<div className="col-span-4 p-2 text-sm font-medium text-center border-r border-gray-300"></div>
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300">/</div>
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300">/</div>
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300"></div>
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300"></div>
<div className="col-span-2 p-2 text-sm font-medium text-center"></div>
</div>
{/* 테이블 데이터 */}
{items.map((item, index) => (
<div
key={item.no}
className={`grid grid-cols-12 ${index < items.length - 1 ? 'border-b border-gray-300' : ''}`}
>
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.no}</div>
<div className="col-span-4 p-2 text-sm border-r border-gray-300">{item.name}</div>
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.location}</div>
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.spec}</div>
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.qty}</div>
<div className="col-span-2 p-2 text-sm text-center">{item.status}</div>
{items.length > 0 ? (
items.map((item, index) => (
<div
key={item.id}
className={`grid grid-cols-12 ${index < items.length - 1 ? 'border-b border-gray-300' : ''}`}
>
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.no}</div>
<div className="col-span-4 p-2 text-sm border-r border-gray-300">{item.productName}</div>
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.floorCode}</div>
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.specification}</div>
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.quantity}</div>
<div className="col-span-2 p-2 text-sm text-center">{ITEM_STATUS_LABELS[item.status]}</div>
</div>
))
) : (
<div className="p-4 text-center text-muted-foreground text-sm">
.
</div>
))}
)}
</div>
{/* 작업내역 */}
<div className="border border-gray-300 mb-6">
{/* 검정 헤더 */}
<div className="bg-gray-800 text-white p-2.5 text-sm font-medium text-center">
{PROCESS_LABELS[order.process]}
{order.processName}
</div>
{/* 작업내역 그리드 */}
<div className="grid grid-cols-4 border-b border-gray-300">
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium"> </div>
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.workType}</div>
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium"> </div>
<div className="p-2 text-sm text-center">{workStats.workWidth}</div>
</div>
<div className="grid grid-cols-4 border-b border-gray-300">
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium"></div>
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.general} EA</div>
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium"></div>
<div className="p-2 text-sm text-center">{workStats.ironing} EA</div>
</div>
<div className="grid grid-cols-4 border-b border-gray-300">
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium"> </div>
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.sandblast} EA</div>
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium"></div>
<div className="p-2 text-sm text-center">{workStats.packing} EA</div>
</div>
{/* 수량 및 진행률 */}
<div className="grid grid-cols-6">
<div className="grid grid-cols-6 border-b border-gray-300">
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium"></div>
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.orderQty} EA</div>
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium"></div>
@@ -270,6 +340,16 @@ export function WorkLogModal({ open, onOpenChange, order }: WorkLogModalProps) {
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium"></div>
<div className="p-2 text-sm text-center font-medium text-blue-600">{workStats.progress}%</div>
</div>
{/* 상세 상태 */}
<div className="grid grid-cols-6">
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium"></div>
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.waitingQty} EA</div>
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium"></div>
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.inProgressQty} EA</div>
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium"></div>
<div className="p-2 text-sm text-center">{workStats.completedQty} EA</div>
</div>
</div>
{/* 특이 사항 */}
@@ -278,7 +358,7 @@ export function WorkLogModal({ open, onOpenChange, order }: WorkLogModalProps) {
</div>
<div className="p-4 min-h-[60px] text-sm">
{order.instruction || '-'}
{order.note || '-'}
</div>
</div>
</div>

View File

@@ -327,7 +327,7 @@ export default function WorkerScreen() {
<WorkLogModal
open={isWorkLogModalOpen}
onOpenChange={setIsWorkLogModalOpen}
order={selectedOrder}
workOrderId={selectedOrder?.id || null}
/>
<IssueReportModal