Files
sam-react-prod/src/components/production/WorkerScreen/WorkLogModal.tsx

299 lines
10 KiB
TypeScript
Raw Normal View History

'use client';
/**
*
*
* document-system (2026-01-22)
* (2026-01-29)
* (2026-02-11)
* - DocumentViewer
* - workLogTemplateId/Name
* - processType
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { Loader2, Save } from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { getWorkOrderById, getMaterialInputLots, patchDocumentSnapshot } from '../WorkOrders/actions';
import { saveWorkLog, getWorkLog } from './actions';
import type { MaterialInputLot } from '../WorkOrders/actions';
import type { WorkOrder, ProcessType } from '../WorkOrders/types';
import { WorkLogContent } from './WorkLogContent';
import {
ScreenWorkLogContent,
SlatWorkLogContent,
BendingWorkLogContent,
} from '../WorkOrders/documents';
interface WorkLogModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workOrderId: string | null;
processType?: ProcessType;
/** 공정관리에서 매핑된 작업일지 양식 ID */
workLogTemplateId?: number;
/** 공정관리에서 매핑된 작업일지 양식명 (예: '스크린 작업일지') */
workLogTemplateName?: string;
}
/**
*
*
*/
function resolveProcessTypeFromTemplate(templateName?: string): ProcessType | undefined {
if (!templateName) return undefined;
if (templateName.includes('스크린')) return 'screen';
if (templateName.includes('슬랫')) return 'slat';
if (templateName.includes('절곡')) return 'bending';
return undefined;
}
export function WorkLogModal({
open,
onOpenChange,
workOrderId,
processType,
workLogTemplateId,
workLogTemplateName,
}: WorkLogModalProps) {
const [order, setOrder] = useState<WorkOrder | null>(null);
const [materialLots, setMaterialLots] = useState<MaterialInputLot[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const contentWrapperRef = useRef<HTMLDivElement>(null);
// Lazy Snapshot 대상 문서 ID
const [snapshotDocumentId, setSnapshotDocumentId] = useState<number | null>(null);
// 목업 WorkOrder 생성
const createMockOrder = (id: string, pType?: ProcessType): WorkOrder => ({
id,
workOrderNo: 'KD-WO-260129-01',
lotNo: 'KD-SA-260129-01',
processId: 1,
processName: pType === 'slat' ? '슬랫' : pType === 'bending' ? '절곡' : '스크린',
processCode: pType || 'screen',
processType: pType || 'screen',
status: 'in_progress',
client: '(주)경동',
projectName: '서울 강남 현장',
dueDate: '2026-02-05',
assignee: '홍길동',
assignees: [{ id: '1', name: '홍길동', isPrimary: true }],
orderDate: '2026-01-20',
scheduledDate: '2026-01-29',
shipmentDate: '2026-02-05',
salesOrderDate: '2026-01-15',
isAssigned: true,
isStarted: true,
priority: 3,
priorityLabel: '긴급',
shutterCount: 12,
department: '생산부',
items: [
{ id: '1', no: 1, status: 'in_progress', productName: '와이어 스크린', floorCode: '1층/FSS-01', specification: '8,260 X 8,350', quantity: 2, unit: 'EA', orderNodeId: null, orderNodeName: '' },
{ id: '2', no: 2, status: 'waiting', productName: '메쉬 스크린', floorCode: '2층/FSS-03', specification: '6,400 X 5,200', quantity: 4, unit: 'EA', orderNodeId: null, orderNodeName: '' },
{ id: '3', no: 3, status: 'completed', productName: '광폭 와이어', floorCode: '3층/FSS-05', specification: '12,000 X 4,500', quantity: 1, unit: 'EA', orderNodeId: null, orderNodeName: '' },
],
currentStep: 2,
issues: [],
note: '',
});
// 모달 열릴 때 데이터 fetch
useEffect(() => {
if (open && workOrderId) {
// 목업 ID인 경우 API 호출 생략
if (workOrderId.startsWith('mock-')) {
setOrder(createMockOrder(workOrderId, processType));
setError(null);
return;
}
setIsLoading(true);
setError(null);
Promise.all([
getWorkOrderById(workOrderId),
getMaterialInputLots(workOrderId),
getWorkLog(workOrderId),
])
.then(([orderResult, lotsResult, workLogResult]) => {
if (orderResult.success && orderResult.data) {
setOrder(orderResult.data);
} else {
setError(orderResult.error || '데이터를 불러올 수 없습니다.');
}
if (lotsResult.success) {
setMaterialLots(lotsResult.data);
}
// Lazy Snapshot: 문서가 있고 rendered_html이 없으면 스냅샷 대상
if (workLogResult.success && workLogResult.data?.document) {
const doc = workLogResult.data.document as { id?: number; rendered_html?: string | null };
if (doc.id && !doc.rendered_html) {
setSnapshotDocumentId(doc.id);
}
}
})
.catch(() => {
setError('서버 오류가 발생했습니다.');
})
.finally(() => {
setIsLoading(false);
});
} else if (!open) {
// 모달 닫힐 때 상태 초기화
setOrder(null);
setMaterialLots([]);
setSnapshotDocumentId(null);
setError(null);
}
}, [open, workOrderId, processType]);
// Lazy Snapshot: 콘텐츠 렌더링 완료 후 rendered_html이 없는 문서에 스냅샷 저장
useEffect(() => {
if (!snapshotDocumentId || isLoading || !order) return;
const timer = setTimeout(() => {
const html = contentWrapperRef.current?.innerHTML;
if (html && html.length > 50) {
patchDocumentSnapshot(snapshotDocumentId, html).then((result) => {
if (result.success) {
setSnapshotDocumentId(null);
}
});
}
}, 500);
return () => clearTimeout(timer);
}, [snapshotDocumentId, isLoading, order]);
// 저장 핸들러
const handleSave = useCallback(async () => {
if (!workOrderId || !order) return;
setIsSaving(true);
try {
// 현재 아이템 데이터를 table_data로 변환
const tableData = (order.items || []).map((item) => ({
id: item.id,
item_name: item.productName,
specification: item.specification || item.floorCode,
quantity: item.quantity,
unit: item.unit || 'EA',
}));
// HTML 스냅샷 캡처 (MNG 출력용)
const renderedHtml = contentWrapperRef.current?.innerHTML || undefined;
const result = await saveWorkLog(workOrderId, {
table_data: tableData,
title: workLogTemplateName || '작업일지',
rendered_html: renderedHtml,
});
if (result.success) {
toast.success('작업일지가 저장되었습니다.');
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, [workOrderId, order, workLogTemplateName]);
if (!workOrderId) return null;
// 로딩/에러 상태는 DocumentViewer 내부에서 처리
const subtitle = order ? `${order.processName} 생산부서` : undefined;
// 양식 미매핑 안내
const renderNoTemplate = () => (
<div className="flex flex-col items-center justify-center h-64 gap-4 bg-white">
<p className="text-muted-foreground">
.
</p>
<p className="text-sm text-muted-foreground">
.
</p>
</div>
);
// 공정관리 양식 매핑 기반 콘텐츠 분기
const renderContent = () => {
if (!order) return null;
// 1순위: 공정관리에서 매핑된 양식명으로 결정
const templateType = resolveProcessTypeFromTemplate(workLogTemplateName);
// 2순위: processType 폴백 (양식 미매핑 시)
const type = templateType || processType || order.processType;
// 양식이 매핑되어 있지 않은 경우 안내
if (!workLogTemplateId && !processType) {
return renderNoTemplate();
}
switch (type) {
case 'screen':
return <ScreenWorkLogContent data={order} materialLots={materialLots} />;
case 'slat':
return <SlatWorkLogContent data={order} materialLots={materialLots} />;
case 'bending': {
const lotNoMap: Record<string, string> = {};
for (const lot of materialLots) {
if (lot.item_code.startsWith('BD-')) {
lotNoMap[lot.item_code] = lot.lot_no;
}
}
return <BendingWorkLogContent data={order} lotNoMap={lotNoMap} />;
}
default:
return <WorkLogContent data={order} />;
}
};
// 양식명으로 문서 제목 결정
const documentTitle = workLogTemplateName || '작업일지';
const toolbarExtra = (
<Button onClick={handleSave} disabled={isSaving} size="sm">
{isSaving ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
) : (
<Save className="w-4 h-4 mr-1.5" />
)}
{isSaving ? '저장 중...' : '저장'}
</Button>
);
return (
<DocumentViewer
title={documentTitle}
subtitle={subtitle}
preset="inspection"
open={open}
onOpenChange={onOpenChange}
toolbarExtra={toolbarExtra}
>
{isLoading ? (
<div className="flex items-center justify-center h-64 bg-white">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
) : error || !order ? (
<div className="flex flex-col items-center justify-center h-64 gap-4 bg-white">
<p className="text-muted-foreground">{error || '데이터를 불러올 수 없습니다.'}</p>
</div>
) : (
<div ref={contentWrapperRef}>
{renderContent()}
</div>
)}
</DocumentViewer>
);
}