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:
2026-01-16 15:39:02 +09:00
parent 34deb61632
commit 98b65a6ca4
18 changed files with 1157 additions and 89 deletions

View File

@@ -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} />;
}

View File

@@ -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));
}
};

View File

@@ -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);

View File

@@ -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: '견적 수정에 실패했습니다.' };
}
}

View File

@@ -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: [] },
};

View File

@@ -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,

View File

@@ -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">

View File

@@ -284,7 +284,7 @@ export function WorkOrderCreate() {
<Label htmlFor="manual" className="cursor-pointer">
{' '}
<span className="text-muted-foreground text-sm">
( )
()
</span>
</Label>
</div>

View 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>
);
}

View File

@@ -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 연동)

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -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 });

View File

@@ -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,

View 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);
}

View File

@@ -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
View 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