feat(WEB): 작업지시 수정 페이지 및 생산 관리 기능 개선
신규 기능: - 작업지시 수정 페이지 추가 (/production/work-orders/[id]/edit) - WorkOrderEdit 컴포넌트 신규 생성 - bulk-actions.ts 일괄 작업 유틸리티 추가 - toast-utils.ts 알림 유틸리티 추가 기능 개선: - ProductionDashboard 대시보드 액션 및 표시 개선 - WorkOrderCreate 생성 화면 개선 - WorkResultList 작업 결과 목록 타입 및 표시 개선 - EstimateDetailForm 견적 폼 개선 - QuoteRegistration 견적 등록 개선 - client-management-sales-admin 거래처 관리 개선 - error-handler.ts 에러 처리 개선
This commit is contained in:
@@ -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 <WorkOrderEdit orderId={id} />;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<EstimateDetailFormData>): Record<st
|
||||
}));
|
||||
}
|
||||
|
||||
// 품목 단가 조정 (신규 구조) 저장
|
||||
if (data.priceAdjustmentData !== undefined) {
|
||||
options.price_adjustment_data = data.priceAdjustmentData;
|
||||
}
|
||||
|
||||
// 견적 상세 (detailItems) 저장
|
||||
if (data.detailItems !== undefined && data.detailItems.length > 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<ApiQuote>(`/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: '견적 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [] },
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
<WorkOrderCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
onClick={() => handleOrderClick(order.orderNo)}
|
||||
onClick={() => handleOrderClick(order.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -264,7 +263,7 @@ export default function ProductionDashboard() {
|
||||
<WorkOrderCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
onClick={() => handleOrderClick(order.orderNo)}
|
||||
onClick={() => handleOrderClick(order.id)}
|
||||
showDelay
|
||||
/>
|
||||
))
|
||||
@@ -355,6 +354,9 @@ function WorkOrderCard({ order, onClick, showDelay }: WorkOrderCardProps) {
|
||||
</div>
|
||||
<p className="text-sm font-medium mt-1 truncate">{order.productName}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{order.client}</p>
|
||||
{order.assignees.length > 0 && (
|
||||
<p className="text-xs text-blue-600 truncate">담당: {order.assignees.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
|
||||
@@ -284,7 +284,7 @@ export function WorkOrderCreate() {
|
||||
<Label htmlFor="manual" className="cursor-pointer">
|
||||
수동 등록{' '}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
(수주 없이 직접 입력)
|
||||
(재고생산)
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
402
src/components/production/WorkOrders/WorkOrderEdit.tsx
Normal file
402
src/components/production/WorkOrders/WorkOrderEdit.tsx
Normal file
@@ -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<string, string> = {
|
||||
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<WorkOrder | null>(null);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
client: '',
|
||||
projectName: '',
|
||||
orderNo: '',
|
||||
itemCount: 0,
|
||||
processId: null,
|
||||
scheduledDate: '',
|
||||
priority: 5,
|
||||
assignees: [],
|
||||
note: '',
|
||||
});
|
||||
const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false);
|
||||
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [processOptions, setProcessOptions] = useState<ProcessOption[]>([]);
|
||||
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 <ContentLoadingSpinner text="작업지시 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
if (!workOrder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={handleCancel}>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
작업지시 수정
|
||||
</h1>
|
||||
<span className="text-muted-foreground">({workOrder.workOrderNo})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isSubmitting}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="w-4 h-4 mr-1.5 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{Object.keys(validationErrors).length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({Object.keys(validationErrors).length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(validationErrors).map(([field, message]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {message}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 기본 정보 (읽기 전용) */}
|
||||
<section className="bg-muted/30 border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">기본 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>발주처</Label>
|
||||
<Input
|
||||
value={formData.client}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>현장명</Label>
|
||||
<Input
|
||||
value={formData.projectName}
|
||||
onChange={(e) => setFormData({ ...formData, projectName: e.target.value })}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>수주번호</Label>
|
||||
<Input
|
||||
value={formData.orderNo}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>품목수</Label>
|
||||
<Input
|
||||
value={formData.itemCount || '-'}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 작업지시 정보 */}
|
||||
<section className="bg-muted/30 border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">작업지시 정보</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>공정구분 *</Label>
|
||||
<Select
|
||||
value={formData.processId?.toString() || ''}
|
||||
onValueChange={(value) => setFormData({ ...formData, processId: parseInt(value) })}
|
||||
disabled={isLoadingProcesses}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue placeholder={isLoadingProcesses ? '로딩 중...' : '공정을 선택하세요'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{processOptions.map((process) => (
|
||||
<SelectItem key={process.id} value={process.id.toString()}>
|
||||
{process.processName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
공정코드: {getSelectedProcessCode()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>출고예정일 *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.scheduledDate}
|
||||
onChange={(e) => setFormData({ ...formData, scheduledDate: e.target.value })}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>우선순위 (1=긴급, 9=낮음)</Label>
|
||||
<Select
|
||||
value={formData.priority.toString()}
|
||||
onValueChange={(value) => setFormData({ ...formData, priority: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
|
||||
<SelectItem key={n} value={n.toString()}>
|
||||
{n} {n === 5 ? '(일반)' : n === 1 ? '(긴급)' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>담당자 (다중선택 가능)</Label>
|
||||
<div
|
||||
onClick={() => 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 ? (
|
||||
<span>{assigneeNames.join(', ')}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">담당자를 선택하세요 (팀/개인)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 비고 */}
|
||||
<section className="bg-muted/30 border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">비고</h3>
|
||||
<Textarea
|
||||
value={formData.note}
|
||||
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
|
||||
placeholder="특이사항이나 메모를 입력하세요"
|
||||
rows={4}
|
||||
className="bg-white"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 담당자 선택 모달 */}
|
||||
<AssigneeSelectModal
|
||||
open={isAssigneeModalOpen}
|
||||
onOpenChange={setIsAssigneeModalOpen}
|
||||
selectedIds={formData.assignees}
|
||||
onSelect={(ids, names) => {
|
||||
setFormData({ ...formData, assignees: ids });
|
||||
setAssigneeNames(names);
|
||||
}}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export * from './types';
|
||||
export { WorkOrderList } from './WorkOrderList';
|
||||
export { WorkOrderCreate } from './WorkOrderCreate';
|
||||
export { WorkOrderDetail } from './WorkOrderDetail';
|
||||
export { WorkOrderEdit } from './WorkOrderEdit';
|
||||
export { SalesOrderSelectModal } from './SalesOrderSelectModal';
|
||||
|
||||
// Server Actions (API 연동)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
BarChart3,
|
||||
Package,
|
||||
@@ -36,6 +37,8 @@ import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export function WorkResultList() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
@@ -177,11 +180,10 @@ export function WorkResultList() {
|
||||
// TODO: 엑셀 다운로드 기능 구현
|
||||
}, [workResults]);
|
||||
|
||||
// 상세 보기
|
||||
const handleView = useCallback((id: string) => {
|
||||
console.log('상세 보기:', id);
|
||||
// TODO: 상세 보기 기능 구현
|
||||
}, []);
|
||||
// 상세 보기 (작업지시 상세 페이지로 이동)
|
||||
const handleView = useCallback((workOrderId: number) => {
|
||||
router.push(`/production/work-orders/${workOrderId}`);
|
||||
}, [router]);
|
||||
|
||||
// 작업일 포맷팅 (날짜만 표시)
|
||||
const formatWorkDate = (dateStr: string) => {
|
||||
@@ -197,7 +199,7 @@ export function WorkResultList() {
|
||||
<TableRow
|
||||
key={result.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleView(result.id)}
|
||||
onClick={() => handleView(result.workOrderId)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
@@ -216,16 +218,16 @@ export function WorkResultList() {
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">{result.productName}</TableCell>
|
||||
<TableCell>{result.specification}</TableCell>
|
||||
<TableCell className="text-center">{result.productionQty}</TableCell>
|
||||
<TableCell className="text-center">{result.goodQty}</TableCell>
|
||||
<TableCell className="text-center">{Math.floor(result.productionQty)}</TableCell>
|
||||
<TableCell className="text-center">{Math.floor(result.goodQty)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={result.defectQty > 0 ? 'text-red-600 font-medium' : ''}>
|
||||
{result.defectQty}
|
||||
{Math.floor(result.defectQty)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={result.defectRate > 0 ? 'text-red-600 font-medium' : ''}>
|
||||
{result.defectRate}%
|
||||
{result.defectRate.toFixed(1)}%
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
@@ -242,7 +244,7 @@ export function WorkResultList() {
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{result.workerId ? `#${result.workerId}` : '-'}</TableCell>
|
||||
<TableCell>{result.workerName || '-'}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
@@ -260,7 +262,7 @@ export function WorkResultList() {
|
||||
id={result.id}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onClick={() => handleView(result.id)}
|
||||
onClick={() => handleView(result.workOrderId)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
|
||||
@@ -278,17 +280,17 @@ export function WorkResultList() {
|
||||
<InfoField label="작업일" value={formatWorkDate(result.workDate)} />
|
||||
<InfoField label="작업지시번호" value={result.workOrderNo} />
|
||||
<InfoField label="규격" value={result.specification} />
|
||||
<InfoField label="작업자" value={result.workerId ? `#${result.workerId}` : '-'} />
|
||||
<InfoField label="생산수량" value={`${result.productionQty}개`} />
|
||||
<InfoField label="양품수량" value={`${result.goodQty}개`} />
|
||||
<InfoField label="작업자" value={result.workerName || '-'} />
|
||||
<InfoField label="생산수량" value={`${Math.floor(result.productionQty)}개`} />
|
||||
<InfoField label="양품수량" value={`${Math.floor(result.goodQty)}개`} />
|
||||
<InfoField
|
||||
label="불량수량"
|
||||
value={`${result.defectQty}개`}
|
||||
value={`${Math.floor(result.defectQty)}개`}
|
||||
className={result.defectQty > 0 ? 'text-red-600' : ''}
|
||||
/>
|
||||
<InfoField
|
||||
label="불량률"
|
||||
value={`${result.defectRate}%`}
|
||||
value={`${result.defectRate.toFixed(1)}%`}
|
||||
className={result.defectRate > 0 ? 'text-red-600' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// 작업실적 데이터 (Frontend)
|
||||
export interface WorkResult {
|
||||
id: string;
|
||||
workOrderId: number; // 작업지시 ID
|
||||
lotNo: string; // 로트번호
|
||||
workDate: string; // 작업 완료일
|
||||
workOrderNo: string; // 작업지시번호
|
||||
@@ -23,6 +24,7 @@ export interface WorkResult {
|
||||
inspection: boolean; // 검사 완료
|
||||
packaging: boolean; // 포장 완료
|
||||
workerId: number | null; // 작업자 ID
|
||||
workerName: string | null; // 작업자 이름
|
||||
memo: string | null; // 메모
|
||||
}
|
||||
|
||||
@@ -66,6 +68,7 @@ export interface WorkResultApi {
|
||||
} | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
worker_name?: string | null; // 작업자 이름 (백엔드에서 추가)
|
||||
// Relations
|
||||
work_order?: {
|
||||
id: number;
|
||||
@@ -108,6 +111,7 @@ export function transformApiToFrontend(api: WorkResultApi): WorkResult {
|
||||
|
||||
return {
|
||||
id: String(api.id),
|
||||
workOrderId: api.work_order_id,
|
||||
lotNo: result?.lot_no || '-',
|
||||
workDate: result?.completed_at || api.updated_at,
|
||||
workOrderNo: api.work_order?.work_order_no || '-',
|
||||
@@ -124,6 +128,7 @@ export function transformApiToFrontend(api: WorkResultApi): WorkResult {
|
||||
inspection: result?.is_inspected ?? false,
|
||||
packaging: result?.is_packaged ?? false,
|
||||
workerId: result?.worker_id ?? null,
|
||||
workerName: api.worker_name ?? null,
|
||||
memo: result?.memo ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,6 +178,9 @@ export function QuoteRegistration({
|
||||
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
|
||||
// 카테고리별 완제품 캐시 (API 재호출 최소화)
|
||||
const [categoryProductsCache, setCategoryProductsCache] = useState<Record<string, FinishedGoods[]>>({});
|
||||
|
||||
// 거래처 목록 상태 (API에서 로드)
|
||||
const [clients, setClients] = useState<Vendor[]>([]);
|
||||
const [isLoadingClients, setIsLoadingClients] = useState(false);
|
||||
@@ -203,14 +206,20 @@ export function QuoteRegistration({
|
||||
}, 0);
|
||||
}, [calculationResults, formData.items]);
|
||||
|
||||
// 컴포넌트 마운트 시 완제품 목록 로드
|
||||
// 컴포넌트 마운트 시 완제품 목록 로드 (초기 로드는 size 제한 없이 - 카테고리별 호출로 대체됨)
|
||||
useEffect(() => {
|
||||
const loadFinishedGoods = async () => {
|
||||
const loadInitialProducts = async () => {
|
||||
setIsLoadingProducts(true);
|
||||
try {
|
||||
// 초기에는 ALL 카테고리로 로드 (size 제한 내에서)
|
||||
const result = await getFinishedGoods();
|
||||
if (result.success) {
|
||||
setFinishedGoods(result.data);
|
||||
// 캐시에도 저장
|
||||
setCategoryProductsCache(prev => ({
|
||||
...prev,
|
||||
"ALL": result.data
|
||||
}));
|
||||
} else {
|
||||
toast.error(`완제품 목록 로드 실패: ${result.error}`);
|
||||
}
|
||||
@@ -221,7 +230,7 @@ export function QuoteRegistration({
|
||||
setIsLoadingProducts(false);
|
||||
}
|
||||
};
|
||||
loadFinishedGoods();
|
||||
loadInitialProducts();
|
||||
}, []);
|
||||
|
||||
// 컴포넌트 마운트 시 거래처 목록 로드
|
||||
@@ -280,12 +289,41 @@ export function QuoteRegistration({
|
||||
}
|
||||
}, [editingQuote]);
|
||||
|
||||
// 카테고리별 완제품 필터링
|
||||
// 카테고리별 완제품 로드 (API 호출)
|
||||
const loadProductsByCategory = async (category: string) => {
|
||||
// 이미 캐시에 있으면 스킵
|
||||
if (categoryProductsCache[category]) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingProducts(true);
|
||||
try {
|
||||
// 카테고리가 ALL이면 전체, 아니면 해당 카테고리만 조회
|
||||
const result = await getFinishedGoods(category === "ALL" ? undefined : category);
|
||||
if (result.success) {
|
||||
setCategoryProductsCache(prev => ({
|
||||
...prev,
|
||||
[category]: result.data
|
||||
}));
|
||||
} else {
|
||||
toast.error(`완제품 목록 로드 실패: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
toast.error("완제품 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoadingProducts(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리별 완제품 조회 (캐시 기반)
|
||||
const getFilteredProducts = (category: string) => {
|
||||
if (!category || category === "ALL") {
|
||||
return finishedGoods; // 전체 선택 시 모든 완제품
|
||||
// 전체 선택 시 캐시된 ALL 데이터 또는 초기 finishedGoods
|
||||
return categoryProductsCache["ALL"] || finishedGoods;
|
||||
}
|
||||
return finishedGoods.filter(fg => fg.item_category === category);
|
||||
// 카테고리별 캐시 반환
|
||||
return categoryProductsCache[category] || [];
|
||||
};
|
||||
|
||||
// 유효성 검사
|
||||
@@ -395,9 +433,11 @@ export function QuoteRegistration({
|
||||
const newItems = [...formData.items];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
|
||||
// 제품 카테고리 변경 시 제품명 초기화
|
||||
if (field === "productCategory") {
|
||||
// 제품 카테고리 변경 시 제품명 초기화 및 해당 카테고리 제품 로드
|
||||
if (field === "productCategory" && typeof value === "string") {
|
||||
newItems[index].productName = "";
|
||||
// 해당 카테고리 제품 목록 API 호출 (캐시 없으면)
|
||||
loadProductsByCategory(value);
|
||||
}
|
||||
|
||||
setFormData({ ...formData, items: newItems });
|
||||
|
||||
@@ -810,7 +810,7 @@ export async function getFinishedGoods(category?: string): Promise<{
|
||||
if (category) {
|
||||
searchParams.set('item_category', category);
|
||||
}
|
||||
searchParams.set('size', '1000'); // 전체 조회
|
||||
searchParams.set('size', '5000'); // 전체 조회 (테넌트별 FG 품목 수에 따라 조정)
|
||||
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?${searchParams.toString()}`;
|
||||
|
||||
@@ -863,8 +863,8 @@ export async function getFinishedGoods(category?: string): Promise<{
|
||||
success: true,
|
||||
data: items.map((item: Record<string, unknown>) => ({
|
||||
id: item.id,
|
||||
item_code: item.code as string, // Item 모델은 'code' 필드 사용
|
||||
item_name: item.name as string, // Item 모델은 'name' 필드 사용
|
||||
item_code: (item.item_code || item.code) as string, // API가 code → item_code로 변환
|
||||
item_name: item.name as string,
|
||||
item_category: (item.item_category as string) || '',
|
||||
specification: item.specification as string | undefined,
|
||||
unit: item.unit as string | undefined,
|
||||
|
||||
222
src/lib/actions/bulk-actions.ts
Normal file
222
src/lib/actions/bulk-actions.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
'use server';
|
||||
|
||||
/**
|
||||
* 일괄 작업 공통 액션
|
||||
*
|
||||
* 여러 모듈에서 공통으로 사용하는 일괄 작업 함수:
|
||||
* - 계정과목 일괄 변경 (출금/입금/매출)
|
||||
* - 엑셀 내보내기 (근태/급여)
|
||||
*/
|
||||
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
// ============================================
|
||||
// 계정과목 일괄 변경
|
||||
// ============================================
|
||||
|
||||
export interface BulkUpdateAccountCodeResult {
|
||||
success: boolean;
|
||||
updatedCount?: number;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 일괄 변경 (범용)
|
||||
*
|
||||
* @param endpoint - API 엔드포인트 (예: '/api/v1/withdrawals/bulk-update-account-code')
|
||||
* @param ids - 변경할 레코드 ID 배열
|
||||
* @param accountCode - 새 계정과목 코드
|
||||
*/
|
||||
export async function bulkUpdateAccountCode(
|
||||
endpoint: string,
|
||||
ids: string[],
|
||||
accountCode: string
|
||||
): Promise<BulkUpdateAccountCodeResult> {
|
||||
try {
|
||||
const url = `${API_URL}${endpoint}`;
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
ids: ids.map((id) => parseInt(id, 10)),
|
||||
account_code: accountCode,
|
||||
}),
|
||||
});
|
||||
|
||||
if (error?.__authError) {
|
||||
return { success: false, __authError: true };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: error?.message || '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '계정과목 변경에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
updatedCount: result.data?.updated_count || ids.length,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[BulkActions] bulkUpdateAccountCode error:', err);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 엑셀 내보내기
|
||||
// ============================================
|
||||
|
||||
export interface ExportResult {
|
||||
success: boolean;
|
||||
data?: Blob;
|
||||
filename?: string;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 내보내기 (범용)
|
||||
*
|
||||
* 서버에서 엑셀 파일을 생성하고 Blob으로 반환합니다.
|
||||
* 클라이언트에서 downloadBlob() 유틸리티로 다운로드 처리합니다.
|
||||
*
|
||||
* @param endpoint - API 엔드포인트 (예: '/api/v1/attendances/export')
|
||||
* @param params - 쿼리 파라미터 (선택)
|
||||
* @param filename - 다운로드 파일명 (선택)
|
||||
*/
|
||||
export async function exportToExcel(
|
||||
endpoint: string,
|
||||
params?: Record<string, string | number | boolean | undefined>,
|
||||
filename?: string
|
||||
): Promise<ExportResult> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('access_token')?.value;
|
||||
|
||||
if (!token) {
|
||||
return { success: false, __authError: true };
|
||||
}
|
||||
|
||||
// 쿼리 파라미터 생성
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${API_URL}${endpoint}${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
// 401 인증 에러
|
||||
if (response.status === 401) {
|
||||
return { success: false, __authError: true };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// JSON 에러 응답인 경우
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
const errorData = await response.json();
|
||||
return { success: false, error: errorData.message || '내보내기에 실패했습니다.' };
|
||||
}
|
||||
return { success: false, error: `내보내기 실패: ${response.status}` };
|
||||
}
|
||||
|
||||
// Content-Disposition 헤더에서 파일명 추출
|
||||
const contentDisposition = response.headers.get('content-disposition');
|
||||
let extractedFilename = filename;
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||
if (match && match[1]) {
|
||||
extractedFilename = match[1].replace(/['"]/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: blob,
|
||||
filename: extractedFilename,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[BulkActions] exportToExcel error:', err);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 모듈별 편의 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 출금 계정과목 일괄 변경
|
||||
*/
|
||||
export async function bulkUpdateWithdrawalAccountCode(
|
||||
ids: string[],
|
||||
accountCode: string
|
||||
): Promise<BulkUpdateAccountCodeResult> {
|
||||
return bulkUpdateAccountCode('/api/v1/withdrawals/bulk-update-account-code', ids, accountCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 입금 계정과목 일괄 변경
|
||||
*/
|
||||
export async function bulkUpdateDepositAccountCode(
|
||||
ids: string[],
|
||||
accountCode: string
|
||||
): Promise<BulkUpdateAccountCodeResult> {
|
||||
return bulkUpdateAccountCode('/api/v1/deposits/bulk-update-account-code', ids, accountCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매출 계정과목 일괄 변경
|
||||
*/
|
||||
export async function bulkUpdateSaleAccountCode(
|
||||
ids: string[],
|
||||
accountCode: string
|
||||
): Promise<BulkUpdateAccountCodeResult> {
|
||||
return bulkUpdateAccountCode('/api/v1/sales/bulk-update-account-code', ids, accountCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 근태 엑셀 내보내기
|
||||
*/
|
||||
export async function exportAttendances(
|
||||
params?: { year?: number; month?: number; user_id?: string; department_id?: string }
|
||||
): Promise<ExportResult> {
|
||||
return exportToExcel('/api/v1/attendances/export', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 엑셀 내보내기
|
||||
*/
|
||||
export async function exportSalaries(
|
||||
params?: { year?: number; month?: number; status?: string }
|
||||
): Promise<ExportResult> {
|
||||
return exportToExcel('/api/v1/salaries/export', params);
|
||||
}
|
||||
@@ -104,14 +104,31 @@ export const handleApiError = async (response: Response): Promise<never> => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 디버그 모드 설정
|
||||
* - true: 에러 코드 표시 (개발/테스트)
|
||||
* - false: 메시지만 표시 (프로덕션)
|
||||
*
|
||||
* TODO: 프로덕션 배포 시 false로 변경하거나 환경변수 사용
|
||||
*/
|
||||
const SHOW_ERROR_CODE = true;
|
||||
|
||||
/**
|
||||
* 에러 객체에서 사용자 친화적인 메시지 추출
|
||||
* @param error - 발생한 에러 객체 (ApiError, Error, unknown)
|
||||
* @param includeCode - 에러 코드 포함 여부 (기본값: SHOW_ERROR_CODE 설정 따름)
|
||||
* @returns 사용자에게 표시할 에러 메시지
|
||||
*/
|
||||
export const getErrorMessage = (error: unknown): string => {
|
||||
export const getErrorMessage = (error: unknown, includeCode?: boolean): string => {
|
||||
const showCode = includeCode ?? SHOW_ERROR_CODE;
|
||||
|
||||
if (error instanceof DuplicateCodeError) {
|
||||
return showCode
|
||||
? `[${error.status}] ${error.message} (코드: ${error.duplicateCode})`
|
||||
: error.message;
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
return error.message;
|
||||
return showCode ? `[${error.status}] ${error.message}` : error.message;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
|
||||
116
src/lib/api/toast-utils.ts
Normal file
116
src/lib/api/toast-utils.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* API 에러 토스트 유틸리티
|
||||
* - 개발 중 디버깅을 위해 에러 코드와 메시지를 함께 표시
|
||||
* - 나중에 프로덕션에서 코드 숨기려면 이 파일만 수정하면 됨
|
||||
*/
|
||||
import { toast } from 'sonner';
|
||||
import { ApiError, DuplicateCodeError, getErrorMessage } from './error-handler';
|
||||
|
||||
/**
|
||||
* 디버그 모드 설정
|
||||
* - true: 에러 코드 표시 (개발/테스트)
|
||||
* - false: 메시지만 표시 (프로덕션)
|
||||
*
|
||||
* TODO: 프로덕션 배포 시 false로 변경하거나 환경변수 사용
|
||||
*/
|
||||
const SHOW_ERROR_CODE = true;
|
||||
|
||||
/**
|
||||
* API 에러를 토스트로 표시
|
||||
* - ApiError: [상태코드] 메시지 형식
|
||||
* - DuplicateCodeError: 중복 코드 정보 포함
|
||||
* - 일반 Error: 메시지만 표시
|
||||
*
|
||||
* @param error - 발생한 에러 객체
|
||||
* @param fallbackMessage - 에러 메시지가 없을 때 표시할 기본 메시지
|
||||
*/
|
||||
export function toastApiError(
|
||||
error: unknown,
|
||||
fallbackMessage = '오류가 발생했습니다.'
|
||||
): void {
|
||||
// DuplicateCodeError - 중복 코드 에러 (별도 처리 필요할 수 있음)
|
||||
if (error instanceof DuplicateCodeError) {
|
||||
const message = SHOW_ERROR_CODE
|
||||
? `[${error.status}] ${error.message} (코드: ${error.duplicateCode})`
|
||||
: error.message;
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// ApiError - HTTP 에러
|
||||
if (error instanceof ApiError) {
|
||||
const message = SHOW_ERROR_CODE
|
||||
? `[${error.status}] ${error.message}`
|
||||
: error.message;
|
||||
|
||||
// Validation 에러가 있으면 첫 번째 에러도 표시
|
||||
if (error.errors && SHOW_ERROR_CODE) {
|
||||
const firstErrorField = Object.keys(error.errors)[0];
|
||||
if (firstErrorField) {
|
||||
const firstError = error.errors[firstErrorField][0];
|
||||
toast.error(`${message}\n${firstErrorField}: ${firstError}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 일반 Error
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message || fallbackMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// unknown 타입
|
||||
toast.error(fallbackMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 성공 토스트
|
||||
* - 일관된 성공 메시지 표시
|
||||
*
|
||||
* @param message - 성공 메시지
|
||||
*/
|
||||
export function toastSuccess(message: string): void {
|
||||
toast.success(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 경고 토스트
|
||||
*
|
||||
* @param message - 경고 메시지
|
||||
*/
|
||||
export function toastWarning(message: string): void {
|
||||
toast.warning(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 정보 토스트
|
||||
*
|
||||
* @param message - 정보 메시지
|
||||
*/
|
||||
export function toastInfo(message: string): void {
|
||||
toast.info(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 메시지 포맷팅 (토스트 외 용도)
|
||||
* - 에러 코드 포함 여부는 SHOW_ERROR_CODE 설정 따름
|
||||
*
|
||||
* @param error - 발생한 에러 객체
|
||||
* @param fallbackMessage - 기본 메시지
|
||||
* @returns 포맷팅된 에러 메시지
|
||||
*/
|
||||
export function formatApiError(
|
||||
error: unknown,
|
||||
fallbackMessage = '오류가 발생했습니다.'
|
||||
): string {
|
||||
if (error instanceof ApiError) {
|
||||
return SHOW_ERROR_CODE
|
||||
? `[${error.status}] ${error.message}`
|
||||
: error.message;
|
||||
}
|
||||
return getErrorMessage(error) || fallbackMessage;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user