diff --git a/src/app/[locale]/(protected)/hr/employee-management/[id]/page.tsx b/src/app/[locale]/(protected)/hr/employee-management/[id]/page.tsx index 48c44064..ee41db69 100644 --- a/src/app/[locale]/(protected)/hr/employee-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/hr/employee-management/[id]/page.tsx @@ -48,19 +48,23 @@ export default function EmployeeDetailPage() { router.push(`/ko/hr/employee-management/${params.id}?mode=edit`); }; - const handleSave = async (data: EmployeeFormData) => { + const handleSave = async (data: EmployeeFormData): Promise<{ success: boolean; error?: string }> => { const id = params.id as string; - if (!id) return; + if (!id) return { success: false, error: 'ID가 없습니다.' }; try { const result = await updateEmployee(id, data); if (result.success) { - router.push(`/ko/hr/employee-management/${id}?mode=view`); + // 데이터 갱신 + await fetchEmployee(); + return { success: true }; } else { console.error('[EmployeeDetailPage] Update failed:', result.error); + return { success: false, error: result.error }; } } catch (error) { console.error('[EmployeeDetailPage] Update error:', error); + return { success: false, error: '저장 중 오류가 발생했습니다.' }; } }; diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/new/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/new/page.tsx index c5e76adb..27b50635 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/new/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/new/page.tsx @@ -3,14 +3,52 @@ /** * 수주 등록 페이지 * API 연동 완료 (2025-01-08) + * + * quoteId 파라미터 지원 (2026-01-27) + * - /sales/order-management-sales/new?quoteId=123 형태로 접근 시 + * - 해당 견적 정보를 자동으로 불러와 폼에 채움 */ -import { useRouter } from "next/navigation"; -import { OrderRegistration, OrderFormData, createOrder } from "@/components/orders"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { OrderRegistration, OrderFormData, createOrder, getQuoteByIdForSelect } from "@/components/orders"; +import type { QuotationForSelect, QuotationItem } from "@/components/orders/actions"; import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; export default function OrderNewPage() { const router = useRouter(); + const searchParams = useSearchParams(); + const quoteId = searchParams.get("quoteId"); + + const [initialQuotation, setInitialQuotation] = useState(); + const [isLoading, setIsLoading] = useState(!!quoteId); + + // quoteId가 있으면 견적 데이터 로드 + useEffect(() => { + const loadQuoteData = async () => { + if (!quoteId) return; + + setIsLoading(true); + try { + const result = await getQuoteByIdForSelect(quoteId); + + if (result.success && result.data) { + setInitialQuotation(result.data); + toast.success("견적 정보가 불러와졌습니다."); + } else { + toast.error(result.error || "견적 정보를 불러오는데 실패했습니다."); + } + } catch (error) { + console.error("Error loading quote:", error); + toast.error("견적 정보를 불러오는데 실패했습니다."); + } finally { + setIsLoading(false); + } + }; + + loadQuoteData(); + }, [quoteId]); const handleBack = () => { router.push("/sales/order-management-sales"); @@ -32,5 +70,50 @@ export default function OrderNewPage() { } }; - return ; + // 로딩 중일 때 로딩 표시 + if (isLoading) { + return ( +
+
+ +

견적 정보를 불러오는 중...

+
+
+ ); + } + + // initialData 생성 - 견적 데이터가 있으면 변환하여 전달 + const initialData: Partial | undefined = initialQuotation + ? { + selectedQuotation: initialQuotation, + clientId: initialQuotation.clientId || "", + clientName: initialQuotation.client, + siteName: initialQuotation.siteName, + manager: initialQuotation.manager || "", + contact: initialQuotation.contact || "", + items: (initialQuotation.items || []).map((qi: QuotationItem) => ({ + id: qi.id, + itemCode: qi.itemCode, + itemName: qi.itemName, + type: qi.type, + symbol: qi.symbol, + spec: qi.spec, + width: 0, + height: 0, + quantity: qi.quantity, + unit: qi.unit, + unitPrice: qi.unitPrice, + amount: qi.amount, + isFromQuotation: true, + })), + } + : undefined; + + return ( + + ); } \ No newline at end of file diff --git a/src/components/hr/EmployeeManagement/EmployeeForm.tsx b/src/components/hr/EmployeeManagement/EmployeeForm.tsx index bbe37e9a..149442ad 100644 --- a/src/components/hr/EmployeeManagement/EmployeeForm.tsx +++ b/src/components/hr/EmployeeManagement/EmployeeForm.tsx @@ -113,8 +113,7 @@ function formatDepartmentName(name: string, depth: number): string { interface EmployeeFormProps { mode: 'create' | 'edit' | 'view'; - employee?: Employee; - onSave?: (data: EmployeeFormData) => void; + employee?: Employee;onSave?: (data: EmployeeFormData) => Promise<{ success: boolean; error?: string }>; onEdit?: () => void; onDelete?: () => void; fieldSettings?: FieldSettings; @@ -428,8 +427,14 @@ export function EmployeeForm({ // onSave 호출 (페이지에서 처리) if (onSave) { - onSave(formData); - return { success: true }; + const result = await onSave(formData); + if (result.success && mode === 'edit') { + // 수정 모드: 저장 성공 시 view 모드로 전환 (리스트 이동 방지) + toast.success('저장되었습니다.'); + router.push(`/${locale}/hr/employee-management/${employee?.id}?mode=view`); + return { success: false, error: '' }; // navigateToList 방지 + 에러 메시지 숨김 + } + return result; } return { success: false, error: '저장 핸들러가 설정되지 않았습니다.' }; @@ -571,13 +576,21 @@ export function EmployeeForm({ value={formData.profileImage} onChange={async (file) => { // 미리보기 즉시 표시 - handleChange('profileImage', URL.createObjectURL(file)); + const previewUrl = URL.createObjectURL(file); + handleChange('profileImage', previewUrl); // 서버에 업로드 (FormData로 감싸서 전송) const uploadFormData = new FormData(); uploadFormData.append('file', file); const result = await uploadProfileImage(uploadFormData); if (result.success && result.data?.url) { + // 업로드 성공 시 서버 URL로 업데이트 + URL.revokeObjectURL(previewUrl); handleChange('profileImage', result.data.url); + } else { + // 업로드 실패 시 미리보기 제거 및 에러 표시 + URL.revokeObjectURL(previewUrl); + handleChange('profileImage', ''); + toast.error(result.error || '이미지 업로드에 실패했습니다.'); } }} onRemove={() => handleChange('profileImage', '')} diff --git a/src/components/hr/EmployeeManagement/actions.ts b/src/components/hr/EmployeeManagement/actions.ts index f3ce0001..300d2ee9 100644 --- a/src/components/hr/EmployeeManagement/actions.ts +++ b/src/components/hr/EmployeeManagement/actions.ts @@ -514,11 +514,23 @@ export async function uploadProfileImage(inputFormData: FormData): Promise<{ return { success: false, error: result.message || '파일 업로드에 실패했습니다.' }; } + // 업로드된 파일 경로 추출 (API 응답: file_path 필드) + const uploadedPath = result.data?.file_path || result.data?.path || result.data?.url; + + if (!uploadedPath) { + return { success: false, error: '업로드된 파일 경로를 가져올 수 없습니다.' }; + } + + // /storage/tenants/ 경로로 변환 (tenant disk 파일 접근 경로) + const storagePath = uploadedPath.startsWith('/storage/') + ? uploadedPath + : `/storage/tenants/${uploadedPath}`; + return { success: true, data: { - url: result.data?.url || result.data?.path, - path: result.data?.path, + url: `${process.env.NEXT_PUBLIC_API_URL}${storagePath}`, + path: uploadedPath, }, }; } catch (error) { diff --git a/src/components/hr/EmployeeManagement/utils.ts b/src/components/hr/EmployeeManagement/utils.ts index ccc26aa0..6664ae50 100644 --- a/src/components/hr/EmployeeManagement/utils.ts +++ b/src/components/hr/EmployeeManagement/utils.ts @@ -61,6 +61,11 @@ export function extractRelativePath(path: string | null | undefined): string | n return null; } + // blob URL인 경우 (미리보기) - 저장하지 않음 + if (path.startsWith('blob:')) { + return null; + } + // 전체 URL인 경우 상대 경로 추출 if (path.startsWith('http://') || path.startsWith('https://')) { // /storage/tenants/ 이후의 경로 추출 diff --git a/src/components/orders/actions.ts b/src/components/orders/actions.ts index 049af14a..28da348a 100644 --- a/src/components/orders/actions.ts +++ b/src/components/orders/actions.ts @@ -1214,6 +1214,50 @@ export async function revertOrderConfirmation(orderId: string): Promise<{ } } +/** + * 수주 변환용 단일 견적 조회 (ID로 조회) + * 견적 상세페이지에서 수주등록 버튼 클릭 시 사용 + */ +export async function getQuoteByIdForSelect(id: string): Promise<{ + success: boolean; + data?: QuotationForSelect; + error?: string; + __authError?: boolean; +}> { + try { + const searchParams = new URLSearchParams(); + // 품목 포함 + searchParams.set('with_items', 'true'); + + const { response, error } = await serverFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes/${id}?${searchParams.toString()}`, + { method: 'GET', cache: 'no-store' } + ); + + if (error) { + return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; + } + + if (!response) { + return { success: false, error: '견적 조회에 실패했습니다.' }; + } + + const result: ApiResponse = await response.json(); + + if (!response.ok || !result.success) { + return { success: false, error: result.message || '견적 조회에 실패했습니다.' }; + } + + return { + success: true, + data: transformQuoteForSelect(result.data), + }; + } catch (error) { + console.error('[getQuoteByIdForSelect] Error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +} + /** * 수주 변환용 확정 견적 목록 조회 * QuotationSelectDialog에서 사용 diff --git a/src/components/orders/index.ts b/src/components/orders/index.ts index e881fc77..157f74a5 100644 --- a/src/components/orders/index.ts +++ b/src/components/orders/index.ts @@ -14,17 +14,20 @@ export { getOrderStats, revertProductionOrder, revertOrderConfirmation, + getQuoteByIdForSelect, type Order, type OrderItem as OrderItemApi, type OrderFormData as OrderApiFormData, type OrderItemFormData, type OrderStats, type OrderStatus, + type QuotationForSelect, + type QuotationItem, } from "./actions"; // Components export { OrderRegistration, type OrderFormData } from "./OrderRegistration"; -export { QuotationSelectDialog, type QuotationForSelect, type QuotationItem } from "./QuotationSelectDialog"; +export { QuotationSelectDialog } from "./QuotationSelectDialog"; export { ItemAddDialog, type OrderItem } from "./ItemAddDialog"; // 문서 컴포넌트 diff --git a/src/components/quotes/actions.ts b/src/components/quotes/actions.ts index 36cc4c69..039ba55e 100644 --- a/src/components/quotes/actions.ts +++ b/src/components/quotes/actions.ts @@ -1161,7 +1161,10 @@ export async function getItemCategoryTree(): Promise<{ } if (!response || !response.ok) { - return { success: false, data: [], error: '카테고리 조회 실패' }; + console.error('[getItemCategoryTree] response status:', response?.status, response?.statusText); + const errorBody = response ? await response.text().catch(() => '') : ''; + console.error('[getItemCategoryTree] response body:', errorBody); + return { success: false, data: [], error: `카테고리 조회 실패 (${response?.status || 'no response'})` }; } const result = await response.json(); diff --git a/src/components/templates/IntegratedDetailTemplate/index.tsx b/src/components/templates/IntegratedDetailTemplate/index.tsx index 020c8c5f..4ad150c1 100644 --- a/src/components/templates/IntegratedDetailTemplate/index.tsx +++ b/src/components/templates/IntegratedDetailTemplate/index.tsx @@ -257,7 +257,8 @@ function IntegratedDetailTemplateInner>( if (result?.success) { toast.success(isCreateMode ? '등록되었습니다.' : '저장되었습니다.'); navigateToList(); - } else { + } else if (result?.error !== '') { + // error가 빈 문자열이면 토스트 표시 안 함 (커스텀 네비게이션 처리용) toast.error(result?.error || '저장에 실패했습니다.'); } } catch (error) {