feat: [작업자화면] 절곡 바라시 모달 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
유병철
2026-03-23 12:29:55 +09:00
parent 5688fd9d9f
commit b7e865d481
3 changed files with 426 additions and 0 deletions

View File

@@ -0,0 +1,226 @@
'use client';
/**
* 절곡 바라시 작업지시서 문서 콘텐츠
*
* DocumentViewer 내에서 사용 (PDF 저장 지원)
* 모델 API에서 가져온 components 데이터로 부품별 절곡치수 테이블 렌더링
*
* 구성:
* - 헤더: 현장명 / 모델코드 / 유형 / 마감
* - 부품별 절곡치수 테이블: 번호 / 재질 / 절곡치수(개별 셀) / 길이 / 수량 / 면적(폭합)
* - 재질별 폭합 합계
*/
import { useMemo } from 'react';
import { ConstructionApprovalTable } from '@/components/document-system';
import type { ComponentData } from '../bending/types';
import type { BendingInfoExtended, LengthQuantity } from '../WorkOrders/documents/bending/types';
interface BendingBarashiContentProps {
components: ComponentData[];
bendingInfo?: BendingInfoExtended;
projectName?: string;
lotNo?: string;
assignee?: string;
}
// 재질별 폭합 합계 계산
function calcMaterialSummary(components: ComponentData[]): Record<string, number> {
const summary: Record<string, number> = {};
for (const c of components) {
if (c.material) {
summary[c.material] = (summary[c.material] || 0) + c.width_sum * c.quantity;
}
}
return summary;
}
// 길이별 수량 포맷
function formatLengthQuantities(lqs: LengthQuantity[]): string {
if (!lqs || lqs.length === 0) return '-';
return lqs.map(lq => `${lq.length.toLocaleString()}mm × ${lq.quantity}`).join(', ');
}
// 폭합 → mm² → ㎡ 면적 계산 (width_sum mm × length mm)
function calcArea(widthSum: number, lengthQuantities: LengthQuantity[]): string {
if (!lengthQuantities || lengthQuantities.length === 0 || widthSum === 0) return '-';
// 총 길이 합계
const totalLength = lengthQuantities.reduce((acc, lq) => acc + lq.length * lq.quantity, 0);
const areaMm2 = widthSum * totalLength;
const areaM2 = areaMm2 / 1_000_000;
return areaM2.toFixed(2);
}
export function BendingBarashiContent({
components,
bendingInfo,
projectName,
lotNo,
assignee,
}: BendingBarashiContentProps) {
const today = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const modelCode = bendingInfo?.productCode || '-';
const modelType = bendingInfo?.common?.type || '-';
const finishMaterial = bendingInfo?.finishMaterial || '-';
const lengthQuantities = bendingInfo?.common?.lengthQuantities || [];
// 부품별 절곡치수 최대 컬럼 수
const maxBendingCols = useMemo(() => {
return Math.max(...components.map(c => c.bendingData?.length || 0), 0);
}, [components]);
// 재질별 폭합
const materialSummary = useMemo(() => calcMaterialSummary(components), [components]);
return (
<div className="p-6 bg-white">
{/* ===== 헤더 ===== */}
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-2xl font-bold"> </h1>
<p className="text-xs text-gray-500 mt-1">
: {today}
</p>
</div>
<ConstructionApprovalTable
approvers={{ writer: { name: assignee || '-' } }}
className="flex-shrink-0"
/>
</div>
{/* ===== 기본 정보 ===== */}
<table className="w-full border-collapse text-xs mb-6">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 text-center"></td>
<td className="border border-gray-400 px-3 py-2">{projectName || '-'}</td>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 text-center"></td>
<td className="border border-gray-400 px-3 py-2 text-blue-600 font-semibold">{modelCode}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center"></td>
<td className="border border-gray-400 px-3 py-2">{modelType}</td>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center"></td>
<td className="border border-gray-400 px-3 py-2">{finishMaterial}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center">LOT NO</td>
<td className="border border-gray-400 px-3 py-2">{lotNo || '-'}</td>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center"> </td>
<td className="border border-gray-400 px-3 py-2">{formatLengthQuantities(lengthQuantities)}</td>
</tr>
</tbody>
</table>
{/* ===== 부품별 절곡치수 ===== */}
<div className="mb-4">
<h2 className="text-sm font-bold mb-2"> </h2>
{components.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-500 border border-gray-300 rounded">
.
</div>
) : (
<table className="w-full border-collapse text-[11px]">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 px-1 py-1 text-center whitespace-nowrap"></th>
<th className="border border-gray-400 px-1 py-1 text-center whitespace-nowrap"></th>
<th className="border border-gray-400 px-1 py-1 text-center" colSpan={maxBendingCols || 1}></th>
<th className="border border-gray-400 px-1 py-1 text-center whitespace-nowrap"></th>
<th className="border border-gray-400 px-1 py-1 text-center whitespace-nowrap"></th>
<th className="border border-gray-400 px-1 py-1 text-center whitespace-nowrap"></th>
</tr>
</thead>
<tbody>
{components.map((comp, idx) => {
const bendingData = comp.bendingData || [];
const emptyColCount = maxBendingCols - bendingData.length;
return (
<tr key={idx}>
{/* 번호 + 부품명 */}
<td className="border border-gray-400 px-1 py-1 text-center whitespace-nowrap">
{comp.orderNumber}
<br />
<span className="text-[9px] text-gray-500">({comp.itemName})</span>
</td>
{/* 재질 */}
<td className="border border-gray-400 px-1 py-1 text-center whitespace-nowrap text-[10px]">
{comp.material}
</td>
{/* 절곡치수 개별 셀 */}
{bendingData.map((d, i) => (
<td
key={i}
className={`border border-gray-400 px-0.5 py-1 text-center min-w-[24px] ${
d.color ? 'bg-gray-200 font-bold' : ''
}`}
>
{d.sum}
{d.aAngle && <span className="text-red-600 font-bold text-[9px]"> A°</span>}
</td>
))}
{/* 빈 셀 채우기 (최대 컬럼 수 맞춤) */}
{emptyColCount > 0 &&
Array.from({ length: emptyColCount }).map((_, i) => (
<td key={`empty-${i}`} className="border border-gray-400 px-0.5 py-1" />
))
}
{/* 폭합 */}
<td className="border border-gray-400 px-1 py-1 text-center font-semibold">
{comp.width_sum.toLocaleString()}
</td>
{/* 수량 */}
<td className="border border-gray-400 px-1 py-1 text-center">
{comp.quantity}
</td>
{/* 면적 */}
<td className="border border-gray-400 px-1 py-1 text-center">
{calcArea(comp.width_sum, lengthQuantities)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* ===== 재질별 폭합 합계 ===== */}
{Object.keys(materialSummary).length > 0 && (
<div className="mb-4">
<h2 className="text-sm font-bold mb-2"> </h2>
<table className="border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 px-3 py-1.5"></th>
<th className="border border-gray-400 px-3 py-1.5"> (mm)</th>
</tr>
</thead>
<tbody>
{Object.entries(materialSummary).map(([material, total]) => (
<tr key={material}>
<td className="border border-gray-400 px-3 py-1.5">{material}</td>
<td className="border border-gray-400 px-3 py-1.5 text-right font-semibold">
{total.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,171 @@
'use client';
/**
* 절곡 바라시 모달
*
* DocumentViewer 사용 (PDF 저장 지원)
* 작업지시 → bendingInfo.productCode → 모델 API → components(BendingData[]) 로드
*/
import { useState, useEffect } from 'react';
import { Loader2 } from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { getWorkOrderById } from '../WorkOrders/actions';
import { getGuiderailModels, getGuiderailModel } from '../bending/actions';
import { parseApiComponent, recalculateSums, getWidthSum } from '../bending/types';
import type { WorkOrder } from '../WorkOrders/types';
import type { BendingInfoExtended } from '../WorkOrders/documents/bending/types';
import type { ComponentData, GuiderailModelDetail } from '../bending/types';
import { BendingBarashiContent } from './BendingBarashiContent';
interface BendingBarashiModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workOrderId: string | null;
}
// API raw component → ComponentData 파싱 (BendingModelForm과 동일 로직)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function parseComponent(c: any, i: number): ComponentData {
if (c.inputList) return parseApiComponent(c, i);
return {
...c,
orderNumber: c.orderNumber || i + 1,
quantity: c.quantity || 1,
bendingData: c.bendingData ? recalculateSums(c.bendingData) : [],
width_sum: c.width_sum || getWidthSum(c.bendingData || []),
sourceItemId: c.sourceItemId || c.sam_item_id || undefined,
};
}
export function BendingBarashiModal({
open,
onOpenChange,
workOrderId,
}: BendingBarashiModalProps) {
const [order, setOrder] = useState<WorkOrder | null>(null);
const [components, setComponents] = useState<ComponentData[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !workOrderId) return;
// mock ID 처리
if (workOrderId.startsWith('mock-') || workOrderId.startsWith('order-')) {
setError('절곡 바라시 데이터를 불러올 수 없습니다.');
return;
}
setIsLoading(true);
setError(null);
(async () => {
try {
// 1. 작업지시 상세 조회
const orderResult = await getWorkOrderById(workOrderId);
if (!orderResult.success || !orderResult.data) {
setError('작업지시 데이터를 불러올 수 없습니다.');
return;
}
const wo = orderResult.data;
setOrder(wo);
// 2. bendingInfo에서 productCode 추출
const bendingInfo = wo.bendingInfo as BendingInfoExtended | undefined;
const productCode = bendingInfo?.productCode;
if (!productCode) {
setError('절곡 모델 코드가 없습니다.');
return;
}
// 3. 모델 검색 (GUIDERAIL_MODEL에서 productCode로 검색)
const searchResult = await getGuiderailModels({
item_category: 'GUIDERAIL_MODEL',
search: productCode,
perPage: 10,
});
if (!searchResult.success || !searchResult.data?.length) {
// SHUTTERBOX_MODEL에서 재시도
const sbResult = await getGuiderailModels({
item_category: 'SHUTTERBOX_MODEL',
search: productCode,
perPage: 10,
});
if (!sbResult.success || !sbResult.data?.length) {
setError(`모델 "${productCode}"을 찾을 수 없습니다.`);
return;
}
searchResult.data = sbResult.data;
}
// code 또는 model_name 정확 매칭
const matchedModel = searchResult.data.find(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(m: any) => m.code === productCode || m.model_name === productCode
) || searchResult.data[0];
// 4. 모델 상세 조회 (components 포함)
const modelId = (matchedModel as { id: number }).id;
const detailResult = await getGuiderailModel(modelId);
if (!detailResult.success || !detailResult.data) {
setError('모델 상세 데이터를 불러올 수 없습니다.');
return;
}
const modelDetail = detailResult.data as GuiderailModelDetail;
const parsedComponents = (modelDetail.components || []).map(parseComponent);
setComponents(parsedComponents);
} catch {
setError('데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
})();
return () => {
// cleanup on close
};
}, [open, workOrderId]);
// 모달 닫힐 때 상태 초기화
useEffect(() => {
if (!open) {
setOrder(null);
setComponents([]);
setError(null);
}
}, [open]);
const bendingInfo = order?.bendingInfo as BendingInfoExtended | undefined;
const assignee = order?.assignees?.find(a => a.isPrimary)?.name || order?.assignee || '-';
return (
<DocumentViewer
title="절곡 바라시 작업지시서"
subtitle={bendingInfo?.productCode ? `모델: ${bendingInfo.productCode}` : undefined}
preset="inspection"
open={open}
onOpenChange={onOpenChange}
>
{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 ? (
<div className="flex flex-col items-center justify-center h-64 gap-4 bg-white">
<p className="text-muted-foreground">{error}</p>
</div>
) : (
<BendingBarashiContent
components={components}
bendingInfo={bendingInfo}
projectName={order?.projectName}
lotNo={order?.lotNo}
assignee={assignee}
/>
)}
</DocumentViewer>
);
}

View File

@@ -90,6 +90,9 @@ const WorkCompletionResultDialog = dynamic(
const InspectionReportModal = dynamic(
() => import('../WorkOrders/documents').then(mod => ({ default: mod.InspectionReportModal })),
);
const BendingBarashiModal = dynamic(
() => import('./BendingBarashiModal').then(mod => ({ default: mod.BendingBarashiModal })),
);
interface SidebarOrder {
id: string;
@@ -275,6 +278,7 @@ export default function WorkerScreen() {
const [selectedWorkOrderItemName, setSelectedWorkOrderItemName] = useState<string | undefined>();
const [isWorkLogModalOpen, setIsWorkLogModalOpen] = useState(false);
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
const [isBarashiModalOpen, setIsBarashiModalOpen] = useState(false);
const [isIssueReportModalOpen, setIsIssueReportModalOpen] = useState(false);
const [isInspectionInputModalOpen, setIsInspectionInputModalOpen] = useState(false);
// 공정의 중간검사 설정
@@ -1321,6 +1325,16 @@ export default function WorkerScreen() {
}
}, [getTargetOrder]);
const handleBarashi = useCallback(() => {
const target = getTargetOrder();
if (target) {
setSelectedOrder(target);
setIsBarashiModalOpen(true);
} else {
toast.error('표시할 작업이 없습니다.');
}
}, [getTargetOrder]);
// 검사 완료 핸들러 (API 저장 + 메모리 업데이트 + 공정 단계 완료 처리)
const handleInspectionComplete = useCallback(async (data: InspectionData) => {
if (!selectedOrder) return;
@@ -1699,6 +1713,15 @@ export default function WorkerScreen() {
{(hasWipItems || activeProcessSettings.needsWorkLog || activeProcessSettings.hasDocumentTemplate) && (
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[24px] ${sidebarCollapsed ? 'md:left-[120px]' : 'md:left-[280px]'}`}>
<div className="flex gap-2 md:gap-3">
{activeProcessTabKey === 'bending' && !hasWipItems && (
<Button
variant="outline"
onClick={handleBarashi}
className="flex-1 py-5 md:py-6 text-sm md:text-base font-medium"
>
</Button>
)}
{(hasWipItems || activeProcessSettings.needsWorkLog) && (
<Button
variant="outline"
@@ -1757,6 +1780,12 @@ export default function WorkerScreen() {
workLogTemplateName={activeProcessSettings.workLogTemplateName}
/>
<BendingBarashiModal
open={isBarashiModalOpen}
onOpenChange={setIsBarashiModalOpen}
workOrderId={selectedOrder?.id || null}
/>
<InspectionReportModal
open={isInspectionModalOpen}
onOpenChange={setIsInspectionModalOpen}