From 98b65a6ca43a6dd561b4586b1c61eddea31dec8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 16 Jan 2026 15:39:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EC=9E=91=EC=97=85=EC=A7=80?= =?UTF-8?q?=EC=8B=9C=20=EC=88=98=EC=A0=95=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=83=9D=EC=82=B0=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0?= =?UTF-8?q?=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 신규 기능: - 작업지시 수정 페이지 추가 (/production/work-orders/[id]/edit) - WorkOrderEdit 컴포넌트 신규 생성 - bulk-actions.ts 일괄 작업 유틸리티 추가 - toast-utils.ts 알림 유틸리티 추가 기능 개선: - ProductionDashboard 대시보드 액션 및 표시 개선 - WorkOrderCreate 생성 화면 개선 - WorkResultList 작업 결과 목록 타입 및 표시 개선 - EstimateDetailForm 견적 폼 개선 - QuoteRegistration 견적 등록 개선 - client-management-sales-admin 거래처 관리 개선 - error-handler.ts 에러 처리 개선 --- .../production/work-orders/[id]/edit/page.tsx | 10 + .../client-management-sales-admin/page.tsx | 7 +- .../estimates/EstimateDetailForm.tsx | 25 +- .../construction/estimates/actions.ts | 307 +++++++++++-- .../business/construction/estimates/types.ts | 12 +- .../production/ProductionDashboard/actions.ts | 4 +- .../production/ProductionDashboard/index.tsx | 12 +- .../production/WorkOrders/WorkOrderCreate.tsx | 2 +- .../production/WorkOrders/WorkOrderEdit.tsx | 402 ++++++++++++++++++ src/components/production/WorkOrders/index.ts | 1 + .../production/WorkResults/WorkResultList.tsx | 36 +- .../production/WorkResults/types.ts | 5 + src/components/quotes/QuoteRegistration.tsx | 56 ++- src/components/quotes/actions.ts | 6 +- src/lib/actions/bulk-actions.ts | 222 ++++++++++ src/lib/api/error-handler.ts | 21 +- src/lib/api/toast-utils.ts | 116 +++++ tsconfig.tsbuildinfo | 2 +- 18 files changed, 1157 insertions(+), 89 deletions(-) create mode 100644 src/app/[locale]/(protected)/production/work-orders/[id]/edit/page.tsx create mode 100644 src/components/production/WorkOrders/WorkOrderEdit.tsx create mode 100644 src/lib/actions/bulk-actions.ts create mode 100644 src/lib/api/toast-utils.ts diff --git a/src/app/[locale]/(protected)/production/work-orders/[id]/edit/page.tsx b/src/app/[locale]/(protected)/production/work-orders/[id]/edit/page.tsx new file mode 100644 index 00000000..b6656d21 --- /dev/null +++ b/src/app/[locale]/(protected)/production/work-orders/[id]/edit/page.tsx @@ -0,0 +1,10 @@ +import { WorkOrderEdit } from '@/components/production/WorkOrders'; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function WorkOrderEditPage({ params }: PageProps) { + const { id } = await params; + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx b/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx index ece2b188..c2a482ad 100644 --- a/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx +++ b/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx @@ -38,6 +38,7 @@ import { TableColumn, } from "@/components/templates/IntegratedListTemplateV2"; import { toast } from "sonner"; +import { getErrorMessage } from "@/lib/api/error-handler"; import { TableRow, TableCell, @@ -288,8 +289,7 @@ export default function CustomerAccountManagementPage() { setDeleteTargetId(null); refreshData(); } catch (err) { - const errorMessage = err instanceof Error ? err.message : "삭제 중 오류가 발생했습니다"; - toast.error(errorMessage); + toast.error(getErrorMessage(err)); } } }; @@ -335,8 +335,7 @@ export default function CustomerAccountManagementPage() { setIsBulkDeleteDialogOpen(false); refreshData(); } catch (err) { - const errorMessage = err instanceof Error ? err.message : "삭제 중 오류가 발생했습니다"; - toast.error(errorMessage); + toast.error(getErrorMessage(err)); } }; diff --git a/src/components/business/construction/estimates/EstimateDetailForm.tsx b/src/components/business/construction/estimates/EstimateDetailForm.tsx index 3d7491f8..f2ac45eb 100644 --- a/src/components/business/construction/estimates/EstimateDetailForm.tsx +++ b/src/components/business/construction/estimates/EstimateDetailForm.tsx @@ -3,7 +3,8 @@ import { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { FileText, Loader2, List } from 'lucide-react'; -import { getExpenseItemOptions, type ExpenseItemOption } from './actions'; +import { getExpenseItemOptions, updateEstimate, type ExpenseItemOption } from './actions'; +import { useAuth } from '@/contexts/AuthContext'; import { Button } from '@/components/ui/button'; import { AlertDialog, @@ -51,6 +52,7 @@ export default function EstimateDetailForm({ initialData, }: EstimateDetailFormProps) { const router = useRouter(); + const { currentUser } = useAuth(); const isViewMode = mode === 'view'; const isEditMode = mode === 'edit'; @@ -126,17 +128,26 @@ export default function EstimateDetailForm({ const handleConfirmSave = useCallback(async () => { setIsLoading(true); try { - await new Promise((resolve) => setTimeout(resolve, 1000)); - toast.success('수정이 완료되었습니다.'); - setShowSaveDialog(false); - router.push(`/ko/construction/project/bidding/estimates/${estimateId}`); - router.refresh(); + // 현재 사용자 이름을 견적자로 설정하여 저장 + const result = await updateEstimate(estimateId, { + ...formData, + estimatorName: currentUser!.name, + }); + + if (result.success) { + toast.success('수정이 완료되었습니다.'); + setShowSaveDialog(false); + router.push(`/ko/construction/project/bidding/estimates/${estimateId}`); + router.refresh(); + } else { + toast.error(result.error || '저장에 실패했습니다.'); + } } catch (error) { toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.'); } finally { setIsLoading(false); } - }, [router, estimateId]); + }, [router, estimateId, formData, currentUser]); const handleDelete = useCallback(() => { setShowDeleteDialog(true); diff --git a/src/components/business/construction/estimates/actions.ts b/src/components/business/construction/estimates/actions.ts index 44e5f5d1..174a93c1 100644 --- a/src/components/business/construction/estimates/actions.ts +++ b/src/components/business/construction/estimates/actions.ts @@ -10,10 +10,12 @@ import type { EstimateSummaryItem, ExpenseItem, PriceAdjustmentItem, + PriceAdjustmentData, EstimateDetailItem, SiteBriefingInfo, BidInfo, } from './types'; +import { getEmptyPriceAdjustmentData } from './types'; import { apiClient } from '@/lib/api'; /** @@ -56,6 +58,82 @@ interface ApiQuoteOptions { summary_items?: ApiSummaryItem[]; expense_items?: ApiExpenseItem[]; price_adjustments?: ApiPriceAdjustment[]; + price_adjustment_data?: ApiPriceAdjustmentData; + detail_items?: ApiDetailItem[]; +} + +// 견적 상세 항목 (API 응답용) +interface ApiDetailItem { + id: string; + no: number; + name: string; + material: string; + width: number; + height: number; + quantity: number; + box: number; + assembly: number; + coating: number; + batting: number; + mounting: number; + fitting: number; + controller: number; + width_construction: number; + height_construction: number; + material_cost: number; + labor_cost: number; + quantity_price: number; + expense_quantity: number; + expense_total: number; + total_cost: number; + other_cost: number; + margin_cost: number; + total_price: number; + unit_price: number; + expense: number; + margin_rate: number; + unit_quantity: number; + expense_result: number; + margin_actual: number; + // 계산 필드 + calc_weight?: number; + calc_area?: number; + calc_steel_screen?: number; + calc_caulking?: number; + calc_rail?: number; + calc_bottom?: number; + calc_box_reinforce?: number; + calc_shaft?: number; + calc_unit_price?: number; + calc_expense?: number; + // 조정단가 필드 + adjusted_caulking?: number; + adjusted_rail?: number; + adjusted_bottom?: number; + adjusted_box_reinforce?: number; + adjusted_shaft?: number; + adjusted_painting?: number; + adjusted_motor?: number; + adjusted_controller?: number; +} + +// 품목 단가 조정 (신규 구조) - API 응답용 +interface ApiPriceAdjustmentData { + caulking?: ApiPriceAdjustmentItemPrice; + rail?: ApiPriceAdjustmentItemPrice; + bottom?: ApiPriceAdjustmentItemPrice; + boxReinforce?: ApiPriceAdjustmentItemPrice; + shaft?: ApiPriceAdjustmentItemPrice; + painting?: ApiPriceAdjustmentItemPrice; + motor?: ApiPriceAdjustmentItemPrice; + controller?: ApiPriceAdjustmentItemPrice; +} + +interface ApiPriceAdjustmentItemPrice { + purchasePrice: number; + marginRate: number; + sellingPrice: number; + adjustedPrice: number; } interface ApiSummaryItem { @@ -177,6 +255,8 @@ function transformQuoteToEstimate(apiData: ApiQuote): Estimate { projectName: apiData.site_name || '', estimatorId: apiData.created_by ? String(apiData.created_by) : '', estimatorName: apiData.author || '', + estimateCompanyManager: '', // API에서 제공 시 매핑 필요 + estimateCompanyManagerContact: '', // API에서 제공 시 매핑 필요 itemCount: apiData.items?.length || 0, estimateAmount: Number(apiData.total_amount) || 0, completedDate: null, @@ -277,39 +357,109 @@ function transformQuoteToEstimateDetail(apiData: ApiQuote): EstimateDetail { total: item.total, })); - const detailItems: EstimateDetailItem[] = (apiData.items || []).map((item, index) => ({ - id: String(item.id), - no: index + 1, - name: item.item_name || '', - material: item.specification || '', - width: 0, - height: 0, - quantity: item.calculated_quantity || 0, - box: 0, - assembly: 0, - coating: 0, - batting: 0, - mounting: 0, - fitting: 0, - controller: 0, - widthConstruction: 0, - heightConstruction: 0, - materialCost: 0, - laborCost: 0, - quantityPrice: item.unit_price || 0, - expenseQuantity: 0, - expenseTotal: 0, - totalCost: item.total_price || 0, - otherCost: 0, - marginCost: 0, - totalPrice: item.total_price || 0, - unitPrice: item.unit_price || 0, - expense: 0, - marginRate: 0, - unitQuantity: item.base_quantity || 0, - expenseResult: 0, - marginActual: 0, - })); + // 품목 단가 조정 (신규 구조) - API 응답에서 변환 + const priceAdjustmentData: PriceAdjustmentData = opts?.price_adjustment_data + ? { + caulking: opts.price_adjustment_data.caulking || getEmptyPriceAdjustmentData().caulking, + rail: opts.price_adjustment_data.rail || getEmptyPriceAdjustmentData().rail, + bottom: opts.price_adjustment_data.bottom || getEmptyPriceAdjustmentData().bottom, + boxReinforce: opts.price_adjustment_data.boxReinforce || getEmptyPriceAdjustmentData().boxReinforce, + shaft: opts.price_adjustment_data.shaft || getEmptyPriceAdjustmentData().shaft, + painting: opts.price_adjustment_data.painting || getEmptyPriceAdjustmentData().painting, + motor: opts.price_adjustment_data.motor || getEmptyPriceAdjustmentData().motor, + controller: opts.price_adjustment_data.controller || getEmptyPriceAdjustmentData().controller, + } + : getEmptyPriceAdjustmentData(); + + // 견적 상세 항목 - options.detail_items 우선 사용 (저장된 전체 데이터) + // 없으면 quote_items 테이블에서 기본 정보만 가져옴 + const detailItems: EstimateDetailItem[] = opts?.detail_items + ? opts.detail_items.map((item) => ({ + id: item.id, + no: item.no, + name: item.name, + material: item.material, + width: item.width ?? 0, + height: item.height ?? 0, + quantity: item.quantity ?? 0, + box: item.box ?? 0, + assembly: item.assembly ?? 0, + coating: item.coating ?? 0, + batting: item.batting ?? 0, + mounting: item.mounting ?? 0, + fitting: item.fitting ?? 0, + controller: item.controller ?? 0, + widthConstruction: item.width_construction ?? 0, + heightConstruction: item.height_construction ?? 0, + materialCost: item.material_cost ?? 0, + laborCost: item.labor_cost ?? 0, + quantityPrice: item.quantity_price ?? 0, + expenseQuantity: item.expense_quantity ?? 0, + expenseTotal: item.expense_total ?? 0, + totalCost: item.total_cost ?? 0, + otherCost: item.other_cost ?? 0, + marginCost: item.margin_cost ?? 0, + totalPrice: item.total_price ?? 0, + unitPrice: item.unit_price ?? 0, + expense: item.expense ?? 0, + marginRate: item.margin_rate ?? 0, + unitQuantity: item.unit_quantity ?? 0, + expenseResult: item.expense_result ?? 0, + marginActual: item.margin_actual ?? 0, + // 계산 필드 + calcWeight: item.calc_weight, + calcArea: item.calc_area, + calcSteelScreen: item.calc_steel_screen, + calcCaulking: item.calc_caulking, + calcRail: item.calc_rail, + calcBottom: item.calc_bottom, + calcBoxReinforce: item.calc_box_reinforce, + calcShaft: item.calc_shaft, + calcUnitPrice: item.calc_unit_price, + calcExpense: item.calc_expense, + // 조정단가 필드 + adjustedCaulking: item.adjusted_caulking, + adjustedRail: item.adjusted_rail, + adjustedBottom: item.adjusted_bottom, + adjustedBoxReinforce: item.adjusted_box_reinforce, + adjustedShaft: item.adjusted_shaft, + adjustedPainting: item.adjusted_painting, + adjustedMotor: item.adjusted_motor, + adjustedController: item.adjusted_controller, + })) + : (apiData.items || []).map((item, index) => ({ + id: String(item.id), + no: index + 1, + name: item.item_name || '', + material: item.specification || '', + width: 0, + height: 0, + quantity: item.calculated_quantity || 0, + box: 0, + assembly: 0, + coating: 0, + batting: 0, + mounting: 0, + fitting: 0, + controller: 0, + widthConstruction: 0, + heightConstruction: 0, + materialCost: 0, + laborCost: 0, + quantityPrice: item.unit_price || 0, + expenseQuantity: 0, + expenseTotal: 0, + totalCost: item.total_price || 0, + otherCost: 0, + marginCost: 0, + totalPrice: item.total_price || 0, + unitPrice: item.unit_price || 0, + expense: 0, + marginRate: 0, + unitQuantity: item.base_quantity || 0, + expenseResult: 0, + marginActual: 0, + })); return { ...base, @@ -318,6 +468,7 @@ function transformQuoteToEstimateDetail(apiData: ApiQuote): EstimateDetail { summaryItems, expenseItems, priceAdjustments, + priceAdjustmentData, detailItems, }; } @@ -386,6 +537,68 @@ function transformToApiRequest(data: Partial): Record 0) { + options.detail_items = data.detailItems.map((item, index) => ({ + id: item.id, + no: item.no ?? index + 1, + name: item.name, + material: item.material, + width: item.width, + height: item.height, + quantity: item.quantity, + box: item.box, + assembly: item.assembly, + coating: item.coating, + batting: item.batting, + mounting: item.mounting, + fitting: item.fitting, + controller: item.controller, + width_construction: item.widthConstruction, + height_construction: item.heightConstruction, + material_cost: item.materialCost, + labor_cost: item.laborCost, + quantity_price: item.quantityPrice, + expense_quantity: item.expenseQuantity, + expense_total: item.expenseTotal, + total_cost: item.totalCost, + other_cost: item.otherCost, + margin_cost: item.marginCost, + total_price: item.totalPrice, + unit_price: item.unitPrice, + expense: item.expense, + margin_rate: item.marginRate, + unit_quantity: item.unitQuantity, + expense_result: item.expenseResult, + margin_actual: item.marginActual, + // 계산 필드 + calc_weight: item.calcWeight, + calc_area: item.calcArea, + calc_steel_screen: item.calcSteelScreen, + calc_caulking: item.calcCaulking, + calc_rail: item.calcRail, + calc_bottom: item.calcBottom, + calc_box_reinforce: item.calcBoxReinforce, + calc_shaft: item.calcShaft, + calc_unit_price: item.calcUnitPrice, + calc_expense: item.calcExpense, + // 조정단가 필드 + adjusted_caulking: item.adjustedCaulking, + adjusted_rail: item.adjustedRail, + adjusted_bottom: item.adjustedBottom, + adjusted_box_reinforce: item.adjustedBoxReinforce, + adjusted_shaft: item.adjustedShaft, + adjusted_painting: item.adjustedPainting, + adjusted_motor: item.adjustedMotor, + adjusted_controller: item.adjustedController, + })); + } + if (Object.keys(options).length > 0) { apiData.options = options; } @@ -515,9 +728,20 @@ export async function getEstimateDetail(id: string): Promise<{ }> { try { const response = await apiClient.get<{ success: boolean; data: ApiQuote }>(`/quotes/${id}`); - return { success: true, data: transformQuoteToEstimateDetail(response.data) }; + + // 🔍 디버깅: 로드된 데이터 확인 + console.log('🔍 [getEstimateDetail] API response:', response); + console.log('🔍 [getEstimateDetail] response.data:', response.data); + console.log('🔍 [getEstimateDetail] response.data.options:', response.data?.options); + + const transformed = transformQuoteToEstimateDetail(response.data); + + console.log('✅ [getEstimateDetail] transformed.detailItems:', transformed.detailItems?.length, '개'); + console.log('✅ [getEstimateDetail] transformed.priceAdjustmentData:', transformed.priceAdjustmentData); + + return { success: true, data: transformed }; } catch (error) { - console.error('견적 상세 조회 오류:', error); + console.error('❌ 견적 상세 조회 오류:', error); return { success: false, error: '견적 상세 정보를 불러오는데 실패했습니다.' }; } } @@ -596,10 +820,21 @@ export async function updateEstimate( }> { try { const apiData = transformToApiRequest(data); + + // 🔍 디버깅: 저장 데이터 확인 + console.log('🔍 [updateEstimate] formData.detailItems:', data.detailItems?.length, '개'); + console.log('🔍 [updateEstimate] formData.priceAdjustmentData:', data.priceAdjustmentData); + console.log('🔍 [updateEstimate] apiData:', apiData); + console.log('🔍 [updateEstimate] apiData.options:', (apiData as { options?: unknown }).options); + const response = await apiClient.put(`/quotes/${id}`, apiData); + + console.log('✅ [updateEstimate] response:', response); + console.log('✅ [updateEstimate] response.options:', response.options); + return { success: true, data: transformQuoteToEstimate(response) }; } catch (error) { - console.error('견적 수정 오류:', error); + console.error('❌ 견적 수정 오류:', error); return { success: false, error: '견적 수정에 실패했습니다.' }; } } diff --git a/src/components/business/construction/estimates/types.ts b/src/components/business/construction/estimates/types.ts index bf859dfc..d1b11b9a 100644 --- a/src/components/business/construction/estimates/types.ts +++ b/src/components/business/construction/estimates/types.ts @@ -120,8 +120,9 @@ export interface EstimateDetailItem { adjustedController?: number; // 제어기 조정단가 } -// 결재자 정보 - 공통 타입 re-export -export type { ApprovalPerson, ElectronicApproval } from '../common/types'; +// 결재자 정보 - 공통 타입 import 및 re-export +import type { ApprovalPerson, ElectronicApproval } from '../common/types'; +export type { ApprovalPerson, ElectronicApproval }; export { getEmptyElectronicApproval } from '../common/types'; // 현장설명회 정보 (견적 상세용) @@ -168,9 +169,12 @@ export interface EstimateDetail extends Estimate { // 공과 상세 expenseItems: ExpenseItem[]; - // 품목 단가 조정 + // 품목 단가 조정 (레거시) priceAdjustments: PriceAdjustmentItem[]; + // 품목 단가 조정 (신규 구조) + priceAdjustmentData?: PriceAdjustmentData; + // 견적 상세 테이블 detailItems: EstimateDetailItem[]; @@ -304,7 +308,7 @@ export function estimateDetailToFormData(detail: EstimateDetail): EstimateDetail summaryMemo: '', expenseItems: detail.expenseItems, priceAdjustments: detail.priceAdjustments, - priceAdjustmentData: getEmptyPriceAdjustmentData(), + priceAdjustmentData: detail.priceAdjustmentData || getEmptyPriceAdjustmentData(), detailItems: detail.detailItems, approval: detail.approval || { approvers: [], references: [] }, }; diff --git a/src/components/production/ProductionDashboard/actions.ts b/src/components/production/ProductionDashboard/actions.ts index 5814b2ae..87caa7af 100644 --- a/src/components/production/ProductionDashboard/actions.ts +++ b/src/components/production/ProductionDashboard/actions.ts @@ -30,6 +30,8 @@ interface WorkOrderApiItem { sales_order?: { id: number; order_no: string; + client_id?: number; + client_name?: string; client?: { id: number; name: string }; }; assignee?: { id: number; name: string }; @@ -75,7 +77,7 @@ function transformToProductionFormat(api: WorkOrderApiItem): WorkOrder { productName, processCode: api.process?.process_code || '-', processName: api.process?.process_name || '-', - client: api.sales_order?.client?.name || '-', + client: api.sales_order?.client_name || api.sales_order?.client?.name || '-', projectName: api.project_name || '-', assignees: api.assignee ? [api.assignee.name] : [], quantity: totalQuantity, diff --git a/src/components/production/ProductionDashboard/index.tsx b/src/components/production/ProductionDashboard/index.tsx index 5bd7f29f..e74b2190 100644 --- a/src/components/production/ProductionDashboard/index.tsx +++ b/src/components/production/ProductionDashboard/index.tsx @@ -117,9 +117,8 @@ export default function ProductionDashboard() { ); // ===== 핸들러 ===== - const handleOrderClick = (orderNo: string) => { - // orderNo (예: KD-WO-251217-12)로 상세 페이지 이동 - router.push(`/ko/production/work-orders/${encodeURIComponent(orderNo)}`); + const handleOrderClick = (id: string) => { + router.push(`/ko/production/work-orders/${id}`); }; const handleWorkerScreenClick = () => { @@ -236,7 +235,7 @@ export default function ProductionDashboard() { handleOrderClick(order.orderNo)} + onClick={() => handleOrderClick(order.id)} /> )) )} @@ -264,7 +263,7 @@ export default function ProductionDashboard() { handleOrderClick(order.orderNo)} + onClick={() => handleOrderClick(order.id)} showDelay /> )) @@ -355,6 +354,9 @@ function WorkOrderCard({ order, onClick, showDelay }: WorkOrderCardProps) {

{order.productName}

{order.client}

+ {order.assignees.length > 0 && ( +

담당: {order.assignees.join(', ')}

+ )}
diff --git a/src/components/production/WorkOrders/WorkOrderCreate.tsx b/src/components/production/WorkOrders/WorkOrderCreate.tsx index 41f5650f..8c9d5bad 100644 --- a/src/components/production/WorkOrders/WorkOrderCreate.tsx +++ b/src/components/production/WorkOrders/WorkOrderCreate.tsx @@ -284,7 +284,7 @@ export function WorkOrderCreate() {
diff --git a/src/components/production/WorkOrders/WorkOrderEdit.tsx b/src/components/production/WorkOrders/WorkOrderEdit.tsx new file mode 100644 index 00000000..430d3c59 --- /dev/null +++ b/src/components/production/WorkOrders/WorkOrderEdit.tsx @@ -0,0 +1,402 @@ +'use client'; + +/** + * 작업지시 수정 페이지 + * WorkOrderCreate 패턴 기반 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { ArrowLeft, FileText, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { PageLayout } from '@/components/organisms/PageLayout'; +import { AssigneeSelectModal } from './AssigneeSelectModal'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; +import { toast } from 'sonner'; +import { isNextRedirectError } from '@/lib/utils/redirect-error'; +import { getWorkOrderById, updateWorkOrder, getProcessOptions, type ProcessOption } from './actions'; +import type { WorkOrder } from './types'; + +// Validation 에러 타입 +interface ValidationErrors { + [key: string]: string; +} + +// 필드명 매핑 +const FIELD_NAME_MAP: Record = { + processId: '공정', + scheduledDate: '출고예정일', +}; + +interface FormData { + // 기본 정보 (읽기 전용) + client: string; + projectName: string; + orderNo: string; + itemCount: number; + + // 수정 가능 정보 + processId: number | null; + scheduledDate: string; + priority: number; + assignees: string[]; + + // 비고 + note: string; +} + +interface WorkOrderEditProps { + orderId: string; +} + +export function WorkOrderEdit({ orderId }: WorkOrderEditProps) { + const router = useRouter(); + const [workOrder, setWorkOrder] = useState(null); + const [formData, setFormData] = useState({ + client: '', + projectName: '', + orderNo: '', + itemCount: 0, + processId: null, + scheduledDate: '', + priority: 5, + assignees: [], + note: '', + }); + const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false); + const [assigneeNames, setAssigneeNames] = useState([]); + const [validationErrors, setValidationErrors] = useState({}); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [processOptions, setProcessOptions] = useState([]); + const [isLoadingProcesses, setIsLoadingProcesses] = useState(true); + + // 데이터 로드 + const loadData = useCallback(async () => { + setIsLoading(true); + try { + const [orderResult, processResult] = await Promise.all([ + getWorkOrderById(orderId), + getProcessOptions(), + ]); + + if (orderResult.success && orderResult.data) { + const order = orderResult.data; + setWorkOrder(order); + setFormData({ + client: order.client, + projectName: order.projectName, + orderNo: order.lotNo, + itemCount: order.items?.length || 0, + processId: order.processId, + scheduledDate: order.scheduledDate || '', + priority: order.priority || 5, + assignees: order.assignees?.map(a => a.id) || [], + note: order.note || '', + }); + // 담당자 이름 설정 + if (order.assignees) { + setAssigneeNames(order.assignees.map(a => a.name)); + } + } else { + toast.error(orderResult.error || '작업지시 조회에 실패했습니다.'); + router.push('/production/work-orders'); + return; + } + + if (processResult.success) { + setProcessOptions(processResult.data); + } else { + toast.error(processResult.error || '공정 목록을 불러오는데 실패했습니다.'); + } + setIsLoadingProcesses(false); + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[WorkOrderEdit] loadData error:', error); + toast.error('데이터 로드 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + }, [orderId, router]); + + useEffect(() => { + loadData(); + }, [loadData]); + + // 폼 제출 + const handleSubmit = async () => { + // Validation 체크 + const errors: ValidationErrors = {}; + + if (!formData.processId) { + errors.processId = '공정을 선택해주세요'; + } + + if (!formData.scheduledDate) { + errors.scheduledDate = '출고예정일을 선택해주세요'; + } + + // 에러가 있으면 상태 업데이트 후 리턴 + if (Object.keys(errors).length > 0) { + setValidationErrors(errors); + window.scrollTo({ top: 0, behavior: 'smooth' }); + return; + } + + // 에러 초기화 + setValidationErrors({}); + setIsSubmitting(true); + + try { + // 담당자 ID 배열 변환 (string[] → number[]) + const assigneeIds = formData.assignees + .map(id => parseInt(id, 10)) + .filter(id => !isNaN(id)); + + const result = await updateWorkOrder(orderId, { + projectName: formData.projectName, + processId: formData.processId!, + scheduledDate: formData.scheduledDate, + priority: formData.priority, + assigneeIds: assigneeIds.length > 0 ? assigneeIds : undefined, + note: formData.note || undefined, + }); + + if (result.success) { + toast.success('작업지시가 수정되었습니다.'); + router.push(`/production/work-orders/${orderId}`); + } else { + toast.error(result.error || '작업지시 수정에 실패했습니다.'); + } + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[WorkOrderEdit] handleSubmit error:', error); + toast.error('작업지시 수정 중 오류가 발생했습니다.'); + } finally { + setIsSubmitting(false); + } + }; + + // 취소 + const handleCancel = () => { + router.back(); + }; + + // 선택된 공정의 코드 가져오기 + const getSelectedProcessCode = (): string => { + const selectedProcess = processOptions.find(p => p.id === formData.processId); + return selectedProcess?.processCode || '-'; + }; + + // 로딩 상태 + if (isLoading) { + return ; + } + + if (!workOrder) { + return null; + } + + return ( + + {/* 헤더 */} +
+
+ +

+ + 작업지시 수정 +

+ ({workOrder.workOrderNo}) +
+
+ + +
+
+ +
+ {/* Validation 에러 표시 */} + {Object.keys(validationErrors).length > 0 && ( + + +
+ ⚠️ +
+ + 입력 내용을 확인해주세요 ({Object.keys(validationErrors).length}개 오류) + +
    + {Object.entries(validationErrors).map(([field, message]) => { + const fieldName = FIELD_NAME_MAP[field] || field; + return ( +
  • + + + {fieldName}: {message} + +
  • + ); + })} +
+
+
+
+
+ )} + + {/* 기본 정보 (읽기 전용) */} +
+

기본 정보

+
+
+ + +
+
+ + setFormData({ ...formData, projectName: e.target.value })} + className="bg-white" + /> +
+
+ + +
+
+ + +
+
+
+ + {/* 작업지시 정보 */} +
+

작업지시 정보

+
+
+ + +

+ 공정코드: {getSelectedProcessCode()} +

+
+ +
+ + setFormData({ ...formData, scheduledDate: e.target.value })} + className="bg-white" + /> +
+ +
+ + +
+ +
+ +
setIsAssigneeModalOpen(true)} + className="flex min-h-10 w-full cursor-pointer items-center rounded-md border border-input bg-white px-3 py-2 text-sm ring-offset-background hover:bg-accent/50" + > + {assigneeNames.length > 0 ? ( + {assigneeNames.join(', ')} + ) : ( + 담당자를 선택하세요 (팀/개인) + )} +
+
+
+
+ + {/* 비고 */} +
+

비고

+