diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx index 41162cf5..a57c947a 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx @@ -31,6 +31,7 @@ import { FileCheck, ClipboardList, Eye, + CheckCircle2, } from "lucide-react"; import { toast } from "sonner"; import { PageLayout } from "@/components/organisms/PageLayout"; @@ -102,6 +103,8 @@ export default function OrderDetailPage() { const [loading, setLoading] = useState(true); const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false); const [isCancelling, setIsCancelling] = useState(false); + const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); // 취소 폼 상태 const [cancelReason, setCancelReason] = useState(""); @@ -185,6 +188,32 @@ export default function OrderDetailPage() { } }; + // 수주 확정 처리 + const handleConfirmOrder = () => { + setIsConfirmDialogOpen(true); + }; + + const handleConfirmOrderSubmit = async () => { + if (order) { + setIsConfirming(true); + try { + const result = await updateOrderStatus(order.id, "order_confirmed"); + if (result.success && result.data) { + setOrder(result.data); + toast.success("수주가 확정되었습니다."); + setIsConfirmDialogOpen(false); + } else { + toast.error(result.error || "수주 확정에 실패했습니다."); + } + } catch (error) { + console.error("Error confirming order:", error); + toast.error("수주 확정 중 오류가 발생했습니다."); + } finally { + setIsConfirming(false); + } + } + }; + // 문서 모달 열기 const openDocumentModal = (type: OrderDocumentType) => { setDocumentType(type); @@ -217,6 +246,8 @@ export default function OrderDetailPage() { // 상태별 버튼 표시 여부 const showEditButton = order.status !== "shipped" && order.status !== "cancelled"; + // 수주 확정 버튼: 수주등록 상태에서만 표시 + const showConfirmButton = order.status === "order_registered"; // 생산지시 생성 버튼: 출하완료, 취소, 생산지시완료 제외하고 표시 // (수주등록, 수주확정, 생산중, 재작업중, 작업완료에서 표시) const showProductionCreateButton = @@ -248,6 +279,12 @@ export default function OrderDetailPage() { 수정 )} + {showConfirmButton && ( + + )} {showProductionCreateButton && ( + + + + ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx index 5f0e8443..141a8817 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/production-order/page.tsx @@ -10,9 +10,11 @@ * - 스크린 품목 상세 * - 모터/전장품 사양 (읽기전용) * - 절곡물 BOM + * + * API 연동: getOrderById, createProductionOrder */ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useRouter, useParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -25,7 +27,7 @@ import { TableRow, } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; -import { Factory, ArrowLeft, BarChart3, CheckCircle2 } from "lucide-react"; +import { Factory, ArrowLeft, BarChart3, CheckCircle2, AlertCircle } from "lucide-react"; import { PageLayout } from "@/components/organisms/PageLayout"; import { AlertDialog, @@ -39,6 +41,13 @@ import { import { PageHeader } from "@/components/organisms/PageHeader"; import { BadgeSm } from "@/components/atoms/BadgeSm"; import { cn } from "@/lib/utils"; +import { + getOrderById, + createProductionOrder, + type Order, + type CreateProductionOrderData, +} from "@/components/orders/actions"; +import { formatAmount } from "@/utils/formatAmount"; // 수주 정보 타입 interface OrderInfo { @@ -187,16 +196,16 @@ const PRIORITY_CONFIGS: PriorityConfig[] = [ }, ]; -// 샘플 수주 정보 -const SAMPLE_ORDER_INFO: OrderInfo = { - orderNumber: "KD-TS-251217-09", - client: "태영건설(주)", - siteName: "데시앙 동탄 파크뷰", - dueDate: "2026-02-25", - itemCount: 3, - totalQuantity: "3EA", - creditGrade: "A (우량)", - status: "재작업중", +// 상태 레이블 +const STATUS_LABELS: Record = { + order_registered: "수주등록", + order_confirmed: "수주확정", + production_ordered: "생산지시", + in_production: "생산중", + rework: "재작업중", + work_completed: "작업완료", + shipped: "출고완료", + cancelled: "취소", }; // 샘플 작업지시 카드 @@ -337,7 +346,8 @@ export default function ProductionOrderCreatePage() { const orderId = params.id as string; const [loading, setLoading] = useState(true); - const [orderInfo, setOrderInfo] = useState(null); + const [error, setError] = useState(null); + const [order, setOrder] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); // 우선순위 상태 @@ -347,36 +357,60 @@ export default function ProductionOrderCreatePage() { // 성공 다이얼로그 상태 const [showSuccessDialog, setShowSuccessDialog] = useState(false); const [generatedOrderNumber, setGeneratedOrderNumber] = useState(""); + const [generatedWorkOrderId, setGeneratedWorkOrderId] = useState(null); - // 데이터 로드 - useEffect(() => { - setTimeout(() => { - setOrderInfo(SAMPLE_ORDER_INFO); + // 수주 데이터 로드 + const fetchOrder = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await getOrderById(orderId); + if (result.success && result.data) { + setOrder(result.data); + } else { + setError(result.error || "수주 정보 조회에 실패했습니다."); + } + } catch { + setError("서버 오류가 발생했습니다."); + } finally { setLoading(false); - }, 300); + } }, [orderId]); + useEffect(() => { + fetchOrder(); + }, [fetchOrder]); + const handleCancel = () => { - router.push(`/sales/order-management-sales/${orderId}`); + router.push(`/ko/sales/order-management-sales/${orderId}`); }; const handleBackToDetail = () => { - router.push(`/sales/order-management-sales/${orderId}`); + router.push(`/ko/sales/order-management-sales/${orderId}`); }; const handleConfirm = async () => { + if (!order) return; + setIsSubmitting(true); + setError(null); try { - // TODO: API 호출 - await new Promise((resolve) => setTimeout(resolve, 500)); + const productionData: CreateProductionOrderData = { + priority: selectedPriority, + memo: memo || undefined, + }; - // 생산지시번호 생성 (실제로는 API 응답에서 받아옴) - const today = new Date(); - const dateStr = `${String(today.getFullYear()).slice(2)}${String(today.getMonth() + 1).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`; - const newOrderNumber = `PO-${orderInfo?.orderNumber.replace("KD-TS-", "KD-") || "KD-000000"}-${dateStr}`; + const result = await createProductionOrder(orderId, productionData); - setGeneratedOrderNumber(newOrderNumber); - setShowSuccessDialog(true); + if (result.success && result.data) { + setGeneratedOrderNumber(result.data.workOrder.workOrderNo); + setGeneratedWorkOrderId(result.data.workOrder.id); + setShowSuccessDialog(true); + } else { + setError(result.error || "생산지시 생성에 실패했습니다."); + } + } catch { + setError("서버 오류가 발생했습니다."); } finally { setIsSubmitting(false); } @@ -384,9 +418,8 @@ export default function ProductionOrderCreatePage() { const handleSuccessDialogClose = () => { setShowSuccessDialog(false); - // 생산지시 상세 페이지로 이동 (실제로는 API 응답에서 받은 생산지시 ID 사용) - // 임시로 PO-002 사용 (샘플 데이터와 매칭) - router.push("/sales/order-management-sales/production-orders/PO-002"); + // 수주 상세 페이지로 이동 (상태가 변경되었으므로) + router.push(`/ko/sales/order-management-sales/${orderId}`); }; // 선택된 우선순위 설정 가져오기 @@ -404,7 +437,22 @@ export default function ProductionOrderCreatePage() { ); } - if (!orderInfo) { + if (error && !order) { + return ( + +
+ +

{error}

+ +
+
+ ); + } + + if (!order) { return (
@@ -421,6 +469,19 @@ export default function ProductionOrderCreatePage() { const selectedConfig = getSelectedPriorityConfig(); const workOrderCount = SAMPLE_WORK_ORDER_CARDS.length; + // Order에서 UI에 표시할 데이터 변환 + const orderInfo = { + orderNumber: order.lotNumber, + client: order.client, + siteName: order.siteName, + dueDate: order.expectedShipDate || "-", + itemCount: order.itemCount, + totalQuantity: `${order.itemCount}EA`, + creditGrade: "B (관리)", // API에서 제공하지 않아 기본값 사용 + status: STATUS_LABELS[order.status] || order.status, + amount: order.amount, + }; + return ( {/* 헤더 */} diff --git a/src/components/orders/OrderRegistration.tsx b/src/components/orders/OrderRegistration.tsx index 2b7187e7..3585dc6b 100644 --- a/src/components/orders/OrderRegistration.tsx +++ b/src/components/orders/OrderRegistration.tsx @@ -49,7 +49,8 @@ import { ResponsiveFormTemplate, FormSection, } from "@/components/templates/ResponsiveFormTemplate"; -import { QuotationSelectDialog, QuotationForSelect, QuotationItem } from "./QuotationSelectDialog"; +import { QuotationSelectDialog } from "./QuotationSelectDialog"; +import { type QuotationForSelect, type QuotationItem } from "./actions"; import { ItemAddDialog, OrderItem } from "./ItemAddDialog"; import { formatAmount } from "@/utils/formatAmount"; import { cn } from "@/lib/utils"; diff --git a/src/components/orders/QuotationSelectDialog.tsx b/src/components/orders/QuotationSelectDialog.tsx index e930155e..952e8c53 100644 --- a/src/components/orders/QuotationSelectDialog.tsx +++ b/src/components/orders/QuotationSelectDialog.tsx @@ -4,9 +4,10 @@ * 견적 선택 팝업 * * 확정된 견적 목록에서 수주 전환할 견적을 선택하는 다이얼로그 + * API 연동: getQuotesForSelect (FINALIZED 상태 견적만 조회) */ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Dialog, DialogContent, @@ -15,37 +16,10 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; -import { Search, FileText, Check } from "lucide-react"; +import { Search, FileText, Check, Loader2 } from "lucide-react"; import { formatAmount } from "@/utils/formatAmount"; import { cn } from "@/lib/utils"; - -// 견적 타입 -export interface QuotationForSelect { - id: string; - quoteNumber: string; // KD-PR-XXXXXX-XX - grade: string; // A(우량), B(관리), C(주의) - client: string; // 발주처 - siteName: string; // 현장명 - amount: number; // 총 금액 - itemCount: number; // 품목 수 - registrationDate: string; // 견적일 - manager?: string; // 담당자 - contact?: string; // 연락처 - items?: QuotationItem[]; // 품목 내역 -} - -export interface QuotationItem { - id: string; - itemCode: string; - itemName: string; - type: string; // 종 - symbol: string; // 부호 - spec: string; // 규격 - quantity: number; - unit: string; - unitPrice: number; - amount: number; -} +import { getQuotesForSelect, type QuotationForSelect } from "./actions"; interface QuotationSelectDialogProps { open: boolean; @@ -54,81 +28,6 @@ interface QuotationSelectDialogProps { selectedId?: string; } -// 샘플 견적 데이터 (실제 구현에서는 API 연동) -const SAMPLE_QUOTATIONS: QuotationForSelect[] = [ - { - id: "QT-001", - quoteNumber: "KD-PR-251210-01", - grade: "A", - client: "태영건설(주)", - siteName: "데시앙 동탄 파크뷰", - amount: 38800000, - itemCount: 5, - registrationDate: "2024-12-10", - manager: "김철수", - contact: "010-1234-5678", - items: [ - { id: "1", itemCode: "PRD-001", itemName: "국민방화스크린세터", type: "B1", symbol: "FSS1", spec: "7260×2600", quantity: 2, unit: "EA", unitPrice: 8000000, amount: 16000000 }, - { id: "2", itemCode: "PRD-002", itemName: "국민방화스크린세터", type: "B1", symbol: "FSS2", spec: "5000×2400", quantity: 3, unit: "EA", unitPrice: 7600000, amount: 22800000 }, - ], - }, - { - id: "QT-002", - quoteNumber: "KD-PR-251211-02", - grade: "A", - client: "현대건설(주)", - siteName: "힐스테이트 판교역", - amount: 52500000, - itemCount: 8, - registrationDate: "2024-12-11", - manager: "이영희", - contact: "010-2345-6789", - items: [ - { id: "1", itemCode: "PRD-003", itemName: "국민방화스크린세터", type: "B2", symbol: "FSS1", spec: "6000×3000", quantity: 4, unit: "EA", unitPrice: 9500000, amount: 38000000 }, - { id: "2", itemCode: "PRD-004", itemName: "국민방화스크린세터", type: "B1", symbol: "FSS2", spec: "4500×2500", quantity: 2, unit: "EA", unitPrice: 7250000, amount: 14500000 }, - ], - }, - { - id: "QT-003", - quoteNumber: "KD-PR-251208-03", - grade: "B", - client: "GS건설(주)", - siteName: "자이 강남센터", - amount: 45000000, - itemCount: 6, - registrationDate: "2024-12-08", - manager: "박민수", - contact: "010-3456-7890", - items: [], - }, - { - id: "QT-004", - quoteNumber: "KD-PR-251205-04", - grade: "B", - client: "대우건설(주)", - siteName: "푸르지오 송도", - amount: 28900000, - itemCount: 4, - registrationDate: "2024-12-05", - manager: "최지원", - contact: "010-4567-8901", - items: [], - }, - { - id: "QT-005", - quoteNumber: "KD-PR-251201-05", - grade: "A", - client: "포스코건설", - siteName: "더샵 분당센트럴", - amount: 62000000, - itemCount: 10, - registrationDate: "2024-12-01", - manager: "정수민", - contact: "010-5678-9012", - items: [], - }, -]; - // 등급 배지 컴포넌트 function GradeBadge({ grade }: { grade: string }) { const config: Record = { @@ -151,25 +50,48 @@ export function QuotationSelectDialog({ selectedId, }: QuotationSelectDialogProps) { const [searchTerm, setSearchTerm] = useState(""); - const [quotations] = useState(SAMPLE_QUOTATIONS); + const [quotations, setQuotations] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); - // 검색 필터링 - const filteredQuotations = quotations.filter((q) => { - const searchLower = searchTerm.toLowerCase(); - return ( - !searchTerm || - q.quoteNumber.toLowerCase().includes(searchLower) || - q.client.toLowerCase().includes(searchLower) || - q.siteName.toLowerCase().includes(searchLower) - ); - }); + // 견적 목록 조회 + const fetchQuotations = useCallback(async (query?: string) => { + setIsLoading(true); + setError(null); + try { + const result = await getQuotesForSelect({ q: query, size: 50 }); + if (result.success && result.data) { + setQuotations(result.data.items); + } else { + setError(result.error || "견적 목록 조회에 실패했습니다."); + setQuotations([]); + } + } catch { + setError("서버 오류가 발생했습니다."); + setQuotations([]); + } finally { + setIsLoading(false); + } + }, []); - // 다이얼로그 열릴 때 검색어 초기화 + // 다이얼로그 열릴 때 데이터 로드 useEffect(() => { if (open) { setSearchTerm(""); + fetchQuotations(); } - }, [open]); + }, [open, fetchQuotations]); + + // 검색어 변경 시 디바운스 적용하여 API 호출 + useEffect(() => { + if (!open) return; + + const timer = setTimeout(() => { + fetchQuotations(searchTerm || undefined); + }, 300); + + return () => clearTimeout(timer); + }, [searchTerm, open, fetchQuotations]); const handleSelect = (quotation: QuotationForSelect) => { onSelect(quotation); @@ -199,60 +121,77 @@ export function QuotationSelectDialog({ {/* 안내 문구 */}
- 전환 가능한 견적 {filteredQuotations.length}건 (최종확정 상태) + {isLoading ? ( + + + 견적 목록을 불러오는 중... + + ) : error ? ( + {error} + ) : ( + `전환 가능한 견적 ${quotations.length}건 (최종확정 상태)` + )}
{/* 견적 목록 */}
- {filteredQuotations.map((quotation) => ( -
handleSelect(quotation)} - className={cn( - "p-4 border rounded-lg cursor-pointer transition-colors", - "hover:bg-muted/50 hover:border-primary/50", - selectedId === quotation.id && "border-primary bg-primary/5" - )} - > - {/* 상단: 견적번호 + 등급 */} -
-
- - {quotation.quoteNumber} - - + {isLoading ? ( +
+ +
+ ) : ( + <> + {quotations.map((quotation) => ( +
handleSelect(quotation)} + className={cn( + "p-4 border rounded-lg cursor-pointer transition-colors", + "hover:bg-muted/50 hover:border-primary/50", + selectedId === quotation.id && "border-primary bg-primary/5" + )} + > + {/* 상단: 견적번호 + 등급 */} +
+
+ + {quotation.quoteNumber} + + +
+ {selectedId === quotation.id && ( + + )} +
+ + {/* 발주처 */} +
+ {quotation.client} +
+ + {/* 현장명 + 금액 */} +
+ + [{quotation.siteName}] + + + {formatAmount(quotation.amount)}원 + +
+ + {/* 품목 수 */} +
+ {quotation.itemCount}개 품목 +
- {selectedId === quotation.id && ( - - )} -
+ ))} - {/* 발주처 */} -
- {quotation.client} -
- - {/* 현장명 + 금액 */} -
- - [{quotation.siteName}] - - - {formatAmount(quotation.amount)}원 - -
- - {/* 품목 수 */} -
- {quotation.itemCount}개 품목 -
-
- ))} - - {filteredQuotations.length === 0 && ( -
- 검색 결과가 없습니다. -
+ {quotations.length === 0 && !error && ( +
+ 검색 결과가 없습니다. +
+ )} + )}
diff --git a/src/components/orders/actions.ts b/src/components/orders/actions.ts index 2f81de08..6a5dd22a 100644 --- a/src/components/orders/actions.ts +++ b/src/components/orders/actions.ts @@ -70,6 +70,47 @@ interface ApiQuote { site_name: string | null; } +// 견적 목록 조회용 상세 타입 +interface ApiQuoteForSelect { + id: number; + quote_number: string; + registration_date: string; + status: string; + client_id: number | null; + client_name: string | null; + site_name: string | null; + supply_amount: number; + tax_amount: number; + total_amount: number; + item_count?: number; + author?: string | null; + manager?: string | null; + contact?: string | null; + client?: { + id: number; + name: string; + grade?: string; + representative?: string; + phone?: string; + } | null; + items?: ApiQuoteItem[]; +} + +interface ApiQuoteItem { + id: number; + item_code?: string; + item_name: string; + type_code?: string; + symbol?: string; + specification?: string; + quantity: number; + unit?: string; + unit_price: number; + supply_amount: number; + tax_amount: number; + total_amount: number; +} + interface ApiWorkOrder { id: number; tenant_id: number; @@ -249,6 +290,7 @@ export interface CreateFromQuoteData { // 생산지시 생성용 export interface CreateProductionOrderData { processType?: 'screen' | 'slat' | 'bending'; + priority?: 'urgent' | 'high' | 'normal' | 'low'; assigneeId?: number; teamId?: number; scheduledDate?: string; @@ -280,6 +322,34 @@ export interface ProductionOrderResult { order: Order; } +// 견적 선택용 타입 (QuotationSelectDialog용) +export interface QuotationForSelect { + id: string; + quoteNumber: string; // KD-PR-XXXXXX-XX + grade: string; // A(우량), B(관리), C(주의) + client: string; // 발주처 + siteName: string; // 현장명 + amount: number; // 총 금액 + itemCount: number; // 품목 수 + registrationDate: string; // 견적일 + manager?: string; // 담당자 + contact?: string; // 연락처 + items?: QuotationItem[]; // 품목 내역 +} + +export interface QuotationItem { + id: string; + itemCode: string; + itemName: string; + type: string; // 종 + symbol: string; // 부호 + spec: string; // 규격 + quantity: number; + unit: string; + unitPrice: number; + amount: number; +} + // ============================================================================ // 상태 매핑 // ============================================================================ @@ -415,6 +485,37 @@ function transformWorkOrderApiToFrontend(apiData: ApiWorkOrder): WorkOrder { }; } +function transformQuoteForSelect(apiData: ApiQuoteForSelect): QuotationForSelect { + return { + id: String(apiData.id), + quoteNumber: apiData.quote_number, + grade: apiData.client?.grade || 'B', // 기본값 B(관리) + client: apiData.client_name || apiData.client?.name || '', + siteName: apiData.site_name || '', + amount: apiData.total_amount, + itemCount: apiData.item_count || apiData.items?.length || 0, + registrationDate: apiData.registration_date, + manager: apiData.manager ?? undefined, + contact: apiData.contact ?? apiData.client?.phone ?? undefined, + items: apiData.items?.map(transformQuoteItemForSelect), + }; +} + +function transformQuoteItemForSelect(apiItem: ApiQuoteItem): QuotationItem { + return { + id: String(apiItem.id), + itemCode: apiItem.item_code || '', + itemName: apiItem.item_name, + type: apiItem.type_code || '', + symbol: apiItem.symbol || '', + spec: apiItem.specification || '', + quantity: apiItem.quantity, + unit: apiItem.unit || 'EA', + unitPrice: apiItem.unit_price, + amount: apiItem.total_amount, + }; +} + // ============================================================================ // API 함수 // ============================================================================ @@ -815,6 +916,7 @@ export async function createProductionOrder( try { const apiData: Record = {}; if (data?.processType) apiData.process_type = data.processType; + if (data?.priority) apiData.priority = data.priority; if (data?.assigneeId) apiData.assignee_id = data.assigneeId; if (data?.teamId) apiData.team_id = data.teamId; if (data?.scheduledDate) apiData.scheduled_date = data.scheduledDate; @@ -851,3 +953,58 @@ export async function createProductionOrder( return { success: false, error: '서버 오류가 발생했습니다.' }; } } + +/** + * 수주 변환용 확정 견적 목록 조회 + * QuotationSelectDialog에서 사용 + */ +export async function getQuotesForSelect(params?: { + q?: string; + page?: number; + size?: number; +}): Promise<{ + success: boolean; + data?: { items: QuotationForSelect[]; total: number }; + error?: string; + __authError?: boolean; +}> { + try { + const searchParams = new URLSearchParams(); + + // 확정(FINALIZED) 상태의 견적만 조회 + searchParams.set('status', 'FINALIZED'); + if (params?.q) searchParams.set('q', params.q); + if (params?.page) searchParams.set('page', String(params.page)); + if (params?.size) searchParams.set('size', String(params.size || 50)); + + const { response, error } = await serverFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/quotes?${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: { + items: result.data.data.map(transformQuoteForSelect), + total: result.data.total, + }, + }; + } catch (error) { + console.error('[getQuotesForSelect] Error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +}