Files
sam-react-prod/src/components/production/WorkerScreen/index.tsx
유병철 19237be4aa refactor: UniversalListPage externalIsLoading 지원 및 스켈레톤 개선
- UniversalListPage에 externalIsLoading prop 추가
- CardTransactionDetailClient DevFill 자동입력 기능 추가
- 여러 컴포넌트 로딩 상태 처리 개선
- skeleton 컴포넌트 확장

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 20:54:16 +09:00

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