diff --git a/.serena/project.yml b/.serena/project.yml index 76ccac8e..3d4296af 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -107,3 +107,10 @@ fixed_tools: [] # override of the corresponding setting in serena_config.yml, see the documentation there. # If null or missing, the value from the global config is used. symbol_info_budget: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: diff --git a/src/components/approval/ApprovalBox/actions.ts b/src/components/approval/ApprovalBox/actions.ts index 17b2a1a8..711a4944 100644 --- a/src/components/approval/ApprovalBox/actions.ts +++ b/src/components/approval/ApprovalBox/actions.ts @@ -70,7 +70,7 @@ function mapTabToApiStatus(tabStatus: string): string | undefined { function mapApprovalType(formCategory?: string): ApprovalType { const typeMap: Record = { - 'expense_report': 'expense_report', 'proposal': 'proposal', 'expense_estimate': 'expense_estimate', + 'expense_report': 'expense_report', 'proposal': 'proposal', 'expense_estimate': 'expense_estimate', '문서': 'document', 'document': 'document', }; return typeMap[formCategory || ''] || 'proposal'; } @@ -176,6 +176,127 @@ export async function approveDocumentsBulk(ids: string[], comment?: string): Pro return { success: true }; } +// ============================================ +// 연결 문서(Document) 조회 +// ============================================ + +interface LinkedDocumentApiData { + id: number; + document_number: string; + title: string; + status: string; + drafter?: { + id: number; name: string; position?: string; + department?: { name: string }; + tenant_profile?: { position_key?: string; department?: { name: string } }; + }; + steps?: InboxStepApiData[]; + linkable?: { + id: number; + title: string; + document_no: string; + status: string; + created_at: string; + linkable_type?: string; + linkable_id?: number; + template?: { id: number; name: string; code: string }; + data?: Array<{ id: number; field_key: string; field_label?: string; field_value?: unknown; value?: unknown }>; + approvals?: Array<{ id: number; step: number; status: string; acted_at?: string; user?: { id: number; name: string } }>; + attachments?: Array<{ id: number; display_name: string; file_path: string }>; + }; +} + +interface LinkedDocumentResult { + documentNo: string; + createdAt: string; + title: string; + templateName: string; + templateCode: string; + status: string; + workOrderId?: number; + documentData: Array<{ fieldKey: string; fieldLabel: string; value: unknown }>; + approvers: Array<{ id: string; name: string; position: string; department: string; status: 'pending' | 'approved' | 'rejected' | 'none' }>; + drafter: { id: string; name: string; position: string; department: string; status: 'approved' | 'pending' | 'rejected' | 'none' }; + attachments?: Array<{ id: number; name: string; url: string }>; +} + +function getPositionLabel(positionKey: string | null | undefined): string { + if (!positionKey) return ''; + const labels: Record = { + 'EXECUTIVE': '임원', 'DIRECTOR': '부장', 'MANAGER': '과장', + 'SENIOR': '대리', 'STAFF': '사원', 'INTERN': '인턴', + }; + return labels[positionKey] ?? positionKey; +} + +export async function getDocumentApprovalById(id: number): Promise<{ + success: boolean; + data?: LinkedDocumentResult; + error?: string; +}> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await executeServerAction({ + url: buildApiUrl(`/api/v1/approvals/${id}`), + errorMessage: '문서 조회에 실패했습니다.', + }); + if (!result.success || !result.data) return { success: false, error: result.error }; + + const apiData = result.data as LinkedDocumentApiData; + const linkable = apiData.linkable; + + const drafter = { + id: String(apiData.drafter?.id || ''), + name: apiData.drafter?.name || '', + position: apiData.drafter?.tenant_profile?.position_key + ? getPositionLabel(apiData.drafter.tenant_profile.position_key) + : (apiData.drafter?.position || ''), + department: apiData.drafter?.tenant_profile?.department?.name + || apiData.drafter?.department?.name || '', + status: 'approved' as const, + }; + + const approvers = (apiData.steps || []) + .filter(s => s.step_type === 'approval' || s.step_type === 'agreement') + .map(step => ({ + id: String(step.approver?.id || step.approver_id), + name: step.approver?.name || '', + position: step.approver?.position || '', + department: step.approver?.department?.name || '', + status: (step.status === 'approved' ? 'approved' + : step.status === 'rejected' ? 'rejected' + : step.status === 'pending' ? 'pending' + : 'none') as 'pending' | 'approved' | 'rejected' | 'none', + })); + + // work_order 연결 문서인 경우 workOrderId 추출 + const workOrderId = linkable?.linkable_type === 'work_order' ? linkable.linkable_id : undefined; + + return { + success: true, + data: { + documentNo: linkable?.document_no || apiData.document_number, + createdAt: linkable?.created_at || '', + title: linkable?.title || apiData.title, + templateName: linkable?.template?.name || '', + templateCode: linkable?.template?.code || '', + status: linkable?.status || apiData.status, + workOrderId, + documentData: (linkable?.data || []).map(d => ({ + fieldKey: d.field_key, + fieldLabel: d.field_label || d.field_key, + value: d.field_value ?? d.value, + })), + approvers, + drafter, + attachments: (linkable?.attachments || []).map(a => ({ + id: a.id, + name: a.display_name, + url: `/api/proxy/files/${a.id}/download`, + })), + }, + }; +} + export async function rejectDocumentsBulk(ids: string[], comment: string): Promise<{ success: boolean; failedIds?: string[]; error?: string }> { if (!comment?.trim()) return { success: false, error: '반려 사유를 입력해주세요.' }; const failedIds: string[] = []; diff --git a/src/components/approval/ApprovalBox/index.tsx b/src/components/approval/ApprovalBox/index.tsx index 98b5d8af..4139f0ab 100644 --- a/src/components/approval/ApprovalBox/index.tsx +++ b/src/components/approval/ApprovalBox/index.tsx @@ -19,6 +19,7 @@ import { rejectDocument, approveDocumentsBulk, rejectDocumentsBulk, + getDocumentApprovalById, } from './actions'; import { getApprovalById } from '@/components/approval/DocumentCreate/actions'; import { Button } from '@/components/ui/button'; @@ -58,6 +59,7 @@ import type { ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData, + LinkedDocumentData, } from '@/components/approval/DocumentDetail/types'; import type { ApprovalTabType, @@ -76,6 +78,7 @@ import { } from './types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { usePermission } from '@/hooks/usePermission'; +import { InspectionReportModal } from '@/components/production/WorkOrders/documents/InspectionReportModal'; // ===== 통계 타입 ===== interface InboxSummary { @@ -111,9 +114,13 @@ export function ApprovalBox() { // ===== 문서 상세 모달 상태 ===== const [isModalOpen, setIsModalOpen] = useState(false); const [selectedDocument, setSelectedDocument] = useState(null); - const [modalData, setModalData] = useState(null); + const [modalData, setModalData] = useState(null); const [isModalLoading, setIsModalLoading] = useState(false); + // ===== 검사성적서 모달 상태 (work_order 연결 문서용) ===== + const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false); + const [inspectionWorkOrderId, setInspectionWorkOrderId] = useState(null); + // API 데이터 const [data, setData] = useState([]); const [totalCount, setTotalCount] = useState(0); @@ -288,6 +295,27 @@ export function ApprovalBox() { setIsModalOpen(true); try { + // 문서 결재(document) 타입은 별도 API로 연결 문서 데이터 조회 + if (item.approvalType === 'document') { + const result = await getDocumentApprovalById(parseInt(item.id)); + if (result.success && result.data) { + // work_order 연결 문서 → InspectionReportModal로 열기 + if (result.data.workOrderId) { + setIsModalOpen(false); + setIsModalLoading(false); + setInspectionWorkOrderId(String(result.data.workOrderId)); + setIsInspectionModalOpen(true); + return; + } + setModalData(result.data as LinkedDocumentData); + } else { + toast.error(result.error || '문서 조회에 실패했습니다.'); + setIsModalOpen(false); + } + return; + } + + // 기존 결재 문서 타입 (품의서, 지출결의서, 지출예상내역서) const result = await getApprovalById(parseInt(item.id)); if (result.success && result.data) { const formData = result.data; @@ -439,6 +467,8 @@ export function ApprovalBox() { return 'expenseEstimate'; case 'expense_report': return 'expenseReport'; + case 'document': + return 'document'; default: return 'proposal'; } @@ -796,6 +826,19 @@ export function ApprovalBox() { onReject={canApprove ? handleModalReject : undefined} /> )} + + {/* 검사성적서 모달 (work_order 연결 문서) */} + { + setIsInspectionModalOpen(open); + if (!open) { + setInspectionWorkOrderId(null); + } + }} + workOrderId={inspectionWorkOrderId} + readOnly={true} + /> ), }), @@ -827,6 +870,8 @@ export function ApprovalBox() { handleModalApprove, handleModalReject, canApprove, + isInspectionModalOpen, + inspectionWorkOrderId, ] ); diff --git a/src/components/approval/ApprovalBox/types.ts b/src/components/approval/ApprovalBox/types.ts index c0c545c4..ca67d7ba 100644 --- a/src/components/approval/ApprovalBox/types.ts +++ b/src/components/approval/ApprovalBox/types.ts @@ -9,17 +9,18 @@ export type ApprovalTabType = 'all' | 'pending' | 'approved' | 'rejected'; // 결재 상태 export type ApprovalStatus = 'pending' | 'approved' | 'rejected'; -// 결재 유형 (문서 종류): 지출결의서, 품의서, 지출예상내역서 -export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate'; +// 결재 유형 (문서 종류): 지출결의서, 품의서, 지출예상내역서, 문서결재 +export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate' | 'document'; // 필터 옵션 -export type FilterOption = 'all' | 'expense_report' | 'proposal' | 'expense_estimate'; +export type FilterOption = 'all' | 'expense_report' | 'proposal' | 'expense_estimate' | 'document'; export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [ { value: 'all', label: '전체' }, { value: 'expense_report', label: '지출결의서' }, { value: 'proposal', label: '품의서' }, { value: 'expense_estimate', label: '지출예상내역서' }, + { value: 'document', label: '문서 결재' }, ]; // 정렬 옵션 @@ -71,12 +72,14 @@ export const APPROVAL_TYPE_LABELS: Record = { expense_report: '지출결의서', proposal: '품의서', expense_estimate: '지출예상내역서', + document: '문서 결재', }; export const APPROVAL_TYPE_COLORS: Record = { expense_report: 'blue', proposal: 'green', expense_estimate: 'purple', + document: 'orange', }; export const APPROVAL_STATUS_LABELS: Record = { diff --git a/src/components/approval/DocumentDetail/DocumentDetailModalV2.tsx b/src/components/approval/DocumentDetail/DocumentDetailModalV2.tsx index 6c1c2a66..ae49943e 100644 --- a/src/components/approval/DocumentDetail/DocumentDetailModalV2.tsx +++ b/src/components/approval/DocumentDetail/DocumentDetailModalV2.tsx @@ -4,12 +4,14 @@ import { DocumentViewer } from '@/components/document-system'; import { ProposalDocument } from './ProposalDocument'; import { ExpenseReportDocument } from './ExpenseReportDocument'; import { ExpenseEstimateDocument } from './ExpenseEstimateDocument'; +import { LinkedDocumentContent } from './LinkedDocumentContent'; import type { DocumentType, DocumentDetailModalProps, ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData, + LinkedDocumentData, } from './types'; /** @@ -41,6 +43,8 @@ export function DocumentDetailModalV2({ return '지출결의서'; case 'expenseEstimate': return '지출 예상 내역서'; + case 'document': + return (data as LinkedDocumentData).templateName || '문서 결재'; default: return '문서'; } @@ -69,6 +73,8 @@ export function DocumentDetailModalV2({ return ; case 'expenseEstimate': return ; + case 'document': + return ; default: return null; } diff --git a/src/components/approval/DocumentDetail/LinkedDocumentContent.tsx b/src/components/approval/DocumentDetail/LinkedDocumentContent.tsx new file mode 100644 index 00000000..0e89e1cc --- /dev/null +++ b/src/components/approval/DocumentDetail/LinkedDocumentContent.tsx @@ -0,0 +1,133 @@ +'use client'; + +/** + * 연결 문서 콘텐츠 컴포넌트 + * + * 문서관리에서 생성된 검사 성적서, 작업일지 등을 + * 결재함 모달에서 렌더링합니다. + */ + +import { ApprovalLineBox } from './ApprovalLineBox'; +import type { LinkedDocumentData } from './types'; +import { DocumentHeader } from '@/components/document-system'; +import { Badge } from '@/components/ui/badge'; +import { FileText, Paperclip } from 'lucide-react'; + +interface LinkedDocumentContentProps { + data: LinkedDocumentData; +} + +const STATUS_LABELS: Record = { + DRAFT: '임시저장', + PENDING: '진행중', + APPROVED: '승인완료', + REJECTED: '반려', + CANCELLED: '회수', +}; + +const STATUS_COLORS: Record = { + DRAFT: 'bg-gray-100 text-gray-800', + PENDING: 'bg-yellow-100 text-yellow-800', + APPROVED: 'bg-green-100 text-green-800', + REJECTED: 'bg-red-100 text-red-800', + CANCELLED: 'bg-gray-100 text-gray-600', +}; + +export function LinkedDocumentContent({ data }: LinkedDocumentContentProps) { + return ( +
+ {/* 문서 헤더 (공통 컴포넌트) */} + } + /> + + {/* 문서 기본 정보 */} +
+
+
+ 문서 제목 +
+
{data.title || '-'}
+
+
+
+
+ 양식 +
+
+ + {data.templateName || '-'} +
+
+
+
+ 상태 +
+
+ + {STATUS_LABELS[data.status] || data.status} + +
+
+
+
+ + {/* 문서 데이터 */} + {data.documentData.length > 0 && ( +
+
+ 문서 내용 +
+
+ {data.documentData.map((field, index) => ( +
+
+ {field.fieldLabel} +
+
+ {renderFieldValue(field.value)} +
+
+ ))} +
+
+ )} + + {/* 첨부파일 */} + {data.attachments && data.attachments.length > 0 && ( +
+
+ 첨부파일 +
+
+ {data.attachments.map((file) => ( + + + {file.name} + + ))} +
+
+ )} +
+ ); +} + +function renderFieldValue(value: unknown): string { + if (value === null || value === undefined) return '-'; + if (typeof value === 'string') return value || '-'; + if (typeof value === 'number') return String(value); + if (typeof value === 'boolean') return value ? '예' : '아니오'; + if (Array.isArray(value)) return value.join(', ') || '-'; + if (typeof value === 'object') return JSON.stringify(value, null, 2); + return String(value); +} diff --git a/src/components/approval/DocumentDetail/types.ts b/src/components/approval/DocumentDetail/types.ts index 1ff24588..7a7b2cb7 100644 --- a/src/components/approval/DocumentDetail/types.ts +++ b/src/components/approval/DocumentDetail/types.ts @@ -1,6 +1,6 @@ // ===== 문서 상세 모달 타입 정의 ===== -export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate'; +export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate' | 'document'; // 결재자 정보 export interface Approver { @@ -72,6 +72,29 @@ export interface ExpenseEstimateDocumentData { drafter: Approver; } +// 연결 문서 데이터 (검사 성적서, 작업일지 등 문서관리에서 생성된 문서) +export interface LinkedDocumentData { + documentNo: string; + createdAt: string; + title: string; + templateName: string; + templateCode: string; + status: string; + workOrderId?: number; + documentData: Array<{ + fieldKey: string; + fieldLabel: string; + value: unknown; + }>; + approvers: Approver[]; + drafter: Approver; + attachments?: Array<{ + id: number; + name: string; + url: string; + }>; +} + // 문서 상세 모달 모드 export type DocumentDetailMode = 'draft' | 'inbox' | 'reference'; @@ -83,7 +106,7 @@ export interface DocumentDetailModalProps { open: boolean; onOpenChange: (open: boolean) => void; documentType: DocumentType; - data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData; + data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData; mode?: DocumentDetailMode; // 'draft': 기안함 (상신), 'inbox': 결재함 (승인/반려) documentStatus?: DocumentStatus; // 문서 상태 (임시저장일 때만 수정/상신 가능) onEdit?: () => void; diff --git a/src/components/production/ProductionDashboard/actions.ts b/src/components/production/ProductionDashboard/actions.ts index 1003f69e..dac5d310 100644 --- a/src/components/production/ProductionDashboard/actions.ts +++ b/src/components/production/ProductionDashboard/actions.ts @@ -23,10 +23,11 @@ interface WorkOrderApiItem { created_at: string; sales_order?: { id: number; order_no: string; client_id?: number; client_name?: string; + item?: { id: number; code: string; name: string } | null; client?: { id: number; name: string }; root_nodes_count?: number; }; assignee?: { id: number; name: string }; - items?: { id: number; item_name: string; quantity: number }[]; + items?: { id: number; item_name: string; item_id?: number | null; item?: { id: number; code: string; name: string } | null; quantity: number; options?: Record | null }[]; } // ===== 상태 변환 ===== @@ -41,7 +42,8 @@ function mapApiStatus(status: WorkOrderApiItem['status']): 'waiting' | 'inProgre // ===== API → WorkOrder 변환 ===== function transformToProductionFormat(api: WorkOrderApiItem): WorkOrder { const totalQuantity = (api.items || []).reduce((sum, item) => sum + Number(item.quantity), 0); - const productName = api.items?.[0]?.item_name || '-'; + const productCode = (api.items?.[0]?.options?.product_code as string) || api.sales_order?.item?.code || '-'; + const productName = (api.items?.[0]?.options?.product_name as string) || api.items?.[0]?.item_name || '-'; const dueDate = api.scheduled_date || ''; const today = new Date(); today.setHours(0, 0, 0, 0); @@ -57,6 +59,7 @@ function transformToProductionFormat(api: WorkOrderApiItem): WorkOrder { return { id: String(api.id), orderNo: api.work_order_no, + productCode, productName, processCode: api.process?.process_code || '-', processName: api.process?.process_name || '-', diff --git a/src/components/production/ProductionDashboard/types.ts b/src/components/production/ProductionDashboard/types.ts index bf3abc27..1fa899b0 100644 --- a/src/components/production/ProductionDashboard/types.ts +++ b/src/components/production/ProductionDashboard/types.ts @@ -14,6 +14,7 @@ export interface ProcessOption { export interface WorkOrder { id: string; orderNo: string; // KD-WO-251216-01 + productCode: string; // 제품코드 (KQTS01 등) productName: string; // 스크린 서터 (표준형) - 추가 processCode: string; // 공정 코드 (P-001, P-002, ...) processName: string; // 공정명 (슬랫, 스크린, 절곡, ...) diff --git a/src/components/production/WorkOrders/actions.ts b/src/components/production/WorkOrders/actions.ts index 732e0606..a9f67af5 100644 --- a/src/components/production/WorkOrders/actions.ts +++ b/src/components/production/WorkOrders/actions.ts @@ -773,6 +773,53 @@ export async function saveInspectionData( } } +// ===== 검사 설정 조회 (공정 판별 + 구성품 목록) ===== +export interface InspectionConfigGapPoint { + point: string; + design_value: string; +} + +export interface InspectionConfigItem { + id: string; + name: string; + gap_points: InspectionConfigGapPoint[]; +} + +export interface InspectionConfigData { + work_order_id: number; + process_type: string; + product_code: string | null; + finishing_type: string | null; + template_id: number | null; + items: InspectionConfigItem[]; +} + +export async function getInspectionConfig( + workOrderId: string | number +): Promise<{ success: boolean; data?: InspectionConfigData; error?: string }> { + try { + const { response, error } = await serverFetch( + buildApiUrl(`/api/v1/work-orders/${workOrderId}/inspection-config`), + { method: 'GET' } + ); + + if (error || !response) { + return { success: false, error: error?.message || 'API 요청 실패' }; + } + + const result = await response.json(); + if (!response.ok || !result.success) { + return { success: false, error: result.message || '검사 설정을 불러올 수 없습니다.' }; + } + + return { success: true, data: result.data }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[WorkOrderActions] getInspectionConfig error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +} + // ===== 검사 문서 템플릿 조회 (document_template 기반) ===== import type { InspectionTemplateData } from '@/components/production/WorkerScreen/types'; @@ -874,6 +921,33 @@ export async function resolveInspectionDocument( } } +// ===== 문서 결재 상신 ===== +export async function submitDocumentForApproval( + documentId: number +): Promise<{ success: boolean; error?: string }> { + try { + const { response, error } = await serverFetch( + buildApiUrl(`/api/v1/documents/${documentId}/submit`), + { method: 'POST' } + ); + + if (error || !response) { + return { success: false, error: error?.message || 'API 요청 실패' }; + } + + const result = await response.json(); + if (!response.ok || !result.success) { + return { success: false, error: result.message || '결재 상신에 실패했습니다.' }; + } + + return { success: true }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[WorkOrderActions] submitDocumentForApproval error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +} + // ===== 수주 목록 조회 (작업지시 생성용) ===== export interface SalesOrderForWorkOrder { id: number; diff --git a/src/components/production/WorkOrders/documents/InspectionReportModal.tsx b/src/components/production/WorkOrders/documents/InspectionReportModal.tsx index 67ea5df7..0d2672dd 100644 --- a/src/components/production/WorkOrders/documents/InspectionReportModal.tsx +++ b/src/components/production/WorkOrders/documents/InspectionReportModal.tsx @@ -12,7 +12,7 @@ */ import { useState, useEffect, useRef, useCallback } from 'react'; -import { Loader2, Save } from 'lucide-react'; +import { Loader2, Save, Send } from 'lucide-react'; import { DocumentViewer } from '@/components/document-system'; import { Button } from '@/components/ui/button'; import { toast } from 'sonner'; @@ -23,6 +23,7 @@ import { getInspectionTemplate, saveInspectionDocument, resolveInspectionDocument, + submitDocumentForApproval, } from '../actions'; import type { WorkOrder, ProcessType } from '../types'; import type { InspectionReportData, InspectionReportNodeGroup } from '../actions'; @@ -178,6 +179,10 @@ export function InspectionReportModal({ field_key: string; field_value: string | null; }> | null>(null); + // 기존 문서 ID/상태 (결재 상신용) + const [savedDocumentId, setSavedDocumentId] = useState(null); + const [savedDocumentStatus, setSavedDocumentStatus] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); // props에서 목업 제외한 실제 개소만 사용 (WorkerScreen에서 apiItems + mockItems가 합쳐져 전달됨) // ★ 반드시 workItems와 inspectionDataMap을 같은 소스에서 가져와야 key 포맷이 일치함 @@ -289,18 +294,23 @@ export function InspectionReportModal({ setSelfTemplateData(templateResult.data); } - // 4) 기존 문서의 document_data EAV 레코드 추출 + // 4) 기존 문서의 document_data EAV 레코드 + ID/상태 추출 if (resolveResult?.success && resolveResult.data) { const existingDoc = (resolveResult.data as Record).existing_document as - | { data?: Array<{ section_id: number | null; column_id: number | null; row_index: number; field_key: string; field_value: string | null }> } + | { id?: number; status?: string; data?: Array<{ section_id: number | null; column_id: number | null; row_index: number; field_key: string; field_value: string | null }> } | null; if (existingDoc?.data && existingDoc.data.length > 0) { setDocumentRecords(existingDoc.data); } else { setDocumentRecords(null); } + // 문서 ID/상태 저장 (결재 상신용) + setSavedDocumentId(existingDoc?.id ?? null); + setSavedDocumentStatus(existingDoc?.status ?? null); } else { setDocumentRecords(null); + setSavedDocumentId(null); + setSavedDocumentStatus(null); } }) .catch(() => { @@ -316,6 +326,8 @@ export function InspectionReportModal({ setReportSummary(null); setSelfTemplateData(null); setDocumentRecords(null); + setSavedDocumentId(null); + setSavedDocumentStatus(null); setError(null); } }, [open, workOrderId, processType, templateData]); @@ -350,6 +362,12 @@ export function InspectionReportModal({ }); if (result.success) { toast.success('검사 문서가 저장되었습니다.'); + // 저장 후 문서 ID/상태 갱신 (결재 상신 활성화용) + const docData = result.data as { id?: number; status?: string } | undefined; + if (docData?.id) { + setSavedDocumentId(docData.id); + setSavedDocumentStatus(docData.status ?? 'DRAFT'); + } } else { toast.error(result.error || '저장에 실패했습니다.'); } @@ -369,6 +387,27 @@ export function InspectionReportModal({ } }, [workOrderId, processType, activeTemplate, activeStepId]); + // 결재 상신 핸들러 + const handleSubmitForApproval = useCallback(async () => { + if (!savedDocumentId) return; + + setIsSubmitting(true); + try { + const result = await submitDocumentForApproval(savedDocumentId); + if (result.success) { + toast.success('결재 상신이 완료되었습니다.'); + setSavedDocumentStatus('PENDING'); + onOpenChange(false); + } else { + toast.error(result.error || '결재 상신에 실패했습니다.'); + } + } catch { + toast.error('결재 상신 중 오류가 발생했습니다.'); + } finally { + setIsSubmitting(false); + } + }, [savedDocumentId, onOpenChange]); + if (!workOrderId) return null; const processLabel = PROCESS_LABELS[processType] || '스크린'; @@ -426,15 +465,35 @@ export function InspectionReportModal({ } }; + // 결재 상신 가능 여부: 저장된 DRAFT 문서가 있을 때 + const canSubmitForApproval = savedDocumentId != null && savedDocumentStatus === 'DRAFT'; + const toolbarExtra = !readOnly ? ( - + {canSubmitForApproval && ( + )} - {isSaving ? '저장 중...' : '저장'} - + ) : undefined; // 검사 진행 상태 표시 (summary 있을 때) diff --git a/src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx b/src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx index 0aac454a..21f1e05d 100644 --- a/src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx +++ b/src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx @@ -36,6 +36,8 @@ import { } from './inspection-shared'; import { formatNumber } from '@/lib/utils/amount'; import type { BendingInfoExtended } from './bending/types'; +import { getInspectionConfig } from '../actions'; +import type { InspectionConfigData } from '../actions'; export type { InspectionContentRef }; @@ -375,10 +377,60 @@ export const TemplateInspectionContent = forwardRef(null); + + useEffect(() => { + if (!isBending || !order.id) return; + let cancelled = false; + getInspectionConfig(order.id).then(result => { + if (!cancelled && result.success && result.data) { + setInspectionConfig(result.data); + } + }); + return () => { cancelled = true; }; + }, [isBending, order.id]); + const bendingProducts = useMemo(() => { if (!isBending) return []; + + // API 응답이 있고 items가 있으면 API 기반 구성품 사용 + if (inspectionConfig?.items?.length) { + const productCode = inspectionConfig.product_code || ''; + // bending_info에서 dimension 보조 데이터 추출 + const bi = order.bendingInfo as BendingInfoExtended | undefined; + const wallLen = bi?.guideRail?.wall?.lengthData?.[0]?.length; + const sideLen = bi?.guideRail?.side?.lengthData?.[0]?.length; + + return inspectionConfig.items.map((item): BendingProduct => { + // API id → 표시용 매핑 (이름, 타입, 치수) + const displayMap: Record = { + guide_rail_wall: { name: '가이드레일', type: '벽면형', len: String(wallLen || 3500), wid: 'N/A' }, + guide_rail_side: { name: '가이드레일', type: '측면형', len: String(sideLen || 3000), wid: 'N/A' }, + bottom_bar: { name: '하단마감재', type: '60×40', len: '3000', wid: 'N/A' }, + case_box: { name: '케이스', type: '양면', len: '3000', wid: 'N/A' }, + smoke_w50: { name: '연기차단재', type: '화이바 W50\n가이드레일용', len: '-', wid: '50' }, + smoke_w80: { name: '연기차단재', type: '화이바 W80\n케이스용', len: '-', wid: '80' }, + }; + const d = displayMap[item.id] || { name: item.name, type: '', len: '-', wid: 'N/A' }; + return { + id: item.id, + category: productCode, + productName: d.name, + productType: d.type, + lengthDesign: d.len, + widthDesign: d.wid, + gapPoints: item.gap_points.map(gp => ({ + point: gp.point, + designValue: gp.design_value, + })), + }; + }); + } + + // fallback: 기존 프론트 로직 사용 return buildBendingProducts(order); - }, [isBending, order]); + }, [isBending, order, inspectionConfig]); const bendingExpandedRows = useMemo(() => { if (!isBending) return []; @@ -740,8 +792,8 @@ export const TemplateInspectionContent = forwardRef { + // 개소(WorkItem)별 데이터: 비-Bending 또는 Bending이지만 구성품이 없는 경우 (AS-IS) + if (!isBending || bendingProducts.length === 0) effectiveWorkItems.forEach((wi, rowIdx) => { for (const col of template.columns) { // 일련번호 컬럼 → 저장 (mng show에서 표시용) if (isSerialColumn(col.label)) { diff --git a/src/components/production/WorkerScreen/WorkOrderListPanel.tsx b/src/components/production/WorkerScreen/WorkOrderListPanel.tsx index e9f0a881..df90be9f 100644 --- a/src/components/production/WorkerScreen/WorkOrderListPanel.tsx +++ b/src/components/production/WorkerScreen/WorkOrderListPanel.tsx @@ -74,8 +74,8 @@ export function WorkOrderListPanel({ - {/* 품목명 */} -

{order.productName}

+ {/* 제품코드 - 제품명 */} +

{order.productCode} - {order.productName}

{/* 현장명 + 수량 */}
diff --git a/src/components/production/WorkerScreen/actions.ts b/src/components/production/WorkerScreen/actions.ts index d63dcb2e..e6b04708 100644 --- a/src/components/production/WorkerScreen/actions.ts +++ b/src/components/production/WorkerScreen/actions.ts @@ -38,6 +38,7 @@ interface WorkOrderApiItem { sales_order?: { id: number; order_no: string; + item?: { id: number; code: string; name: string } | null; client?: { id: number; name: string }; client_contact?: string; options?: { manager_name?: string; [key: string]: unknown }; @@ -50,6 +51,8 @@ interface WorkOrderApiItem { items?: { id: number; item_name: string; + item_id?: number | null; + item?: { id: number; code: string; name: string } | null; quantity: number; specification?: string | null; options?: Record | null; @@ -89,7 +92,8 @@ function mapApiStatus(status: WorkOrderApiItem['status']): WorkOrderStatus { // ===== API → WorkOrder 변환 ===== function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder { const totalQuantity = (api.items || []).reduce((sum, item) => sum + Number(item.quantity), 0); - const productName = api.items?.[0]?.item_name || '-'; + const productCode = (api.items?.[0]?.options?.product_code as string) || api.sales_order?.item?.code || '-'; + const productName = (api.items?.[0]?.options?.product_name as string) || api.items?.[0]?.item_name || '-'; // 납기일 계산 (지연 여부) const dueDate = api.scheduled_date || ''; @@ -173,6 +177,7 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder { return { id: String(api.id), orderNo: api.work_order_no, + productCode, productName, processCode: processInfo.code, processName: processInfo.name, diff --git a/src/components/production/WorkerScreen/index.tsx b/src/components/production/WorkerScreen/index.tsx index 8ffa88fd..848966f3 100644 --- a/src/components/production/WorkerScreen/index.tsx +++ b/src/components/production/WorkerScreen/index.tsx @@ -714,7 +714,7 @@ export default function WorkerScreen() { workOrderId: selectedOrder.id, itemNo: index + 1, itemCode: selectedOrder.orderNo || '-', - itemName: itemSummary, + itemName: `${selectedOrder.productCode !== '-' ? selectedOrder.productCode + ' - ' : ''}${itemSummary}`, floor: (opts.floor as string) || '-', code: (opts.code as string) || '-', width: (opts.width as number) || 0, @@ -774,7 +774,7 @@ export default function WorkerScreen() { workOrderId: selectedOrder.id, itemNo: 1, itemCode: selectedOrder.orderNo || '-', - itemName: selectedOrder.productName || '-', + itemName: `${selectedOrder.productCode !== '-' ? selectedOrder.productCode + ' - ' : ''}${selectedOrder.productName || '-'}`, floor: '-', code: '-', width: 0, @@ -940,6 +940,7 @@ export default function WorkerScreen() { const syntheticOrder: WorkOrder = { id: item.id, orderNo: item.itemCode, + productCode: item.itemCode, productName: item.itemName, processCode: item.processType, processName: PROCESS_TAB_LABELS[item.processType], @@ -999,6 +1000,7 @@ export default function WorkerScreen() { const syntheticOrder: WorkOrder = { id: mockItem.id, orderNo: mockItem.itemCode, + productCode: mockItem.itemCode, productName: mockItem.itemName, processCode: mockItem.processType, processName: PROCESS_TAB_LABELS[mockItem.processType], @@ -1239,6 +1241,7 @@ export default function WorkerScreen() { return { id: mockItem.id, orderNo: mockItem.itemCode, + productCode: mockItem.itemCode, productName: mockItem.itemName, processCode: mockItem.processType, processName: PROCESS_TAB_LABELS[mockItem.processType],