feat: [작업자화면] 절곡 바라시 모달 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
226
src/components/production/WorkerScreen/BendingBarashiContent.tsx
Normal file
226
src/components/production/WorkerScreen/BendingBarashiContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
src/components/production/WorkerScreen/BendingBarashiModal.tsx
Normal file
171
src/components/production/WorkerScreen/BendingBarashiModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user