Files
sam-react-prod/src/components/production/WorkOrders/WorkOrderDetail.tsx
kent 6cd5477eed fix(WEB): 생산지시 생성 에러 표시 및 Loader2 import 수정
- 생산지시 생성 실패 시 에러 메시지가 UI에 표시되지 않던 문제 수정
- WorkOrderDetail.tsx에서 Loader2 import 누락 수정
2026-01-12 19:11:27 +09:00

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>
);
}