- UniversalListPage에 externalIsLoading prop 추가 - CardTransactionDetailClient DevFill 자동입력 기능 추가 - 여러 컴포넌트 로딩 상태 처리 개선 - skeleton 컴포넌트 확장 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* 작업자 화면 메인 컴포넌트
|
|
* API 연동 완료 (2025-12-26)
|
|
*
|
|
* 기능:
|
|
* - 상단 통계 카드 4개 (할당/작업중/완료/긴급)
|
|
* - 내 작업 목록 카드 리스트
|
|
* - 각 작업 카드별 버튼 (전량완료/공정상세/자재투입/작업일지/이슈보고)
|
|
*/
|
|
|
|
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 {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
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 { CompletionConfirmDialog } from './CompletionConfirmDialog';
|
|
import { CompletionToast } from './CompletionToast';
|
|
import { MaterialInputModal } from './MaterialInputModal';
|
|
import { WorkLogModal } from './WorkLogModal';
|
|
import { IssueReportModal } from './IssueReportModal';
|
|
import { WorkCompletionResultDialog } from './WorkCompletionResultDialog';
|
|
|
|
export default function WorkerScreen() {
|
|
// ===== 상태 관리 =====
|
|
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
// 데이터 로드
|
|
const loadData = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const result = await getMyWorkOrders();
|
|
if (result.success) {
|
|
setWorkOrders(result.data);
|
|
} else {
|
|
toast.error(result.error || '작업 목록 조회에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[WorkerScreen] loadData error:', error);
|
|
toast.error('데이터 로드 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
// 모달/다이얼로그 상태
|
|
const [selectedOrder, setSelectedOrder] = useState<WorkOrder | null>(null);
|
|
const [isCompletionDialogOpen, setIsCompletionDialogOpen] = useState(false);
|
|
const [isMaterialModalOpen, setIsMaterialModalOpen] = useState(false);
|
|
const [isWorkLogModalOpen, setIsWorkLogModalOpen] = useState(false);
|
|
const [isIssueReportModalOpen, setIsIssueReportModalOpen] = useState(false);
|
|
|
|
// 전량완료 흐름 상태
|
|
const [isCompletionFlow, setIsCompletionFlow] = useState(false);
|
|
const [isCompletionResultOpen, setIsCompletionResultOpen] = useState(false);
|
|
const [completionLotNo, setCompletionLotNo] = useState('');
|
|
|
|
// 투입된 자재 관리 (orderId -> MaterialInput[])
|
|
const [inputMaterialsMap, setInputMaterialsMap] = useState<Map<string, MaterialInput[]>>(
|
|
new Map()
|
|
);
|
|
|
|
// 완료 토스트 상태
|
|
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);
|
|
}
|
|
});
|
|
}, [workOrders, sortBy]);
|
|
|
|
// ===== 핸들러 =====
|
|
|
|
// 전량완료 버튼 클릭
|
|
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);
|
|
} else {
|
|
// 자재 투입이 필요합니다 팝업
|
|
setIsCompletionDialogOpen(true);
|
|
}
|
|
},
|
|
[inputMaterialsMap]
|
|
);
|
|
|
|
// "자재 투입이 필요합니다" 팝업에서 확인 클릭 → MaterialInputModal 열기
|
|
const handleCompletionConfirm = useCallback(() => {
|
|
setIsCompletionFlow(true);
|
|
setIsMaterialModalOpen(true);
|
|
}, []);
|
|
|
|
// 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 handleSaveMaterials = useCallback((orderId: string, materials: MaterialInput[]) => {
|
|
setInputMaterialsMap((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(orderId, materials);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// 완료 결과 팝업에서 확인 → API 완료 처리 후 목록에서 제거
|
|
const handleCompletionResultConfirm = useCallback(async () => {
|
|
if (!selectedOrder) return;
|
|
|
|
try {
|
|
// API로 완료 처리
|
|
const materials = inputMaterialsMap.get(selectedOrder.id);
|
|
const result = await completeWorkOrder(
|
|
selectedOrder.id,
|
|
materials?.map((m) => ({
|
|
materialId: parseInt(m.id),
|
|
quantity: m.inputQuantity,
|
|
lotNo: m.lotNo,
|
|
}))
|
|
);
|
|
|
|
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 || '작업 완료 처리에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[WorkerScreen] handleCompletionResultConfirm error:', error);
|
|
toast.error('작업 완료 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setSelectedOrder(null);
|
|
setCompletionLotNo('');
|
|
}
|
|
}, [selectedOrder, inputMaterialsMap]);
|
|
|
|
const handleProcessDetail = useCallback((order: WorkOrder) => {
|
|
setSelectedOrder(order);
|
|
// 공정상세는 카드 내 토글로 처리 (Phase 4에서 구현)
|
|
console.log('[공정상세] 토글:', order.orderNo);
|
|
}, []);
|
|
|
|
const handleMaterialInput = useCallback((order: WorkOrder) => {
|
|
setSelectedOrder(order);
|
|
setIsMaterialModalOpen(true);
|
|
}, []);
|
|
|
|
const handleWorkLog = useCallback((order: WorkOrder) => {
|
|
setSelectedOrder(order);
|
|
setIsWorkLogModalOpen(true);
|
|
}, []);
|
|
|
|
const handleIssueReport = useCallback((order: WorkOrder) => {
|
|
setSelectedOrder(order);
|
|
setIsIssueReportModalOpen(true);
|
|
}, []);
|
|
|
|
return (
|
|
<PageLayout>
|
|
<div className="space-y-6">
|
|
{/* 완료 토스트 */}
|
|
{toastInfo && <CompletionToast info={toastInfo} />}
|
|
|
|
{/* 헤더 */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
|
<ClipboardList className="h-5 w-5 text-primary" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold">작업자 화면</h1>
|
|
<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>
|
|
|
|
{/* 작업 목록 */}
|
|
<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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 모달/다이얼로그 */}
|
|
<CompletionConfirmDialog
|
|
open={isCompletionDialogOpen}
|
|
onOpenChange={setIsCompletionDialogOpen}
|
|
order={selectedOrder}
|
|
onConfirm={handleCompletionConfirm}
|
|
/>
|
|
|
|
<MaterialInputModal
|
|
open={isMaterialModalOpen}
|
|
onOpenChange={setIsMaterialModalOpen}
|
|
order={selectedOrder}
|
|
isCompletionFlow={isCompletionFlow}
|
|
onComplete={handleWorkCompletion}
|
|
onSaveMaterials={handleSaveMaterials}
|
|
savedMaterials={selectedOrder ? inputMaterialsMap.get(selectedOrder.id) : undefined}
|
|
/>
|
|
|
|
<WorkLogModal
|
|
open={isWorkLogModalOpen}
|
|
onOpenChange={setIsWorkLogModalOpen}
|
|
workOrderId={selectedOrder?.id || null}
|
|
/>
|
|
|
|
<IssueReportModal
|
|
open={isIssueReportModalOpen}
|
|
onOpenChange={setIsIssueReportModalOpen}
|
|
order={selectedOrder}
|
|
/>
|
|
|
|
<WorkCompletionResultDialog
|
|
open={isCompletionResultOpen}
|
|
onOpenChange={setIsCompletionResultOpen}
|
|
lotNo={completionLotNo}
|
|
onConfirm={handleCompletionResultConfirm}
|
|
/>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
// ===== 하위 컴포넌트 =====
|
|
|
|
interface StatCardProps {
|
|
title: string;
|
|
value: number;
|
|
icon: React.ReactNode;
|
|
variant: 'default' | 'blue' | 'green' | 'red';
|
|
}
|
|
|
|
function StatCard({ title, value, icon, variant }: StatCardProps) {
|
|
const variantClasses = {
|
|
default: 'bg-gray-50 text-gray-700',
|
|
blue: 'bg-blue-50 text-blue-700',
|
|
green: 'bg-green-50 text-green-700',
|
|
red: 'bg-red-50 text-red-700',
|
|
};
|
|
|
|
return (
|
|
<Card className={variantClasses[variant]}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">{title}</span>
|
|
{icon}
|
|
</div>
|
|
<p className="text-2xl font-bold mt-2">{value}</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|