fix: API 응답 래퍼 패턴 수정 및 기능 개선
- construction actions.ts 파일들 API 응답 래퍼 패턴 수정
- handover-report, order-management, site-management, structure-review
- apiClient 반환값 { success, data } 구조에 맞게 수정
- ShipmentManagement 기능 개선
- WorkerScreen 컴포넌트 수정
- .gitignore에 package-lock.json, tsconfig.tsbuildinfo 추가
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -109,3 +109,7 @@ playwright.config.ts
|
||||
playwright-report/
|
||||
test-results/
|
||||
.playwright/
|
||||
|
||||
# ---> Build artifacts
|
||||
package-lock.json
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
@@ -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<ApiHandoverReportStats>('/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<ApiHandoverReport>(`/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<ApiHandoverReport>(`/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<ApiHandoverReport>('/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: '등록에 실패했습니다.' };
|
||||
|
||||
@@ -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<ApiOrderStats>('/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<ApiOrder>(`/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<ApiOrder>(`/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<ApiOrder>(`/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: '발주 생성에 실패했습니다.' };
|
||||
|
||||
@@ -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<ApiSiteStats>('/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) {
|
||||
|
||||
@@ -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<ApiStructureReviewStats>('/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<ApiStructureReview>(`/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<StructureReview>): Pro
|
||||
}> {
|
||||
try {
|
||||
const apiData = transformToApiData(data);
|
||||
const response = await apiClient.post<ApiStructureReview>('/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<ApiStructureReview>(`/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);
|
||||
|
||||
@@ -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<ShipmentStatus, ShipmentStatus | null> = {
|
||||
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<ShipmentStatus | null>(null);
|
||||
const [isChangingStatus, setIsChangingStatus] = useState(false);
|
||||
const [statusFormData, setStatusFormData] = useState({
|
||||
loadingTime: '',
|
||||
vehicleNo: '',
|
||||
driverName: '',
|
||||
driverContact: '',
|
||||
confirmedArrival: '',
|
||||
});
|
||||
|
||||
// API 데이터 상태
|
||||
const [detail, setDetail] = useState<ShipmentDetailType | null>(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<string, string> = {};
|
||||
|
||||
// 상태별 추가 데이터 설정
|
||||
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) {
|
||||
</div>
|
||||
);
|
||||
|
||||
// 커스텀 헤더 액션 (문서 미리보기 버튼들)
|
||||
const customHeaderActions = useMemo(() => {
|
||||
// 로딩 상태 표시
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPreviewDocument('shipping')}
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-1" />
|
||||
출고증
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPreviewDocument('transaction')}
|
||||
>
|
||||
<Receipt className="w-4 h-4 mr-1" />
|
||||
거래명세서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPreviewDocument('delivery')}
|
||||
>
|
||||
<ClipboardList className="w-4 h-4 mr-1" />
|
||||
납품확인서
|
||||
</Button>
|
||||
</>
|
||||
<PageLayout>
|
||||
<ContentLoadingSpinner text="출하 정보를 불러오는 중..." />
|
||||
</PageLayout>
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 폼 내용 렌더링
|
||||
const renderFormContent = () => {
|
||||
if (!detail) return null;
|
||||
}
|
||||
|
||||
// 에러 상태 표시
|
||||
if (error || !detail) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<AlertCircle className="w-12 h-12 text-red-500" />
|
||||
<p className="text-lg text-muted-foreground">{error || '데이터를 찾을 수 없습니다.'}</p>
|
||||
<Button onClick={loadData}>다시 시도</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 수정/삭제 가능 여부 (scheduled, ready 상태에서만)
|
||||
const canEdit = detail.status === 'scheduled' || detail.status === 'ready';
|
||||
const canDelete = detail.status === 'scheduled' || detail.status === 'ready';
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Truck className="w-6 h-6" />
|
||||
<h1 className="text-xl font-semibold">출하 상세</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 문서 미리보기 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPreviewDocument('shipping')}
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-1" />
|
||||
출고증
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPreviewDocument('transaction')}
|
||||
>
|
||||
<Receipt className="w-4 h-4 mr-1" />
|
||||
거래명세서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPreviewDocument('delivery')}
|
||||
>
|
||||
<ClipboardList className="w-4 h-4 mr-1" />
|
||||
납품확인서
|
||||
</Button>
|
||||
<div className="w-px h-6 bg-border mx-2" />
|
||||
<Button variant="outline" onClick={handleGoBack}>
|
||||
목록
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button onClick={handleEdit}>
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
{/* 상태 변경 버튼 */}
|
||||
{STATUS_TRANSITIONS[detail.status] && (
|
||||
<>
|
||||
<div className="w-px h-6 bg-border mx-2" />
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
onClick={() => handleOpenStatusDialog(STATUS_TRANSITIONS[detail.status]!)}
|
||||
>
|
||||
<ArrowRight className="w-4 h-4 mr-1" />
|
||||
{SHIPMENT_STATUS_LABELS[STATUS_TRANSITIONS[detail.status]!]}으로 변경
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="space-y-6">
|
||||
{/* 출고 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -352,38 +512,10 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 에러 상태 표시
|
||||
if (!isLoading && (error || !detail)) {
|
||||
return (
|
||||
<ServerErrorPage
|
||||
title="출하 정보를 불러올 수 없습니다"
|
||||
message={error || '출하 정보를 찾을 수 없습니다.'}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedDetailTemplate
|
||||
config={dynamicConfig}
|
||||
mode="view"
|
||||
initialData={{}}
|
||||
itemId={id}
|
||||
isLoading={isLoading}
|
||||
onDelete={canDelete ? handleDelete : undefined}
|
||||
headerActions={customHeaderActions}
|
||||
renderView={() => renderFormContent()}
|
||||
renderForm={() => renderFormContent()}
|
||||
/>
|
||||
|
||||
{/* 문서 미리보기 다이얼로그 - 작업일지 모달 패턴 적용 */}
|
||||
{detail && (
|
||||
{/* 문서 미리보기 다이얼로그 - 작업일지 모달 패턴 적용 */}
|
||||
<Dialog open={previewDocument !== null} onOpenChange={() => setPreviewDocument(null)}>
|
||||
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
|
||||
{/* 접근성을 위한 숨겨진 타이틀 */}
|
||||
@@ -434,7 +566,155 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>출하 정보 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
출하번호 {detail.shipmentNo}을(를) 삭제하시겠습니까?
|
||||
<br />
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
삭제 중...
|
||||
</>
|
||||
) : (
|
||||
'삭제'
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 상태 변경 다이얼로그 */}
|
||||
<Dialog open={showStatusDialog} onOpenChange={setShowStatusDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>출하 상태 변경</DialogTitle>
|
||||
<DialogDescription>
|
||||
{detail.status && targetStatus && (
|
||||
<span className="flex items-center gap-2 mt-2">
|
||||
<Badge className={SHIPMENT_STATUS_STYLES[detail.status]}>
|
||||
{SHIPMENT_STATUS_LABELS[detail.status]}
|
||||
</Badge>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
<Badge className={SHIPMENT_STATUS_STYLES[targetStatus]}>
|
||||
{SHIPMENT_STATUS_LABELS[targetStatus]}
|
||||
</Badge>
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 출하대기로 변경 시 - 상차 시간 */}
|
||||
{targetStatus === 'ready' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="loadingTime">상차 시간 (선택)</Label>
|
||||
<Input
|
||||
id="loadingTime"
|
||||
type="datetime-local"
|
||||
value={statusFormData.loadingTime}
|
||||
onChange={(e) =>
|
||||
setStatusFormData((prev) => ({ ...prev, loadingTime: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 배송중으로 변경 시 - 차량/운전자 정보 */}
|
||||
{targetStatus === 'shipping' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vehicleNo">차량번호 (선택)</Label>
|
||||
<Input
|
||||
id="vehicleNo"
|
||||
placeholder="예: 12가 3456"
|
||||
value={statusFormData.vehicleNo}
|
||||
onChange={(e) =>
|
||||
setStatusFormData((prev) => ({ ...prev, vehicleNo: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="driverName">운전자명 (선택)</Label>
|
||||
<Input
|
||||
id="driverName"
|
||||
placeholder="운전자 이름"
|
||||
value={statusFormData.driverName}
|
||||
onChange={(e) =>
|
||||
setStatusFormData((prev) => ({ ...prev, driverName: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="driverContact">운전자 연락처 (선택)</Label>
|
||||
<Input
|
||||
id="driverContact"
|
||||
placeholder="010-0000-0000"
|
||||
value={statusFormData.driverContact}
|
||||
onChange={(e) =>
|
||||
setStatusFormData((prev) => ({ ...prev, driverContact: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 배송완료로 변경 시 - 도착 확인 시간 */}
|
||||
{targetStatus === 'completed' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmedArrival">도착 확인 시간 (선택)</Label>
|
||||
<Input
|
||||
id="confirmedArrival"
|
||||
type="datetime-local"
|
||||
value={statusFormData.confirmedArrival}
|
||||
onChange={(e) =>
|
||||
setStatusFormData((prev) => ({ ...prev, confirmedArrival: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowStatusDialog(false)}
|
||||
disabled={isChangingStatus}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleStatusChange}
|
||||
disabled={isChangingStatus}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{isChangingStatus ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
변경 중...
|
||||
</>
|
||||
) : (
|
||||
'상태 변경'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
<p>
|
||||
<span className="text-muted-foreground">공정:</span>{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{PROCESS_LABELS[order.process]}
|
||||
{order.processName}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-700">{order.orderNo}</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user