diff --git a/src/components/production/WorkOrders/documents/BendingWorkLogContent.tsx b/src/components/production/WorkOrders/documents/BendingWorkLogContent.tsx index 31193d45..e86cba61 100644 --- a/src/components/production/WorkOrders/documents/BendingWorkLogContent.tsx +++ b/src/components/production/WorkOrders/documents/BendingWorkLogContent.tsx @@ -147,7 +147,7 @@ export function BendingWorkLogContent({ data: order }: BendingWorkLogContentProp - - {order.lotNo} - - + {item.specification || '-'} {item.quantity} )) diff --git a/src/components/production/WorkOrders/documents/ScreenWorkLogContent.tsx b/src/components/production/WorkOrders/documents/ScreenWorkLogContent.tsx index 7bacb231..7ec098ac 100644 --- a/src/components/production/WorkOrders/documents/ScreenWorkLogContent.tsx +++ b/src/components/production/WorkOrders/documents/ScreenWorkLogContent.tsx @@ -3,24 +3,108 @@ /** * 스크린 작업일지 문서 콘텐츠 * - * 기획서 스크린샷 기준 구성: - * - 헤더: "작업일지 (스크린)" + 문서번호/작성일자 + 결재란(작성/승인/승인/승인) + * 기존 시스템(5130/output/viewScreenWork.php) 기준 구성: + * - 헤더: "작업일지 (스크린)" + 문서번호/작성일자 + 결재란(작성/검토/승인) * - 신청업체(수주일,수주처,담당자,연락처) / 신청내용(현장명,작업일자,제품LOT NO,생산담당자,출고예정일) * - 작업내역 테이블: No, 입고 LOT NO, 제품명, 부호, 제작사이즈(가로/세로), 나머지 높이, - * 규격(매수)(1220/900/600/400/300), 제작, 재단 사항, 잔량, 완료 + * 규격(매수) 6열({기준폭}/900/800/600/400/300) * - 합계 * - 내화실 입고 LOT NO * - 비고 + * + * 계산 로직 (5130/output/common/function.php calculateCutSize): + * 1. 기준폭: 실리카=1220, 와이어=1180, 화이바=1200 + * 2. 시접: makeVertical = height + 140 + floor(height/기준폭) * 40 + * 3. 절단매수(firstCut) = floor(makeVertical / 기준폭) + * 4. 나머지높이 = makeVertical - (firstCut * 기준폭) + * 5. 나머지 > 임계치 → firstCut++ + * 6. 나머지높이로 규격 분류 (범위별 0 or 1) */ -import type { WorkOrder } from '../types'; +import type { WorkOrder, WorkOrderItem } from '../types'; import { SectionHeader, ConstructionApprovalTable } from '@/components/document-system'; +// ===== 절단 계산 로직 (기존 시스템 calculateCutSize 이식) ===== + +type FabricType = '실리카' | '와이어' | '화이바'; + +interface FabricConfig { + width: number; // 기준폭 (mm) + threshold: number; // 추가 절단 임계치 + ranges: Record; // 규격별 분류 범위 [min, max] +} + +const FABRIC_CONFIG: Record = { + '실리카': { + width: 1220, threshold: 940, + ranges: { '900': [841, 940], '800': [641, 840], '600': [441, 640], '400': [341, 440], '300': [1, 340] }, + }, + '와이어': { + width: 1180, threshold: 900, + ranges: { '900': [801, 900], '800': [601, 800], '600': [401, 600], '400': [301, 400], '300': [1, 300] }, + }, + '화이바': { + width: 1200, threshold: 924, + ranges: { '900': [825, 924], '800': [625, 824], '600': [425, 624], '400': [325, 424], '300': [1, 324] }, + }, +}; + +interface CutResult { + firstCut: number; // 기준폭 매수 + remaining: number; // 나머지 높이 + sizes: Record; // { '900': 0|1, '800': 0|1, ... } +} + +function detectFabricType(productName: string): FabricType { + if (productName.includes('실리')) return '실리카'; + if (productName.includes('화이')) return '화이바'; + return '와이어'; // 기본값 +} + +function calculateCutSize(fabricType: FabricType, height: number): CutResult { + const cfg = FABRIC_CONFIG[fabricType]; + const { width, threshold, ranges } = cfg; + + // 시접 계산 + const makeVertical = height + 140 + Math.floor(height / width) * 40; + + // 기본 절단 횟수 + let firstCut = Math.floor(makeVertical / width); + + // 나머지 높이 + const remaining = makeVertical - (firstCut * width); + + // 임계치 초과 시 추가 절단 + if (remaining > threshold) { + firstCut++; + } + + // 규격별 분류 + const sizes: Record = {}; + for (const [key, [min, max]] of Object.entries(ranges)) { + sizes[key] = (remaining >= min && remaining <= max) ? 1 : 0; + } + + return { firstCut, remaining, sizes }; +} + +// ===== 컴포넌트 ===== + +interface MaterialInputLot { + lot_no: string; + item_code: string; + item_name: string; + total_qty: number; + input_count: number; + first_input_at: string; +} + interface ScreenWorkLogContentProps { data: WorkOrder; + materialLots?: MaterialInputLot[]; } -export function ScreenWorkLogContent({ data: order }: ScreenWorkLogContentProps) { +export function ScreenWorkLogContent({ data: order, materialLots = [] }: ScreenWorkLogContentProps) { const today = new Date().toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', @@ -37,6 +121,16 @@ export function ScreenWorkLogContent({ data: order }: ScreenWorkLogContentProps) const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-'; const items = order.items || []; + // 숫자 천단위 콤마 포맷 + const fmt = (v?: number) => v != null ? v.toLocaleString() : '-'; + + // floorCode에서 부호 추출: "1층/FSS-01" → "FSS-01" + const getSymbolCode = (floorCode?: string) => { + if (!floorCode || floorCode === '-') return '-'; + const parts = floorCode.split('/'); + return parts.length > 1 ? parts.slice(1).join('/') : floorCode; + }; + const formattedDueDate = order.dueDate !== '-' ? new Date(order.dueDate).toLocaleDateString('ko-KR', { year: 'numeric', @@ -45,22 +139,60 @@ export function ScreenWorkLogContent({ data: order }: ScreenWorkLogContentProps) }).replace(/\. /g, '-').replace('.', '') : '-'; - // 규격 사이즈 컬럼 - const SCREEN_SIZES = ['1220', '900', '600', '400', '300']; + // 원단 유형 판별 (첫 번째 아이템 기준) + const fabricType = items.length > 0 ? detectFabricType(items[0].productName) : '와이어'; + const baseWidth = FABRIC_CONFIG[fabricType].width; + + // 규격 사이즈 헤더 (기준폭 + 900/800/600/400/300) + const SPEC_SIZES = [String(baseWidth), '900', '800', '600', '400', '300']; + + // 각 아이템별 절단 계산 + const itemCuts = items.map((item: WorkOrderItem) => { + if (!item.height || item.height <= 0) return null; + const ft = detectFabricType(item.productName); + return calculateCutSize(ft, item.height); + }); + + // 합계 계산 + const totals = SPEC_SIZES.reduce>((acc, size) => { + acc[size] = 0; + return acc; + }, {}); + + itemCuts.forEach((cut) => { + if (!cut) return; + totals[String(baseWidth)] += cut.firstCut; + for (const key of ['900', '800', '600', '400', '300']) { + totals[key] += cut.sizes[key] || 0; + } + }); + + // 투입 LOT 번호 (중복 제거) + const lotNoList = materialLots.map(lot => lot.lot_no).filter(Boolean); + const lotNoDisplay = lotNoList.length > 0 ? lotNoList.join(', ') : ''; + + // 투입 자재 LOT 그룹핑 (item_name별) — 하단 자재별 LOT 섹션용 + const materialLotGroupMap = new Map(); + materialLots.forEach(lot => { + const name = lot.item_name || '기타'; + if (!materialLotGroupMap.has(name)) materialLotGroupMap.set(name, []); + materialLotGroupMap.get(name)!.push(lot.lot_no); + }); + const materialLotGroups = Array.from(materialLotGroupMap.entries()).map(([name, lots]) => ({ + itemName: name, + lotNos: lots.filter(Boolean).join(', '), + })); return (
{/* ===== 헤더 영역 ===== */}
- {/* 좌측: 제목 + 문서번호 */}

작업일지 (스크린)

문서번호: {documentNo} | 작성일자: {fullDate}

- - {/* 우측: 결재란 */} 작업내역 - +
+ {/* No. | 입고 LOT NO | 제품명 | 부호 | 가로 | 세로 | 나머지높이 | 규격별 */} + {SPEC_SIZES.map(s => )} - - - - - - - - - - + + + + + + + - - - {SCREEN_SIZES.map(size => ( - + + + {SPEC_SIZES.map(size => ( + ))} {items.length > 0 ? ( - items.map((item, idx) => ( - - - - - - - - - {SCREEN_SIZES.map(size => ( - - ))} - - - - - )) + items.map((item, idx) => { + const cut = itemCuts[idx]; + return ( + + + + + + + + + + {['900', '800', '600', '400', '300'].map(size => ( + + ))} + + ); + }) ) : ( - )} {/* 합계 행 */} - - - - - - {SCREEN_SIZES.map(size => ( - + + + {SPEC_SIZES.map(size => ( + ))} - - -
No.입고 LOT
NO
제품명부호제작사이즈나머지
높이
규격 (매수)제작
형태
제단
사항
작업
완료
No.입고 LOT
NO
제품명부호제작사이즈(mm)나머지
높이
규격 (매수)
가로세로{size}가로세로{size}
{idx + 1}{order.lotNo}{item.productName}{item.floorCode}-------
{idx + 1}{lotNoDisplay}{item.productName}{getSymbolCode(item.floorCode)}{fmt(item.width)}{fmt(item.height)}{cut && cut.remaining > 0 ? cut.remaining : ''}{cut && cut.firstCut > 0 ? cut.firstCut : ''} + {cut && cut.sizes[size] > 0 ? cut.sizes[size] : ''} +
+ 등록된 품목이 없습니다.
합계----
합계 + {totals[size] > 0 ? totals[size] : ''} + ---
- {/* ===== 내화실 입고 LOT NO ===== */} + {/* ===== 투입 자재 입고 LOT NO (자재별 동적 행) ===== */} - - - - + {materialLotGroups.length > 0 ? ( + materialLotGroups.map((group, idx) => ( + + + + + )) + ) : ( + + + + + )}
내화실 입고 LOT NO 
+ {group.itemName} 입고 LOT NO + + {group.lotNos || '\u00A0'} +
+ 자재 입고 LOT NO + {'\u00A0'}
@@ -190,7 +335,7 @@ export function ScreenWorkLogContent({ data: order }: ScreenWorkLogContentProps) 비고 - {order.note || ''} + {order.note || '사이즈 착오 없이 부탁드립니다.'} diff --git a/src/components/production/WorkOrders/documents/SlatWorkLogContent.tsx b/src/components/production/WorkOrders/documents/SlatWorkLogContent.tsx index 5b3adf17..9b2833fd 100644 --- a/src/components/production/WorkOrders/documents/SlatWorkLogContent.tsx +++ b/src/components/production/WorkOrders/documents/SlatWorkLogContent.tsx @@ -15,11 +15,21 @@ import type { WorkOrder } from '../types'; import { SectionHeader } from '@/components/document-system'; -interface SlatWorkLogContentProps { - data: WorkOrder; +interface MaterialInputLot { + lot_no: string; + item_code: string; + item_name: string; + total_qty: number; + input_count: number; + first_input_at: string; } -export function SlatWorkLogContent({ data: order }: SlatWorkLogContentProps) { +interface SlatWorkLogContentProps { + data: WorkOrder; + materialLots?: MaterialInputLot[]; +} + +export function SlatWorkLogContent({ data: order, materialLots = [] }: SlatWorkLogContentProps) { const today = new Date().toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', @@ -36,6 +46,16 @@ export function SlatWorkLogContent({ data: order }: SlatWorkLogContentProps) { const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-'; const items = order.items || []; + // 숫자 천단위 콤마 포맷 + const fmt = (v?: number) => v != null ? v.toLocaleString() : '-'; + + // floorCode에서 부호 추출: "1층/FSS-01" → "FSS-01" + const getSymbolCode = (floorCode?: string) => { + if (!floorCode || floorCode === '-') return '-'; + const parts = floorCode.split('/'); + return parts.length > 1 ? parts.slice(1).join('/') : floorCode; + }; + const formattedDueDate = order.dueDate !== '-' ? new Date(order.dueDate).toLocaleDateString('ko-KR', { year: 'numeric', @@ -44,6 +64,10 @@ export function SlatWorkLogContent({ data: order }: SlatWorkLogContentProps) { }).replace(/\. /g, '-').replace('.', '') : '-'; + // 투입 LOT 번호 (중복 제거) + const lotNoList = materialLots.map(lot => lot.lot_no).filter(Boolean); + const lotNoDisplay = lotNoList.length > 0 ? lotNoList.join(', ') : ''; + return (
{/* ===== 헤더 영역 ===== */} @@ -128,35 +152,35 @@ export function SlatWorkLogContent({ data: order }: SlatWorkLogContentProps) { - - - - - - - - + + + + + + + + - - - + + + {items.length > 0 ? ( items.map((item, idx) => ( - - - - - - - - - - + + + + + + + + + + )) ) : ( diff --git a/src/components/production/WorkerScreen/WorkLogModal.tsx b/src/components/production/WorkerScreen/WorkLogModal.tsx index 9f45474c..02fb10bc 100644 --- a/src/components/production/WorkerScreen/WorkLogModal.tsx +++ b/src/components/production/WorkerScreen/WorkLogModal.tsx @@ -5,15 +5,17 @@ * * document-system 통합 버전 (2026-01-22) * 공정별 작업일지 지원 (2026-01-29) + * 공정관리 양식 매핑 연동 (2026-02-11) * - DocumentViewer 사용 - * - 공정 타입에 따라 스크린/슬랫/절곡 작업일지 분기 - * - processType 미지정 시 기존 WorkLogContent (범용) 사용 + * - 공정관리에서 매핑된 workLogTemplateId/Name 기반으로 콘텐츠 분기 + * - 양식 미매핑 시 processType 폴백 */ import { useState, useEffect } from 'react'; import { Loader2 } from 'lucide-react'; import { DocumentViewer } from '@/components/document-system'; -import { getWorkOrderById } from '../WorkOrders/actions'; +import { getWorkOrderById, getMaterialInputLots } from '../WorkOrders/actions'; +import type { MaterialInputLot } from '../WorkOrders/actions'; import type { WorkOrder, ProcessType } from '../WorkOrders/types'; import { WorkLogContent } from './WorkLogContent'; import { @@ -27,10 +29,34 @@ interface WorkLogModalProps { onOpenChange: (open: boolean) => void; workOrderId: string | null; processType?: ProcessType; + /** 공정관리에서 매핑된 작업일지 양식 ID */ + workLogTemplateId?: number; + /** 공정관리에서 매핑된 작업일지 양식명 (예: '스크린 작업일지') */ + workLogTemplateName?: string; } -export function WorkLogModal({ open, onOpenChange, workOrderId, processType }: WorkLogModalProps) { +/** + * 양식명 → 공정 타입 매핑 + * 공정관리에서 매핑된 양식명을 기반으로 콘텐츠 컴포넌트를 결정 + */ +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(null); + const [materialLots, setMaterialLots] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -82,12 +108,18 @@ export function WorkLogModal({ open, onOpenChange, workOrderId, processType }: W setIsLoading(true); setError(null); - getWorkOrderById(workOrderId) - .then((result) => { - if (result.success && result.data) { - setOrder(result.data); + Promise.all([ + getWorkOrderById(workOrderId), + getMaterialInputLots(workOrderId), + ]) + .then(([orderResult, lotsResult]) => { + if (orderResult.success && orderResult.data) { + setOrder(orderResult.data); } else { - setError(result.error || '데이터를 불러올 수 없습니다.'); + setError(orderResult.error || '데이터를 불러올 수 없습니다.'); + } + if (lotsResult.success) { + setMaterialLots(lotsResult.data); } }) .catch(() => { @@ -99,6 +131,7 @@ export function WorkLogModal({ open, onOpenChange, workOrderId, processType }: W } else if (!open) { // 모달 닫힐 때 상태 초기화 setOrder(null); + setMaterialLots([]); setError(null); } }, [open, workOrderId, processType]); @@ -108,18 +141,38 @@ export function WorkLogModal({ open, onOpenChange, workOrderId, processType }: W // 로딩/에러 상태는 DocumentViewer 내부에서 처리 const subtitle = order ? `${order.processName} 생산부서` : undefined; - // 공정 타입에 따라 콘텐츠 분기 + // 양식 미매핑 안내 + const renderNoTemplate = () => ( +
+

+ 이 공정에 작업일지 양식이 매핑되지 않았습니다. +

+

+ 공정관리에서 작업일지 양식을 설정해주세요. +

+
+ ); + + // 공정관리 양식 매핑 기반 콘텐츠 분기 const renderContent = () => { if (!order) return null; - // processType prop 또는 order의 processType 사용 - const type = processType || order.processType; + // 1순위: 공정관리에서 매핑된 양식명으로 결정 + const templateType = resolveProcessTypeFromTemplate(workLogTemplateName); + + // 2순위: processType 폴백 (양식 미매핑 시) + const type = templateType || processType || order.processType; + + // 양식이 매핑되어 있지 않은 경우 안내 + if (!workLogTemplateId && !processType) { + return renderNoTemplate(); + } switch (type) { case 'screen': - return ; + return ; case 'slat': - return ; + return ; case 'bending': return ; default: @@ -127,9 +180,12 @@ export function WorkLogModal({ open, onOpenChange, workOrderId, processType }: W } }; + // 양식명으로 문서 제목 결정 + const documentTitle = workLogTemplateName || '작업일지'; + return (
No.입고 LOT
NO
방화유리
수량
제품명제작사이즈(mm) - 미미제외조인트바
수량
코일
사용량
설치홈/
부호
No.입고 LOT
NO
방화유리
수량
제품명제작사이즈(mm) - 미미제외조인트바
수량
코일
사용량
설치홈/
부호
가로세로매수
(세로)
가로세로매수
(세로)
{idx + 1}{order.lotNo}-{item.productName}------{idx + 1}{lotNoDisplay}-{item.productName}{fmt(item.width)}{fmt(item.height)}---{getSymbolCode(item.floorCode)}