From d12e2e0b4cf7719c920cb5c1de52086203a474b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 20 Jan 2026 17:03:13 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20API=20=EC=9D=91=EB=8B=B5=20=EB=9E=98?= =?UTF-8?q?=ED=8D=BC=20=ED=8C=A8=ED=84=B4=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - construction actions.ts 파일들 API 응답 래퍼 패턴 수정 - handover-report, order-management, site-management, structure-review - apiClient 반환값 { success, data } 구조에 맞게 수정 - ShipmentManagement 기능 개선 - WorkerScreen 컴포넌트 수정 - .gitignore에 package-lock.json, tsconfig.tsbuildinfo 추가 --- .gitignore | 4 + .../construction/handover-report/actions.ts | 250 ++++++++-- .../construction/order-management/actions.ts | 74 ++- .../construction/site-management/actions.ts | 40 +- .../construction/structure-review/actions.ts | 57 ++- .../ShipmentManagement/ShipmentDetail.tsx | 462 ++++++++++++++---- .../outbound/ShipmentManagement/actions.ts | 24 +- .../WorkerScreen/CompletionConfirmDialog.tsx | 3 +- .../production/WorkerScreen/WorkCard.tsx | 4 +- 9 files changed, 729 insertions(+), 189 deletions(-) diff --git a/.gitignore b/.gitignore index d061a18d..c38ff7a9 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,7 @@ playwright.config.ts playwright-report/ test-results/ .playwright/ + +# ---> Build artifacts +package-lock.json +tsconfig.tsbuildinfo diff --git a/src/components/business/construction/handover-report/actions.ts b/src/components/business/construction/handover-report/actions.ts index 92a0022f..4e479227 100644 --- a/src/components/business/construction/handover-report/actions.ts +++ b/src/components/business/construction/handover-report/actions.ts @@ -20,6 +20,42 @@ import { apiClient } from '@/lib/api'; // API 응답 타입 // ======================================== +// 목록 조회 시: Contract 데이터 + handover_report 관계 +interface ApiContractWithHandover { + id: number; + contract_code: string; + project_name: string; + partner_id: number | null; + partner_name: string | null; + contract_manager_id: number | null; + contract_manager_name: string | null; + construction_pm_id: number | null; + construction_pm_name: string | null; + total_locations: number; + contract_amount: string | number; + contract_start_date: string | null; + contract_end_date: string | null; + status: 'pending' | 'completed'; + stage: string; + bidding_id: number | null; + bidding_code: string | null; + remarks: string | null; + is_active: boolean; + created_at: string; + updated_at: string; + // 인수인계 보고서 관계 (없으면 null) + handover_report: ApiHandoverReportRelation | null; +} + +// 인수인계 보고서 관계 데이터 +interface ApiHandoverReportRelation { + id: number; + report_number: string; + status: 'pending' | 'completed'; + completion_date: string | null; +} + +// 상세 조회 시: HandoverReport 직접 반환 interface ApiHandoverReport { id: number; report_number: string; @@ -73,12 +109,42 @@ interface ApiExternalEquipmentCost { public_expense: number; } +// 통계 응답 (계약 완료건 기준) interface ApiHandoverReportStats { total_count: number; - pending_count: number; - completed_count: number; + handover_completed_count: number; + handover_pending_count: number; total_amount?: number; - total_sites?: number; + total_locations?: number; +} + +// 상세 조회 시: Contract + handover_report 관계 (API show 응답) +interface ApiContractWithHandoverDetail extends ApiContractWithHandover { + contract_manager?: { + id: number; + name: string; + } | null; + construction_pm?: { + id: number; + name: string; + } | null; + handover_report: { + id: number; + report_number: string; + status: 'pending' | 'completed'; + completion_date: string | null; + contract_date: string | null; + has_secondary_piping?: boolean; + secondary_piping_amount?: number; + secondary_piping_note?: string | null; + has_coating?: boolean; + coating_amount?: number; + coating_note?: string | null; + external_equipment_cost?: ApiExternalEquipmentCost; + special_notes?: string | null; + managers?: ApiManager[]; + items?: ApiContractItem[]; + } | null; } // ======================================== @@ -86,7 +152,37 @@ interface ApiHandoverReportStats { // ======================================== /** - * API 응답 → HandoverReport 타입 변환 (목록용) + * Contract + handover_report 관계 → HandoverReport 타입 변환 (목록용) + * 계약 완료건 기준 목록 조회용 + */ +function transformContractToHandoverReport(apiData: ApiContractWithHandover): HandoverReport { + // 인수인계 상태: 보고서가 있으면 completed, 없으면 pending + const handoverStatus = apiData.handover_report ? 'completed' : 'pending'; + + return { + id: String(apiData.id), + // 보고서 번호: 보고서가 있으면 보고서 번호, 없으면 계약 코드 + reportNumber: apiData.handover_report?.report_number || apiData.contract_code || '', + partnerName: apiData.partner_name || '', + // 현장명 = 프로젝트명 + siteName: apiData.project_name || '', + contractManagerName: apiData.contract_manager_name || '', + constructionPMName: apiData.construction_pm_name || null, + // 총 개소 = total_locations + totalSites: apiData.total_locations || 0, + contractAmount: Number(apiData.contract_amount) || 0, + contractStartDate: apiData.contract_start_date || null, + contractEndDate: apiData.contract_end_date || null, + // 인수인계 상태 (보고서 유무 기준) + status: handoverStatus, + contractId: String(apiData.id), + createdAt: apiData.created_at || '', + updatedAt: apiData.updated_at || '', + }; +} + +/** + * API 응답 → HandoverReport 타입 변환 (상세용 - 기존 유지) */ function transformHandoverReport(apiData: ApiHandoverReport): HandoverReport { return { @@ -108,7 +204,7 @@ function transformHandoverReport(apiData: ApiHandoverReport): HandoverReport { } /** - * API 응답 → HandoverReportDetail 타입 변환 (상세용) + * API 응답 → HandoverReportDetail 타입 변환 (상세용 - 기존 HandoverReport 직접 조회용) */ function transformHandoverReportDetail(apiData: ApiHandoverReport): HandoverReportDetail { // 공사담당자 목록 변환 @@ -174,6 +270,82 @@ function transformHandoverReportDetail(apiData: ApiHandoverReport): HandoverRepo }; } +/** + * Contract + handover_report → HandoverReportDetail 타입 변환 (상세용 - Contract 기반 조회) + */ +function transformContractToHandoverReportDetail(apiData: ApiContractWithHandoverDetail): HandoverReportDetail { + const report = apiData.handover_report; + + // 공사담당자 목록 변환 + const constructionManagers: ConstructionManager[] = (report?.managers || []).map((m) => ({ + id: String(m.id), + name: m.name || '', + nonPerformanceReason: m.non_performance_reason || '', + signature: m.signature || null, + })); + + // 계약 ITEM 목록 변환 + const contractItems: ContractItem[] = (report?.items || []).map((item) => ({ + id: String(item.id), + no: item.item_no || 0, + name: item.name || '', + product: item.product || '', + quantity: item.quantity || 0, + remark: item.remark || '', + })); + + // 장비 외 실행금액 변환 + const externalCost = report?.external_equipment_cost; + const externalEquipmentCost: ExternalEquipmentCost = externalCost + ? { + shippingCost: externalCost.shipping_cost || 0, + highAltitudeWork: externalCost.high_altitude_work || 0, + publicExpense: externalCost.public_expense || 0, + } + : { + shippingCost: 0, + highAltitudeWork: 0, + publicExpense: 0, + }; + + // 인수인계 상태: 보고서가 있으면 completed, 없으면 pending + const handoverStatus = report ? (report.status || 'pending') : 'pending'; + + return { + // Contract 기반 ID (상세 조회에서는 contract_id를 사용) + id: String(apiData.id), + // 보고서 번호: 보고서가 있으면 보고서 번호, 없으면 계약 코드 + reportNumber: report?.report_number || apiData.contract_code || '', + partnerName: apiData.partner_name || '', + // 현장명 = 프로젝트명 + siteName: apiData.project_name || '', + contractManagerName: apiData.contract_manager_name || apiData.contract_manager?.name || '', + constructionPMName: apiData.construction_pm_name || apiData.construction_pm?.name || null, + constructionPMId: apiData.construction_pm_id ? String(apiData.construction_pm_id) : null, + // 총 개소 = total_locations + totalSites: apiData.total_locations || 0, + contractAmount: Number(apiData.contract_amount) || 0, + contractDate: report?.contract_date || null, + contractStartDate: apiData.contract_start_date || null, + contractEndDate: apiData.contract_end_date || null, + completionDate: report?.completion_date || null, + status: handoverStatus, + contractId: String(apiData.id), + createdAt: apiData.created_at || '', + updatedAt: apiData.updated_at || '', + constructionManagers, + contractItems, + hasSecondaryPiping: report?.has_secondary_piping || false, + secondaryPipingAmount: report?.secondary_piping_amount || 0, + secondaryPipingNote: report?.secondary_piping_note || '', + hasCoating: report?.has_coating || false, + coatingAmount: report?.coating_amount || 0, + coatingNote: report?.coating_note || '', + externalEquipmentCost, + specialNotes: report?.special_notes || '', + }; +} + /** * HandoverReportFormData → API 요청 데이터 변환 */ @@ -270,8 +442,8 @@ export async function getHandoverReportList(params?: { // 검색 if (params?.search) queryParams.search = params.search; - // 필터 - if (params?.status && params.status !== 'all') queryParams.status = params.status; + // 인수인계 상태 필터 (API는 handover_status 파라미터 사용) + if (params?.status && params.status !== 'all') queryParams.handover_status = params.status; if (params?.partnerId && params.partnerId !== 'all') queryParams.partner_id = params.partnerId; if (params?.contractManagerId && params.contractManagerId !== 'all') { queryParams.contract_manager_id = params.contractManagerId; @@ -301,24 +473,30 @@ export async function getHandoverReportList(params?: { } } + // API 응답: Contract 목록 + handover_report 관계 const response = await apiClient.get<{ - data: ApiHandoverReport[]; - current_page: number; - per_page: number; - total: number; - last_page: number; + success: boolean; + data: { + data: ApiContractWithHandover[]; + current_page: number; + per_page: number; + total: number; + last_page: number; + }; }>('/construction/handover-reports', { params: queryParams }); - const items = (response.data || []).map(transformHandoverReport); + // Contract → HandoverReport 변환 + const paginatedData = response.data; + const items = (paginatedData.data || []).map(transformContractToHandoverReport); return { success: true, data: { items, - total: response.total || 0, - page: response.current_page || 1, - size: response.per_page || 20, - totalPages: response.last_page || 1, + total: paginatedData.total || 0, + page: paginatedData.current_page || 1, + size: paginatedData.per_page || 20, + totalPages: paginatedData.last_page || 1, }, }; } catch (error) { @@ -337,14 +515,18 @@ export async function getHandoverReportStats(): Promise<{ error?: string; }> { try { - const response = await apiClient.get('/construction/handover-reports/stats'); + const response = await apiClient.get<{ + success: boolean; + data: ApiHandoverReportStats; + }>('/construction/handover-reports/stats'); + const statsData = response.data; return { success: true, data: { - total: response.total_count || 0, - pending: response.pending_count || 0, - completed: response.completed_count || 0, + total: statsData.total_count || 0, + pending: statsData.handover_pending_count || 0, + completed: statsData.handover_completed_count || 0, }, }; } catch (error) { @@ -391,8 +573,9 @@ export async function deleteHandoverReports(ids: string[]): Promise<{ } /** - * 인수인계보고서 상세 조회 - * GET /api/v1/construction/handover-reports/{id} + * 인수인계보고서 상세 조회 (계약 ID 기준) + * GET /api/v1/construction/handover-reports/{contractId} + * 백엔드는 Contract + handover_report 관계로 반환 */ export async function getHandoverReportDetail(id: string): Promise<{ success: boolean; @@ -400,8 +583,11 @@ export async function getHandoverReportDetail(id: string): Promise<{ error?: string; }> { try { - const response = await apiClient.get(`/construction/handover-reports/${id}`); - return { success: true, data: transformHandoverReportDetail(response) }; + const response = await apiClient.get<{ + success: boolean; + data: ApiContractWithHandoverDetail; + }>(`/construction/handover-reports/${id}`); + return { success: true, data: transformContractToHandoverReportDetail(response.data) }; } catch (error) { console.error('인수인계보고서 상세 조회 오류:', error); return { success: false, error: '인수인계보고서를 찾을 수 없습니다.' }; @@ -422,8 +608,11 @@ export async function updateHandoverReport( }> { try { const apiData = transformToApiRequest(data); - const response = await apiClient.put(`/construction/handover-reports/${id}`, apiData); - return { success: true, data: transformHandoverReportDetail(response) }; + const response = await apiClient.put<{ + success: boolean; + data: ApiHandoverReport; + }>(`/construction/handover-reports/${id}`, apiData); + return { success: true, data: transformHandoverReportDetail(response.data) }; } catch (error) { console.error('인수인계보고서 수정 오류:', error); return { success: false, error: '수정에 실패했습니다.' }; @@ -443,8 +632,11 @@ export async function createHandoverReport( }> { try { const apiData = transformToApiRequest(data); - const response = await apiClient.post('/construction/handover-reports', apiData); - return { success: true, data: transformHandoverReportDetail(response) }; + const response = await apiClient.post<{ + success: boolean; + data: ApiHandoverReport; + }>('/construction/handover-reports', apiData); + return { success: true, data: transformHandoverReportDetail(response.data) }; } catch (error) { console.error('인수인계보고서 등록 오류:', error); return { success: false, error: '등록에 실패했습니다.' }; diff --git a/src/components/business/construction/order-management/actions.ts b/src/components/business/construction/order-management/actions.ts index 16173d18..89441afd 100644 --- a/src/components/business/construction/order-management/actions.ts +++ b/src/components/business/construction/order-management/actions.ts @@ -227,19 +227,23 @@ export async function getOrderList(params?: { } const response = await apiClient.get<{ - data: ApiOrder[]; - meta?: { total: number; current_page: number; per_page: number }; - total?: number; - current_page?: number; - per_page?: number; + success: boolean; + data: { + data: ApiOrder[]; + current_page: number; + per_page: number; + total: number; + last_page: number; + }; }>('/orders', { params: queryParams }); // API 응답 구조 처리 - const orders = Array.isArray(response.data) ? response.data : (response.data as unknown as ApiOrder[]); - const meta = response.meta || { - total: response.total || orders.length, - current_page: response.current_page || params?.page || 1, - per_page: response.per_page || params?.size || 20, + const paginatedData = response.data; + const orders = paginatedData.data || []; + const meta = { + total: paginatedData.total || orders.length, + current_page: paginatedData.current_page || params?.page || 1, + per_page: paginatedData.per_page || params?.size || 20, }; const transformedOrders = orders.map(transformOrder); @@ -267,16 +271,20 @@ export async function getOrderStats(): Promise<{ error?: string; }> { try { - const response = await apiClient.get('/orders/stats'); + const response = await apiClient.get<{ + success: boolean; + data: ApiOrderStats; + }>('/orders/stats'); + const statsData = response.data; return { success: true, data: { - total: response.total, - waiting: response.draft, - orderComplete: response.confirmed, - deliveryScheduled: response.in_progress, - deliveryComplete: response.completed, + total: statsData.total, + waiting: statsData.draft, + orderComplete: statsData.confirmed, + deliveryScheduled: statsData.in_progress, + deliveryComplete: statsData.completed, }, }; } catch (error) { @@ -341,8 +349,11 @@ export async function getOrderDetail(id: string): Promise<{ error?: string; }> { try { - const response = await apiClient.get(`/orders/${id}`); - return { success: true, data: transformOrder(response) }; + const response = await apiClient.get<{ + success: boolean; + data: ApiOrder; + }>(`/orders/${id}`); + return { success: true, data: transformOrder(response.data) }; } catch (error) { console.error('발주 상세 조회 오류:', error); return { success: false, error: '발주를 찾을 수 없습니다.' }; @@ -359,8 +370,11 @@ export async function getOrderDetailFull(id: string): Promise<{ error?: string; }> { try { - const response = await apiClient.get(`/orders/${id}`); - return { success: true, data: transformOrderDetail(response) }; + const response = await apiClient.get<{ + success: boolean; + data: ApiOrder; + }>(`/orders/${id}`); + return { success: true, data: transformOrderDetail(response.data) }; } catch (error) { console.error('발주 상세 조회 오류:', error); return { success: false, error: '발주 상세 조회에 실패했습니다.' }; @@ -399,7 +413,11 @@ export async function duplicateOrder(id: string): Promise<{ }> { try { // 1. 기존 발주 조회 - const existingOrder = await apiClient.get(`/orders/${id}`); + const existingOrderResponse = await apiClient.get<{ + success: boolean; + data: ApiOrder; + }>(`/orders/${id}`); + const existingOrder = existingOrderResponse.data; // 2. 새 발주 생성 (order_no는 자동 생성됨) const newOrderData = { @@ -419,11 +437,14 @@ export async function duplicateOrder(id: string): Promise<{ })), }; - const response = await apiClient.post<{ id: number }>('/orders', newOrderData); + const response = await apiClient.post<{ + success: boolean; + data: { id: number }; + }>('/orders', newOrderData); return { success: true, - newId: String(response.id), + newId: String(response.data.id), }; } catch (error) { console.error('발주 복제 오류:', error); @@ -442,9 +463,12 @@ export async function createOrder(data: OrderDetailFormData): Promise<{ }> { try { const apiData = transformOrderToApi(data); - const response = await apiClient.post<{ id: number }>('/orders', apiData); + const response = await apiClient.post<{ + success: boolean; + data: { id: number }; + }>('/orders', apiData); - return { success: true, data: { id: String(response.id) } }; + return { success: true, data: { id: String(response.data.id) } }; } catch (error) { console.error('발주 생성 오류:', error); return { success: false, error: '발주 생성에 실패했습니다.' }; diff --git a/src/components/business/construction/site-management/actions.ts b/src/components/business/construction/site-management/actions.ts index 2dab97ae..2349d621 100644 --- a/src/components/business/construction/site-management/actions.ts +++ b/src/components/business/construction/site-management/actions.ts @@ -122,23 +122,27 @@ export async function getSiteList(params: GetSiteListParams = {}): Promise<{ } const response = await apiClient.get<{ - data: ApiSite[]; - current_page: number; - per_page: number; - total: number; - last_page: number; + success: boolean; + data: { + data: ApiSite[]; + current_page: number; + per_page: number; + total: number; + last_page: number; + }; }>('/sites', { params: queryParams }); - const items = (response.data || []).map(transformSite); + const paginatedData = response.data; + const items = (paginatedData.data || []).map(transformSite); return { success: true, data: { items, - total: response.total || 0, - page: response.current_page || 1, - size: response.per_page || 20, - totalPages: response.last_page || 1, + total: paginatedData.total || 0, + page: paginatedData.current_page || 1, + size: paginatedData.per_page || 20, + totalPages: paginatedData.last_page || 1, }, }; } catch (error) { @@ -157,16 +161,20 @@ export async function getSiteStats(): Promise<{ error?: string; }> { try { - const response = await apiClient.get('/sites/stats'); + const response = await apiClient.get<{ + success: boolean; + data: ApiSiteStats; + }>('/sites/stats'); + const statsData = response.data; return { success: true, data: { - total: response.total || 0, - construction: response.construction || 0, - unregistered: response.unregistered || 0, - suspended: response.suspended || 0, - pending: response.pending || 0, + total: statsData.total || 0, + construction: statsData.construction || 0, + unregistered: statsData.unregistered || 0, + suspended: statsData.suspended || 0, + pending: statsData.pending || 0, }, }; } catch (error) { diff --git a/src/components/business/construction/structure-review/actions.ts b/src/components/business/construction/structure-review/actions.ts index e5f5f952..bdd65b2b 100644 --- a/src/components/business/construction/structure-review/actions.ts +++ b/src/components/business/construction/structure-review/actions.ts @@ -151,23 +151,27 @@ export async function getStructureReviewList(params: GetStructureReviewListParam } const response = await apiClient.get<{ - data: ApiStructureReview[]; - current_page: number; - per_page: number; - total: number; - last_page: number; + success: boolean; + data: { + data: ApiStructureReview[]; + current_page: number; + per_page: number; + total: number; + last_page: number; + }; }>('/construction/structure-reviews', { params: queryParams }); - const items = (response.data || []).map(transformStructureReview); + const paginatedData = response.data; + const items = (paginatedData.data || []).map(transformStructureReview); return { success: true, data: { items, - total: response.total || 0, - page: response.current_page || 1, - size: response.per_page || 20, - totalPages: response.last_page || 1, + total: paginatedData.total || 0, + page: paginatedData.current_page || 1, + size: paginatedData.per_page || 20, + totalPages: paginatedData.last_page || 1, }, }; } catch (error) { @@ -186,14 +190,18 @@ export async function getStructureReviewStats(): Promise<{ error?: string; }> { try { - const response = await apiClient.get('/construction/structure-reviews/stats'); + const response = await apiClient.get<{ + success: boolean; + data: ApiStructureReviewStats; + }>('/construction/structure-reviews/stats'); + const statsData = response.data; return { success: true, data: { - total: response.total || 0, - pending: response.pending || 0, - completed: response.completed || 0, + total: statsData.total || 0, + pending: statsData.pending || 0, + completed: statsData.completed || 0, }, }; } catch (error) { @@ -212,11 +220,14 @@ export async function getStructureReview(id: string): Promise<{ error?: string; }> { try { - const response = await apiClient.get(`/construction/structure-reviews/${id}`); + const response = await apiClient.get<{ + success: boolean; + data: ApiStructureReview; + }>(`/construction/structure-reviews/${id}`); return { success: true, - data: transformStructureReview(response), + data: transformStructureReview(response.data), }; } catch (error) { console.error('구조검토 상세 조회 오류:', error); @@ -235,11 +246,14 @@ export async function createStructureReview(data: Partial): Pro }> { try { const apiData = transformToApiData(data); - const response = await apiClient.post('/construction/structure-reviews', apiData); + const response = await apiClient.post<{ + success: boolean; + data: ApiStructureReview; + }>('/construction/structure-reviews', apiData); return { success: true, - data: transformStructureReview(response), + data: transformStructureReview(response.data), }; } catch (error) { console.error('구조검토 생성 오류:', error); @@ -261,11 +275,14 @@ export async function updateStructureReview( }> { try { const apiData = transformToApiData(data); - const response = await apiClient.put(`/construction/structure-reviews/${id}`, apiData); + const response = await apiClient.put<{ + success: boolean; + data: ApiStructureReview; + }>(`/construction/structure-reviews/${id}`, apiData); return { success: true, - data: transformStructureReview(response), + data: transformStructureReview(response.data), }; } catch (error) { console.error('구조검토 수정 오류:', error); diff --git a/src/components/outbound/ShipmentManagement/ShipmentDetail.tsx b/src/components/outbound/ShipmentManagement/ShipmentDetail.tsx index 006336a9..a35241c2 100644 --- a/src/components/outbound/ShipmentManagement/ShipmentDetail.tsx +++ b/src/components/outbound/ShipmentManagement/ShipmentDetail.tsx @@ -3,12 +3,12 @@ /** * 출하 상세 페이지 * API 연동 완료 (2025-12-26) - * IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20) */ -import { useState, useCallback, useEffect, useMemo } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { + Truck, FileText, Receipt, ClipboardList, @@ -16,7 +16,12 @@ import { Printer, X, Loader2, + AlertCircle, + Trash2, + ArrowRight, + ChevronDown, } from 'lucide-react'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -33,11 +38,31 @@ import { DialogContent, DialogTitle, } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + DialogDescription, + DialogFooter, + DialogHeader, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; -import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; -import { shipmentConfig } from './shipmentConfig'; -import { ServerErrorPage } from '@/components/common/ServerErrorPage'; -import { toast } from 'sonner'; +import { PageLayout } from '@/components/organisms/PageLayout'; import { getShipmentById, deleteShipment, updateShipmentStatus } from './actions'; import { SHIPMENT_STATUS_LABELS, @@ -57,9 +82,31 @@ interface ShipmentDetailProps { id: string; } +// 상태 전이 맵: 현재 상태 → 다음 가능한 상태 +const STATUS_TRANSITIONS: Record = { + scheduled: 'ready', + ready: 'shipping', + shipping: 'completed', + completed: null, // 최종 상태 +}; + export function ShipmentDetail({ id }: ShipmentDetailProps) { const router = useRouter(); const [previewDocument, setPreviewDocument] = useState<'shipping' | 'transaction' | 'delivery' | null>(null); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + // 상태 변경 관련 상태 + const [showStatusDialog, setShowStatusDialog] = useState(false); + const [targetStatus, setTargetStatus] = useState(null); + const [isChangingStatus, setIsChangingStatus] = useState(false); + const [statusFormData, setStatusFormData] = useState({ + loadingTime: '', + vehicleNo: '', + driverName: '', + driverContact: '', + confirmedArrival: '', + }); // API 데이터 상태 const [detail, setDetail] = useState(null); @@ -93,19 +140,33 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) { loadData(); }, [loadData]); - // 삭제 핸들러 (IntegratedDetailTemplate용) - const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => { + // 목록으로 이동 + const handleGoBack = useCallback(() => { + router.push('/ko/outbound/shipments'); + }, [router]); + + // 수정 페이지로 이동 + const handleEdit = useCallback(() => { + router.push(`/ko/outbound/shipments/${id}/edit`); + }, [id, router]); + + // 삭제 처리 + const handleDelete = useCallback(async () => { + setIsDeleting(true); try { const result = await deleteShipment(id); if (result.success) { - toast.success('출하 정보가 삭제되었습니다.'); router.push('/ko/outbound/shipments'); - return { success: true }; + } else { + alert(result.error || '삭제에 실패했습니다.'); } - return { success: false, error: result.error || '삭제에 실패했습니다.' }; } catch (err) { if (isNextRedirectError(err)) throw err; - return { success: false, error: '삭제 중 오류가 발생했습니다.' }; + console.error('[ShipmentDetail] handleDelete error:', err); + alert('삭제 중 오류가 발생했습니다.'); + } finally { + setIsDeleting(false); + setShowDeleteDialog(false); } }, [id, router]); @@ -117,21 +178,60 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) { printArea({ title: `${docName} 인쇄` }); }, [previewDocument]); - // 수정/삭제 가능 여부 (scheduled, ready 상태에서만) - const canEdit = detail?.status === 'scheduled' || detail?.status === 'ready'; - const canDelete = detail?.status === 'scheduled' || detail?.status === 'ready'; + // 상태 변경 다이얼로그 열기 + const handleOpenStatusDialog = useCallback((status: ShipmentStatus) => { + setTargetStatus(status); + setStatusFormData({ + loadingTime: '', + vehicleNo: '', + driverName: '', + driverContact: '', + confirmedArrival: '', + }); + setShowStatusDialog(true); + }, []); - // 동적 config (상태에 따른 삭제 버튼 표시 여부) - const dynamicConfig = useMemo(() => { - return { - ...shipmentConfig, - actions: { - ...shipmentConfig.actions, - showDelete: canDelete, - showEdit: canEdit, - }, - }; - }, [canDelete, canEdit]); + // 상태 변경 처리 + const handleStatusChange = useCallback(async () => { + if (!targetStatus) return; + + setIsChangingStatus(true); + try { + const additionalData: Record = {}; + + // 상태별 추가 데이터 설정 + if (targetStatus === 'ready' && statusFormData.loadingTime) { + additionalData.loadingTime = statusFormData.loadingTime; + } + if (targetStatus === 'shipping') { + if (statusFormData.vehicleNo) additionalData.vehicleNo = statusFormData.vehicleNo; + if (statusFormData.driverName) additionalData.driverName = statusFormData.driverName; + if (statusFormData.driverContact) additionalData.driverContact = statusFormData.driverContact; + } + if (targetStatus === 'completed' && statusFormData.confirmedArrival) { + additionalData.confirmedArrival = statusFormData.confirmedArrival; + } + + const result = await updateShipmentStatus( + id, + targetStatus, + Object.keys(additionalData).length > 0 ? additionalData : undefined + ); + + if (result.success && result.data) { + setDetail(result.data); + setShowStatusDialog(false); + } else { + alert(result.error || '상태 변경에 실패했습니다.'); + } + } catch (err) { + if (isNextRedirectError(err)) throw err; + console.error('[ShipmentDetail] handleStatusChange error:', err); + alert('상태 변경 중 오류가 발생했습니다.'); + } finally { + setIsChangingStatus(false); + } + }, [id, targetStatus, statusFormData]); // 정보 영역 렌더링 const renderInfoField = (label: string, value: React.ReactNode, className?: string) => ( @@ -141,44 +241,104 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) { ); - // 커스텀 헤더 액션 (문서 미리보기 버튼들) - const customHeaderActions = useMemo(() => { + // 로딩 상태 표시 + if (isLoading) { return ( - <> - - - - + + + ); - }, []); - - // 폼 내용 렌더링 - const renderFormContent = () => { - if (!detail) return null; + } + // 에러 상태 표시 + if (error || !detail) { return ( + +
+ +

{error || '데이터를 찾을 수 없습니다.'}

+ +
+
+ ); + } + + // 수정/삭제 가능 여부 (scheduled, ready 상태에서만) + const canEdit = detail.status === 'scheduled' || detail.status === 'ready'; + const canDelete = detail.status === 'scheduled' || detail.status === 'ready'; + + return ( +
+ {/* 헤더 */} +
+
+ +

출하 상세

+
+
+ {/* 문서 미리보기 버튼 */} + + + +
+ + {canEdit && ( + + )} + {canDelete && ( + + )} + {/* 상태 변경 버튼 */} + {STATUS_TRANSITIONS[detail.status] && ( + <> +
+ + + )} +
+
+ + {/* 메인 콘텐츠 */} +
{/* 출고 정보 */} @@ -352,38 +512,10 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) { )} -
- ); - }; - // 에러 상태 표시 - if (!isLoading && (error || !detail)) { - return ( - - ); - } +
- return ( - <> - renderFormContent()} - renderForm={() => renderFormContent()} - /> - - {/* 문서 미리보기 다이얼로그 - 작업일지 모달 패턴 적용 */} - {detail && ( + {/* 문서 미리보기 다이얼로그 - 작업일지 모달 패턴 적용 */} setPreviewDocument(null)}> {/* 접근성을 위한 숨겨진 타이틀 */} @@ -434,7 +566,155 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
- )} - + + {/* 삭제 확인 다이얼로그 */} + + + + 출하 정보 삭제 + + 출하번호 {detail.shipmentNo}을(를) 삭제하시겠습니까? +
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + 취소 + + {isDeleting ? ( + <> + + 삭제 중... + + ) : ( + '삭제' + )} + + +
+
+ + {/* 상태 변경 다이얼로그 */} + + + + 출하 상태 변경 + + {detail.status && targetStatus && ( + + + {SHIPMENT_STATUS_LABELS[detail.status]} + + + + {SHIPMENT_STATUS_LABELS[targetStatus]} + + + )} + + + +
+ {/* 출하대기로 변경 시 - 상차 시간 */} + {targetStatus === 'ready' && ( +
+ + + setStatusFormData((prev) => ({ ...prev, loadingTime: e.target.value })) + } + /> +
+ )} + + {/* 배송중으로 변경 시 - 차량/운전자 정보 */} + {targetStatus === 'shipping' && ( + <> +
+ + + setStatusFormData((prev) => ({ ...prev, vehicleNo: e.target.value })) + } + /> +
+
+ + + setStatusFormData((prev) => ({ ...prev, driverName: e.target.value })) + } + /> +
+
+ + + setStatusFormData((prev) => ({ ...prev, driverContact: e.target.value })) + } + /> +
+ + )} + + {/* 배송완료로 변경 시 - 도착 확인 시간 */} + {targetStatus === 'completed' && ( +
+ + + setStatusFormData((prev) => ({ ...prev, confirmedArrival: e.target.value })) + } + /> +
+ )} +
+ + + + + +
+
+
+
); } diff --git a/src/components/outbound/ShipmentManagement/actions.ts b/src/components/outbound/ShipmentManagement/actions.ts index c5d813ee..f9304d07 100644 --- a/src/components/outbound/ShipmentManagement/actions.ts +++ b/src/components/outbound/ShipmentManagement/actions.ts @@ -37,6 +37,19 @@ import type { } from './types'; // ===== API 데이터 타입 ===== + +// 수주 연동 정보 (Order → Shipment) +interface OrderInfoApiData { + order_id?: number; + order_no?: string; + order_status?: string; + client_id?: number; + customer_name?: string; + site_name?: string; + delivery_address?: string; + contact?: string; +} + interface ShipmentApiData { id: number; shipment_no: string; @@ -52,6 +65,8 @@ interface ShipmentApiData { delivery_address?: string; receiver?: string; receiver_contact?: string; + // 수주 연동 정보 (order_info accessor) + order_info?: OrderInfoApiData; can_ship: boolean; deposit_confirmed: boolean; invoice_issued: boolean; @@ -170,11 +185,12 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail { loadingManager: data.loading_manager, loadingCompleted: data.loading_completed_at, registrant: data.creator?.name, - customerName: data.customer_name || '', - siteName: data.site_name || '', - deliveryAddress: data.delivery_address || '', + // 발주처/배송 정보: order_info 우선 참조 (Order가 Single Source of Truth) + customerName: data.order_info?.customer_name || data.customer_name || '', + siteName: data.order_info?.site_name || data.site_name || '', + deliveryAddress: data.order_info?.delivery_address || data.delivery_address || '', receiver: data.receiver, - receiverContact: data.receiver_contact, + receiverContact: data.order_info?.contact || data.receiver_contact, products: (data.items || []).map(transformApiToProduct), logisticsCompany: data.logistics_company, vehicleTonnage: data.vehicle_tonnage, diff --git a/src/components/production/WorkerScreen/CompletionConfirmDialog.tsx b/src/components/production/WorkerScreen/CompletionConfirmDialog.tsx index 711c135f..608753aa 100644 --- a/src/components/production/WorkerScreen/CompletionConfirmDialog.tsx +++ b/src/components/production/WorkerScreen/CompletionConfirmDialog.tsx @@ -16,7 +16,6 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; -import { PROCESS_LABELS } from '../ProductionDashboard/types'; import type { WorkOrder } from '../ProductionDashboard/types'; interface CompletionConfirmDialogProps { @@ -60,7 +59,7 @@ export function CompletionConfirmDialog({

공정:{' '} - {PROCESS_LABELS[order.process]} + {order.processName}

diff --git a/src/components/production/WorkerScreen/WorkCard.tsx b/src/components/production/WorkerScreen/WorkCard.tsx index 531d323a..7fccac0b 100644 --- a/src/components/production/WorkerScreen/WorkCard.tsx +++ b/src/components/production/WorkerScreen/WorkCard.tsx @@ -20,7 +20,7 @@ import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import type { WorkOrder } from '../ProductionDashboard/types'; -import { PROCESS_LABELS, STATUS_LABELS } from '../ProductionDashboard/types'; +import { STATUS_LABELS } from '../ProductionDashboard/types'; import { ProcessDetailSection } from './ProcessDetailSection'; interface WorkCardProps { @@ -103,7 +103,7 @@ export function WorkCard({ variant="outline" className="text-xs font-medium px-2.5 py-1 border-gray-300 text-gray-600 rounded" > - {PROCESS_LABELS[order.process]} + {order.processName} {order.orderNo}