- 생산지시 생성 실패 시 에러 메시지가 UI에 표시되지 않던 문제 수정 - WorkOrderDetail.tsx에서 Loader2 import 누락 수정
449 lines
16 KiB
TypeScript
449 lines
16 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* 작업지시 상세 페이지
|
|
* API 연동 완료 (2025-12-26)
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { FileText, List, AlertTriangle, Play, CheckCircle2, Loader2 } from 'lucide-react';
|
|
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
|
import { WorkLogModal } from '../WorkerScreen/WorkLogModal';
|
|
import { toast } from 'sonner';
|
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
|
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,
|
|
BENDING_PROCESS_STEPS,
|
|
type WorkOrder,
|
|
type ProcessType,
|
|
} from './types';
|
|
|
|
// 공정 진행 단계 컴포넌트
|
|
function ProcessSteps({
|
|
processType,
|
|
currentStep,
|
|
}: {
|
|
processType: ProcessType;
|
|
currentStep: number;
|
|
}) {
|
|
const steps =
|
|
processType === 'screen'
|
|
? SCREEN_PROCESS_STEPS
|
|
: processType === 'slat'
|
|
? SLAT_PROCESS_STEPS
|
|
: BENDING_PROCESS_STEPS;
|
|
|
|
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">
|
|
{steps.map((step, index) => {
|
|
const isCompleted = index < currentStep;
|
|
const isCurrent = index === currentStep;
|
|
|
|
return (
|
|
<div key={step.key} 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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 전개도 상세정보 컴포넌트 (절곡용)
|
|
function BendingDetailsSection({ order }: { order: WorkOrder }) {
|
|
if (!order.bendingDetails || order.bendingDetails.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="bg-white border rounded-lg p-6">
|
|
<h3 className="font-semibold mb-4">전개도 상세정보</h3>
|
|
<div className="space-y-4">
|
|
{order.bendingDetails.map((detail) => (
|
|
<div key={detail.id} className="border rounded-lg overflow-hidden">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between bg-gray-100 px-4 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold">{detail.code}</span>
|
|
<span className="font-medium">{detail.name}</span>
|
|
<span className="text-sm text-muted-foreground">{detail.material}</span>
|
|
</div>
|
|
<span className="text-sm">수량: {detail.quantity}</span>
|
|
</div>
|
|
|
|
{/* 상세 정보 */}
|
|
<div className="grid grid-cols-5 gap-4 p-4">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1">전개폭</p>
|
|
<p className="font-medium">{detail.developWidth}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1">길이</p>
|
|
<p className="font-medium">{detail.length}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1">중량</p>
|
|
<p className="font-medium">{detail.weight}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1">비고</p>
|
|
<p className="font-medium">{detail.note}</p>
|
|
</div>
|
|
<div className="row-span-2 flex items-center justify-center border rounded bg-gray-50">
|
|
{/* 전개도 이미지 placeholder */}
|
|
<div className="text-center p-4">
|
|
<div className="w-24 h-16 border-2 border-dashed border-gray-300 rounded flex items-center justify-center mb-1">
|
|
<span className="text-xs text-muted-foreground">전개도</span>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">{detail.developDimension}</span>
|
|
</div>
|
|
</div>
|
|
<div className="col-span-4">
|
|
<p className="text-xs text-muted-foreground mb-1">전개치수</p>
|
|
<p className="font-medium">{detail.developDimension}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 이슈 섹션 컴포넌트
|
|
function IssueSection({ order }: { order: WorkOrder }) {
|
|
if (!order.issues || order.issues.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="bg-white border rounded-lg p-6">
|
|
<h3 className="font-semibold mb-4">이슈 ({order.issues.length}건)</h3>
|
|
<div className="space-y-3">
|
|
{order.issues.map((issue) => (
|
|
<div key={issue.id} className="flex items-start gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
|
|
<Badge
|
|
variant="outline"
|
|
className={`shrink-0 ${
|
|
issue.status === 'processing'
|
|
? 'bg-yellow-100 text-yellow-700 border-yellow-300'
|
|
: issue.status === 'resolved'
|
|
? 'bg-green-100 text-green-700 border-green-300'
|
|
: 'bg-gray-100 text-gray-700 border-gray-300'
|
|
}`}
|
|
>
|
|
{ISSUE_STATUS_LABELS[issue.status]}
|
|
</Badge>
|
|
<div>
|
|
<span className="font-medium">{issue.type}</span>
|
|
<span className="mx-2 text-muted-foreground">·</span>
|
|
<span>{issue.description}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface WorkOrderDetailProps {
|
|
orderId: string;
|
|
}
|
|
|
|
export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
|
const router = useRouter();
|
|
const [isWorkLogOpen, setIsWorkLogOpen] = useState(false);
|
|
const [order, setOrder] = useState<WorkOrder | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isStatusUpdating, setIsStatusUpdating] = useState(false);
|
|
|
|
// API에서 데이터 로드
|
|
const loadData = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const result = await getWorkOrderById(orderId);
|
|
if (result.success && result.data) {
|
|
setOrder(result.data);
|
|
} else {
|
|
toast.error(result.error || '작업지시 조회에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('[WorkOrderDetail] loadData error:', error);
|
|
toast.error('데이터 로드 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [orderId]);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
// 상태 변경 핸들러
|
|
const handleStatusChange = useCallback(async (newStatus: 'waiting' | 'in_progress' | 'completed') => {
|
|
if (!order) return;
|
|
|
|
setIsStatusUpdating(true);
|
|
try {
|
|
const result = await updateWorkOrderStatus(orderId, newStatus);
|
|
if (result.success && result.data) {
|
|
setOrder(result.data);
|
|
const statusLabels = {
|
|
waiting: '작업대기',
|
|
in_progress: '작업중',
|
|
completed: '작업완료',
|
|
};
|
|
toast.success(`상태가 '${statusLabels[newStatus]}'(으)로 변경되었습니다.`);
|
|
} else {
|
|
toast.error(result.error || '상태 변경에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('[WorkOrderDetail] handleStatusChange error:', error);
|
|
toast.error('상태 변경 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsStatusUpdating(false);
|
|
}
|
|
}, [order, orderId]);
|
|
|
|
// 로딩 상태
|
|
if (isLoading) {
|
|
return (
|
|
<PageLayout>
|
|
<h1 className="text-2xl font-bold mb-6">작업지시 상세</h1>
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
if (!order) {
|
|
return (
|
|
<PageLayout>
|
|
<h1 className="text-2xl font-bold mb-6">작업지시 상세</h1>
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
작업지시를 찾을 수 없습니다.
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
// 작업일지용 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>
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1 className="text-2xl font-bold">작업지시 상세</h1>
|
|
<div className="flex items-center gap-2">
|
|
{/* 상태 변경 버튼 */}
|
|
{order.status === 'waiting' && (
|
|
<Button
|
|
onClick={() => handleStatusChange('in_progress')}
|
|
disabled={isStatusUpdating}
|
|
className="bg-green-600 hover:bg-green-700"
|
|
>
|
|
{isStatusUpdating ? (
|
|
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
|
) : (
|
|
<Play className="w-4 h-4 mr-1.5" />
|
|
)}
|
|
작업 시작
|
|
</Button>
|
|
)}
|
|
{order.status === 'in_progress' && (
|
|
<Button
|
|
onClick={() => handleStatusChange('completed')}
|
|
disabled={isStatusUpdating}
|
|
className="bg-purple-600 hover:bg-purple-700"
|
|
>
|
|
{isStatusUpdating ? (
|
|
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
|
) : (
|
|
<CheckCircle2 className="w-4 h-4 mr-1.5" />
|
|
)}
|
|
작업 완료
|
|
</Button>
|
|
)}
|
|
<Button variant="outline" onClick={() => setIsWorkLogOpen(true)}>
|
|
<FileText className="w-4 h-4 mr-1.5" />
|
|
작업일지
|
|
</Button>
|
|
<Button variant="outline" onClick={() => router.push('/production/work-orders')}>
|
|
<List className="w-4 h-4 mr-1.5" />
|
|
목록
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
{/* 기본 정보 */}
|
|
<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>
|
|
<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>
|
|
</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>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground mb-1">발주처</p>
|
|
<p className="font-medium">{order.client}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground mb-1">현장명</p>
|
|
<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>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground mb-1">작업자</p>
|
|
<p className="font-medium">
|
|
{order.assignees && order.assignees.length > 0
|
|
? order.assignees.map(a => a.name).join(', ')
|
|
: order.assignee}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 공정 진행 */}
|
|
<ProcessSteps processType={order.processType} currentStep={order.currentStep} />
|
|
|
|
{/* 작업 품목 */}
|
|
<div className="bg-white border rounded-lg p-6">
|
|
<h3 className="font-semibold mb-4">작업 품목 ({order.items.length}건)</h3>
|
|
{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 className="w-28">층/부호</TableHead>
|
|
<TableHead className="w-32">규격</TableHead>
|
|
<TableHead className="w-20 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 className="font-medium">{item.productName}</TableCell>
|
|
<TableCell>{item.floorCode}</TableCell>
|
|
<TableCell>{item.specification}</TableCell>
|
|
<TableCell className="text-right">{item.quantity}</TableCell>
|
|
<TableCell>
|
|
{item.status === 'waiting' && (
|
|
<Button variant="outline" size="sm">
|
|
<Play className="w-3 h-3 mr-1" />
|
|
시작
|
|
</Button>
|
|
)}
|
|
{item.status === 'in_progress' && (
|
|
<Button variant="outline" size="sm">
|
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
|
완료
|
|
</Button>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
) : (
|
|
<p className="text-muted-foreground text-center py-8">
|
|
등록된 품목이 없습니다.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* 절곡 전용: 전개도 상세정보 */}
|
|
{order.processType === 'bending' && <BendingDetailsSection order={order} />}
|
|
|
|
{/* 이슈 섹션 */}
|
|
<IssueSection order={order} />
|
|
</div>
|
|
|
|
{/* 작업일지 모달 */}
|
|
<WorkLogModal
|
|
open={isWorkLogOpen}
|
|
onOpenChange={setIsWorkLogOpen}
|
|
order={workLogOrder}
|
|
/>
|
|
</PageLayout>
|
|
);
|
|
} |