From 572ffe81cfd8a64f828a604f86e9453f33747e30 Mon Sep 17 00:00:00 2001 From: kent Date: Thu, 8 Jan 2026 17:29:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(orders):=20Phase=202=20-=20Frontend=20API?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions.ts 생성: Server Actions 패턴으로 Order API 클라이언트 구현 - getOrders, getOrderById, createOrder, updateOrder, deleteOrder(s) - updateOrderStatus, getOrderStats - API snake_case → Frontend camelCase 변환 - 상태 매핑 (DRAFT→order_registered 등) - 목록 페이지(page.tsx): - SAMPLE_ORDERS 제거, API 연동 state 추가 - loadData() 함수로 API 호출 - 삭제/일괄삭제 API 연동 - 상세 페이지([id]/page.tsx): - SAMPLE_ITEMS/ORDERS 제거 - getOrderById, updateOrderStatus API 연동 - 수정 페이지([id]/edit/page.tsx): - SAMPLE_ORDER 제거 - getOrderById, updateOrder API 연동 - 등록 페이지(new/page.tsx): - createOrder API 연동 --- ...20250108_order_frontend_api_integration.md | 92 +++ .../order-management-sales/[id]/edit/page.tsx | 178 ++--- .../order-management-sales/[id]/page.tsx | 318 ++------- .../sales/order-management-sales/new/page.tsx | 22 +- .../sales/order-management-sales/page.tsx | 302 ++++----- src/components/orders/actions.ts | 630 ++++++++++++++++++ src/components/orders/index.ts | 21 +- 7 files changed, 1026 insertions(+), 537 deletions(-) create mode 100644 claudedocs/changes/20250108_order_frontend_api_integration.md create mode 100644 src/components/orders/actions.ts diff --git a/claudedocs/changes/20250108_order_frontend_api_integration.md b/claudedocs/changes/20250108_order_frontend_api_integration.md new file mode 100644 index 00000000..fabe8c51 --- /dev/null +++ b/claudedocs/changes/20250108_order_frontend_api_integration.md @@ -0,0 +1,92 @@ +# 수주 관리 Frontend API 연동 + +**날짜:** 2025-01-08 +**Phase:** Phase 2 - Frontend 연동 +**관련 Plan:** docs/plans/order-management-plan.md + +## 변경 개요 + +수주 관리 React 페이지들을 백엔드 API와 연동 완료. Mock 데이터를 제거하고 실제 API 호출로 대체. + +## 수정된 파일 + +### 1. `src/components/orders/actions.ts` (신규 생성) +- Server Actions 패턴으로 API 클라이언트 구현 +- 주요 함수: + - `getOrders()`: 수주 목록 조회 + - `getOrderById(id)`: 수주 상세 조회 + - `createOrder(data)`: 수주 등록 + - `updateOrder(id, data)`: 수주 수정 + - `deleteOrder(id)`: 수주 삭제 + - `deleteOrders(ids)`: 수주 일괄 삭제 + - `updateOrderStatus(id, status)`: 수주 상태 변경 + - `getOrderStats()`: 통계 조회 +- 데이터 변환: API snake_case → Frontend camelCase +- 상태 매핑: API 상태(DRAFT, CONFIRMED 등) → Frontend 상태(order_registered, order_confirmed 등) + +### 2. `src/components/orders/index.ts` (수정) +- actions.ts export 추가 +- 타입 충돌 해결 (OrderItem → OrderItemApi) + +### 3. `src/app/[locale]/(protected)/sales/order-management-sales/page.tsx` (수정) +- SAMPLE_ORDERS (~115줄) 제거 +- API 연동 state 추가: `orders`, `apiStats`, `isLoading`, `isDeleting` +- `loadData()` 함수로 API 호출 (getOrders, getOrderStats) +- 삭제 핸들러에 API 호출 추가 (deleteOrder, deleteOrders) +- 로딩 UI 추가 + +### 4. `src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx` (수정) +- SAMPLE_ITEMS, SAMPLE_ORDERS (~250줄) 제거 +- useEffect에서 getOrderById API 호출 +- handleConfirmCancel에서 updateOrderStatus API 호출 +- isCancelling 로딩 상태 적용 + +### 5. `src/app/[locale]/(protected)/sales/order-management-sales/[id]/edit/page.tsx` (수정) +- SAMPLE_ORDER (~50줄) 제거 +- useEffect에서 getOrderById API 호출 +- handleSave에서 updateOrder API 호출 + +### 6. `src/app/[locale]/(protected)/sales/order-management-sales/new/page.tsx` (수정) +- handleSave에서 createOrder API 호출 + +## 기술 패턴 + +### Server Actions 패턴 +```typescript +"use server"; +import { serverFetch } from "@/lib/api/serverFetch"; + +export async function getOrders() { + const response = await serverFetch("/orders"); + // 데이터 변환 로직 +} +``` + +### 데이터 변환 +- API: `order_no`, `client_name`, `site_name` +- Frontend: `orderNo`, `clientName`, `siteName` + +### 상태 매핑 +| API | Frontend | +|-----|----------| +| DRAFT | order_registered | +| CONFIRMED | order_confirmed | +| IN_PROGRESS | production_ordered | +| COMPLETED | shipped | +| CANCELLED | cancelled | + +## 테스트 체크리스트 + +- [ ] 수주 목록 로드 +- [ ] 수주 상세 조회 +- [ ] 수주 등록 (견적 선택 후) +- [ ] 수주 수정 +- [ ] 수주 개별 삭제 +- [ ] 수주 일괄 삭제 +- [ ] 수주 취소 +- [ ] 통계 카드 표시 + +## 연관 작업 + +- Phase 1: Order API 백엔드 구현 (커밋: de19ac9) +- Phase 1.1: OrderController/Service 구현 (진행 중) diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/edit/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/edit/page.tsx index 765e89e1..13f67c87 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/edit/page.tsx @@ -13,7 +13,6 @@ import { useState, useEffect } from "react"; import { useRouter, useParams } from "next/navigation"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -32,26 +31,19 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { FileText, ArrowLeft, Info, AlertTriangle } from "lucide-react"; +import { FileText, AlertTriangle } from "lucide-react"; import { toast } from "sonner"; import { PageLayout } from "@/components/organisms/PageLayout"; import { PageHeader } from "@/components/organisms/PageHeader"; -import { FormSection } from "@/components/templates/ResponsiveFormTemplate"; import { FormActions } from "@/components/organisms/FormActions"; import { BadgeSm } from "@/components/atoms/BadgeSm"; import { formatAmount } from "@/utils/formatAmount"; -import { OrderItem } from "@/components/orders"; - -// 수주 상태 타입 -type OrderStatus = - | "order_registered" - | "order_confirmed" - | "production_ordered" - | "in_production" - | "rework" - | "work_completed" - | "shipped" - | "cancelled"; +import { + OrderItem, + getOrderById, + updateOrder, + type OrderStatus, +} from "@/components/orders"; // 수정 폼 데이터 interface EditFormData { @@ -99,60 +91,6 @@ const SHIPPING_COSTS = [ { value: "negotiable", label: "협의" }, ]; -// 샘플 데이터 -const SAMPLE_ORDER: EditFormData = { - lotNumber: "KD-TS-251217-01", - quoteNumber: "KD-PR-251210-01", - client: "태영건설(주)", - siteName: "데시앙 동탄 파크뷰", - manager: "김철수", - contact: "010-1234-5678", - status: "order_confirmed", - expectedShipDate: "2025-01-15", - expectedShipDateUndecided: false, - deliveryRequestDate: "2025-01-20", - deliveryMethod: "direct", - shippingCost: "free", - receiver: "박반장", - receiverContact: "010-9876-5432", - address: "경기도 화성시 동탄대로 123-45", - addressDetail: "데시앙 동탄 파크뷰 현장", - remarks: "4층 우선 납품 요청", - items: [ - { - id: "1", - itemCode: "PRD-001", - itemName: "국민방화스크린세터", - type: "B1", - symbol: "FSS1", - spec: "7260×2600", - width: 7260, - height: 2600, - quantity: 2, - unit: "EA", - unitPrice: 8000000, - amount: 16000000, - }, - { - id: "2", - itemCode: "PRD-002", - itemName: "국민방화스크린세터", - type: "B1", - symbol: "FSS2", - spec: "5000×2400", - width: 5000, - height: 2400, - quantity: 3, - unit: "EA", - unitPrice: 7600000, - amount: 22800000, - }, - ], - canEditItems: true, - subtotal: 38800000, - discountRate: 0, - totalAmount: 38800000, -}; // 상태 뱃지 헬퍼 function getOrderStatusBadge(status: OrderStatus) { @@ -183,21 +121,57 @@ export default function OrderEditPage() { const [loading, setLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); - // 데이터 로드 + // 데이터 로드 (API) useEffect(() => { - setTimeout(() => { - // 상태에 따라 품목 수정 가능 여부 결정 - const canEditItems = !["in_production", "rework", "work_completed", "shipped"].includes( - SAMPLE_ORDER.status - ); - setForm({ ...SAMPLE_ORDER, canEditItems }); - setLoading(false); - }, 300); - }, [orderId]); - - const handleBack = () => { - router.push(`/sales/order-management-sales/${orderId}`); - }; + async function loadOrder() { + try { + setLoading(true); + const result = await getOrderById(orderId); + if (result.success && result.data) { + const order = result.data; + // 상태에 따라 품목 수정 가능 여부 결정 + const canEditItems = !["in_production", "rework", "work_completed", "shipped"].includes( + order.status + ); + // Order 데이터를 EditFormData로 변환 + setForm({ + lotNumber: order.lotNumber, + quoteNumber: order.quoteNumber || "", + client: order.client, + siteName: order.siteName, + manager: order.manager || "", + contact: order.contact || "", + status: order.status, + expectedShipDate: order.expectedShipDate || "", + expectedShipDateUndecided: !order.expectedShipDate, + deliveryRequestDate: order.deliveryRequestDate || "", + deliveryMethod: order.deliveryMethod || "", + shippingCost: order.shippingCost || "", + receiver: order.receiver || "", + receiverContact: order.receiverContact || "", + address: order.address || "", + addressDetail: order.addressDetail || "", + remarks: order.remarks || "", + items: order.items || [], + canEditItems, + subtotal: order.subtotal || order.amount, + discountRate: order.discountRate || 0, + totalAmount: order.amount, + }); + } else { + toast.error(result.error || "수주 정보를 불러오는데 실패했습니다."); + router.push("/sales/order-management-sales"); + } + } catch (error) { + console.error("Error loading order:", error); + toast.error("수주 정보를 불러오는 중 오류가 발생했습니다."); + router.push("/sales/order-management-sales"); + } finally { + setLoading(false); + } + } + loadOrder(); + }, [orderId, router]); const handleCancel = () => { router.push(`/sales/order-management-sales/${orderId}`); @@ -222,11 +196,39 @@ export default function OrderEditPage() { setIsSaving(true); try { - // TODO: API 연동 - console.log("수주 수정 데이터:", form); - await new Promise((resolve) => setTimeout(resolve, 500)); - toast.success("수주가 수정되었습니다."); - router.push(`/sales/order-management-sales/${orderId}`); + // API 연동 + const result = await updateOrder(orderId, { + clientId: undefined, // 기존 값 유지 + siteName: form.siteName, + expectedShipDate: form.expectedShipDateUndecided ? undefined : form.expectedShipDate, + deliveryRequestDate: form.deliveryRequestDate, + deliveryMethod: form.deliveryMethod, + shippingCost: form.shippingCost, + receiver: form.receiver, + receiverContact: form.receiverContact, + address: form.address, + addressDetail: form.addressDetail, + remarks: form.remarks, + items: form.items.map((item) => ({ + itemId: item.id ? parseInt(item.id, 10) : undefined, + itemCode: item.itemCode, + itemName: item.itemName, + specification: item.spec, + quantity: item.quantity, + unit: item.unit, + unitPrice: item.unitPrice, + })), + }); + + if (result.success) { + toast.success("수주가 수정되었습니다."); + router.push(`/sales/order-management-sales/${orderId}`); + } else { + toast.error(result.error || "수주 수정에 실패했습니다."); + } + } catch (error) { + console.error("Error updating order:", error); + toast.error("수주 수정 중 오류가 발생했습니다."); } finally { setIsSaving(false); } 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 c48b0b14..41162cf5 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 @@ -54,266 +54,14 @@ import { } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { - OrderItem, OrderDocumentModal, type OrderDocumentType, + getOrderById, + updateOrderStatus, + type Order, + type OrderStatus, } from "@/components/orders"; -// 수주 상태 타입 -type OrderStatus = - | "order_registered" - | "order_confirmed" - | "production_ordered" - | "in_production" - | "rework" - | "work_completed" - | "shipped" - | "cancelled"; - -// 수주 상세 데이터 타입 -interface OrderDetail { - id: string; - lotNumber: string; - quoteNumber: string; - orderDate: string; - status: OrderStatus; - client: string; - siteName: string; - manager: string; - contact: string; - expectedShipDate: string; - deliveryRequestDate: string; - deliveryMethod: string; - shippingCost: string; - receiver: string; - receiverContact: string; - address: string; - remarks: string; - items: OrderItem[]; - subtotal: number; - discountRate: number; - totalAmount: number; -} - -// 샘플 품목 데이터 -const SAMPLE_ITEMS: OrderItem[] = [ - { - id: "1", - itemCode: "PRD-001", - itemName: "국민방화스크린세터", - type: "B1", - symbol: "FSS1", - spec: "7260×2600", - width: 7260, - height: 2600, - quantity: 2, - unit: "EA", - unitPrice: 8000000, - amount: 16000000, - }, - { - id: "2", - itemCode: "PRD-002", - itemName: "국민방화스크린세터", - type: "B1", - symbol: "FSS2", - spec: "5000×2400", - width: 5000, - height: 2400, - quantity: 3, - unit: "EA", - unitPrice: 7600000, - amount: 22800000, - }, -]; - -// 샘플 수주 데이터 (리스트 페이지와 동기화) -const SAMPLE_ORDERS: Record = { - "ORD-001": { - id: "ORD-001", - lotNumber: "KD-TS-251217-01", - quoteNumber: "KD-PR-251210-01", - orderDate: "2024-12-17", - status: "order_confirmed", - client: "태영건설(주)", - siteName: "데시앙 동탄 파크뷰", - manager: "김철수", - contact: "010-1234-5678", - expectedShipDate: "2025-01-15", - deliveryRequestDate: "2025-01-20", - deliveryMethod: "직접배차", - shippingCost: "무료", - receiver: "박반장", - receiverContact: "010-9876-5432", - address: "경기도 화성시 동탄대로 123-45 데시앙 동탄 파크뷰 현장", - remarks: "4층 우선 납품 요청", - items: SAMPLE_ITEMS, - subtotal: 38800000, - discountRate: 0, - totalAmount: 38800000, - }, - "ORD-002": { - id: "ORD-002", - lotNumber: "KD-TS-251217-02", - quoteNumber: "KD-PR-251211-02", - orderDate: "2024-12-17", - status: "in_production", - client: "현대건설(주)", - siteName: "힐스테이트 판교역", - manager: "이영희", - contact: "010-2345-6789", - expectedShipDate: "2025-01-20", - deliveryRequestDate: "2025-01-25", - deliveryMethod: "상차", - shippingCost: "선불", - receiver: "김반장", - receiverContact: "010-8765-4321", - address: "경기도 성남시 분당구 판교역로 123", - remarks: "지하 1층 납품", - items: SAMPLE_ITEMS, - subtotal: 52500000, - discountRate: 0, - totalAmount: 52500000, - }, - "ORD-003": { - id: "ORD-003", - lotNumber: "KD-TS-251216-01", - quoteNumber: "KD-PR-251208-03", - orderDate: "2024-12-16", - status: "production_ordered", - client: "GS건설(주)", - siteName: "자이 강남센터", - manager: "박민수", - contact: "010-3456-7890", - expectedShipDate: "2025-01-10", - deliveryRequestDate: "2025-01-15", - deliveryMethod: "직접배차", - shippingCost: "무료", - receiver: "최반장", - receiverContact: "010-7654-3210", - address: "서울시 강남구 테헤란로 234", - remarks: "", - items: SAMPLE_ITEMS, - subtotal: 45000000, - discountRate: 0, - totalAmount: 45000000, - }, - "ORD-004": { - id: "ORD-004", - lotNumber: "KD-TS-251215-01", - quoteNumber: "KD-PR-251205-04", - orderDate: "2024-12-15", - status: "shipped", - client: "대우건설(주)", - siteName: "푸르지오 송도", - manager: "정수진", - contact: "010-4567-8901", - expectedShipDate: "2024-12-20", - deliveryRequestDate: "2024-12-22", - deliveryMethod: "상차", - shippingCost: "착불", - receiver: "오반장", - receiverContact: "010-6543-2109", - address: "인천시 연수구 송도동 456", - remarks: "출하 완료", - items: SAMPLE_ITEMS, - subtotal: 28900000, - discountRate: 0, - totalAmount: 28900000, - }, - "ORD-005": { - id: "ORD-005", - lotNumber: "KD-TS-251214-01", - quoteNumber: "KD-PR-251201-05", - orderDate: "2024-12-14", - status: "rework", - client: "포스코건설", - siteName: "더샵 분당센트럴", - manager: "강호동", - contact: "010-5678-9012", - expectedShipDate: "2025-01-25", - deliveryRequestDate: "2025-01-30", - deliveryMethod: "직접배차", - shippingCost: "무료", - receiver: "유반장", - receiverContact: "010-5432-1098", - address: "경기도 성남시 분당구 정자동 789", - remarks: "재작업 진행 중", - items: SAMPLE_ITEMS, - subtotal: 62000000, - discountRate: 0, - totalAmount: 62000000, - }, - "ORD-006": { - id: "ORD-006", - lotNumber: "KD-TS-251213-01", - quoteNumber: "KD-PR-251128-06", - orderDate: "2024-12-13", - status: "work_completed", - client: "롯데건설(주)", - siteName: "캐슬 잠실파크", - manager: "신동엽", - contact: "010-6789-0123", - expectedShipDate: "2024-12-25", - deliveryRequestDate: "2024-12-28", - deliveryMethod: "직접배차", - shippingCost: "무료", - receiver: "한반장", - receiverContact: "010-4321-0987", - address: "서울시 송파구 잠실동 321", - remarks: "작업 완료, 출하 대기", - items: SAMPLE_ITEMS, - subtotal: 35500000, - discountRate: 0, - totalAmount: 35500000, - }, - "ORD-007": { - id: "ORD-007", - lotNumber: "KD-TS-251212-01", - quoteNumber: "KD-PR-251125-07", - orderDate: "2024-12-12", - status: "order_registered", - client: "삼성물산(주)", - siteName: "래미안 서초", - manager: "유재석", - contact: "010-7890-1234", - expectedShipDate: "", - deliveryRequestDate: "2025-02-01", - deliveryMethod: "", - shippingCost: "", - receiver: "이반장", - receiverContact: "010-3210-9876", - address: "서울시 서초구 서초동 654", - remarks: "수주 등록 상태", - items: SAMPLE_ITEMS, - subtotal: 48000000, - discountRate: 0, - totalAmount: 48000000, - }, - "ORD-008": { - id: "ORD-008", - lotNumber: "KD-TS-251211-01", - quoteNumber: "KD-PR-251120-08", - orderDate: "2024-12-11", - status: "shipped", - client: "SK에코플랜트", - siteName: "SK VIEW 일산", - manager: "하하", - contact: "010-8901-2345", - expectedShipDate: "2024-12-18", - deliveryRequestDate: "2024-12-20", - deliveryMethod: "상차", - shippingCost: "선불", - receiver: "조반장", - receiverContact: "010-2109-8765", - address: "경기도 고양시 일산서구 주엽동 987", - remarks: "출하 완료, 미수금 있음", - items: SAMPLE_ITEMS, - subtotal: 31200000, - discountRate: 0, - totalAmount: 31200000, - }, -}; // 상태 뱃지 헬퍼 function getOrderStatusBadge(status: OrderStatus) { @@ -350,9 +98,10 @@ export default function OrderDetailPage() { const params = useParams(); const orderId = params.id as string; - const [order, setOrder] = useState(null); + const [order, setOrder] = useState(null); const [loading, setLoading] = useState(true); const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false); + const [isCancelling, setIsCancelling] = useState(false); // 취소 폼 상태 const [cancelReason, setCancelReason] = useState(""); @@ -362,14 +111,27 @@ export default function OrderDetailPage() { const [documentModalOpen, setDocumentModalOpen] = useState(false); const [documentType, setDocumentType] = useState("contract"); - // 데이터 로드 (샘플 - ID로 매칭) + // 데이터 로드 (API 호출) useEffect(() => { - // 실제 구현에서는 API 호출 - setTimeout(() => { - const foundOrder = SAMPLE_ORDERS[orderId]; - setOrder(foundOrder || null); - setLoading(false); - }, 300); + async function loadOrder() { + try { + setLoading(true); + const result = await getOrderById(orderId); + if (result.success && result.data) { + setOrder(result.data); + } else { + toast.error(result.error || "수주 정보를 불러오는데 실패했습니다."); + setOrder(null); + } + } catch (error) { + console.error("Error loading order:", error); + toast.error("수주 정보를 불러오는 중 오류가 발생했습니다."); + setOrder(null); + } finally { + setLoading(false); + } + } + loadOrder(); }, [orderId]); const handleBack = () => { @@ -396,18 +158,31 @@ export default function OrderDetailPage() { setIsCancelDialogOpen(true); }; - const handleConfirmCancel = () => { + const handleConfirmCancel = async () => { if (!cancelReason) { toast.error("취소 사유를 선택해주세요."); return; } if (order) { - setOrder({ ...order, status: "cancelled" }); - toast.success("수주가 취소되었습니다."); + setIsCancelling(true); + try { + const result = await updateOrderStatus(order.id, "cancelled"); + if (result.success) { + setOrder({ ...order, status: "cancelled" }); + toast.success("수주가 취소되었습니다."); + setIsCancelDialogOpen(false); + setCancelReason(""); + setCancelDetail(""); + } else { + toast.error(result.error || "수주 취소에 실패했습니다."); + } + } catch (error) { + console.error("Error cancelling order:", error); + toast.error("수주 취소 중 오류가 발생했습니다."); + } finally { + setIsCancelling(false); + } } - setIsCancelDialogOpen(false); - setCancelReason(""); - setCancelDetail(""); }; // 문서 모달 열기 @@ -770,9 +545,10 @@ export default function OrderDetailPage() { variant="outline" onClick={handleConfirmCancel} className="border-gray-300" + disabled={isCancelling} > - 취소 확정 + {isCancelling ? "취소 중..." : "취소 확정"} 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 2f0dbbcb..c5e76adb 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 @@ -2,10 +2,11 @@ /** * 수주 등록 페이지 + * API 연동 완료 (2025-01-08) */ import { useRouter } from "next/navigation"; -import { OrderRegistration, OrderFormData } from "@/components/orders"; +import { OrderRegistration, OrderFormData, createOrder } from "@/components/orders"; import { toast } from "sonner"; export default function OrderNewPage() { @@ -16,14 +17,19 @@ export default function OrderNewPage() { }; const handleSave = async (formData: OrderFormData) => { - // TODO: API 연동 - console.log("수주 등록 데이터:", formData); + try { + const result = await createOrder(formData); - // 임시: 성공 시뮬레이션 - await new Promise((resolve) => setTimeout(resolve, 500)); - - toast.success("수주가 등록되었습니다."); - router.push("/sales/order-management-sales"); + if (result.success) { + toast.success("수주가 등록되었습니다."); + router.push("/sales/order-management-sales"); + } else { + toast.error(result.error || "수주 등록에 실패했습니다."); + } + } catch (error) { + console.error("Error creating order:", error); + toast.error("수주 등록 중 오류가 발생했습니다."); + } }; return ; diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx index fb5dbd94..d01275aa 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx @@ -7,9 +7,10 @@ * - 상단 통계 카드: 이번 달 수주, 분할 대기, 생산지시 대기, 출하 대기 * - 필터 탭: 전체, 수주등록, 수주확정, 생산지시완료, 미수 * - 완전한 반응형 지원 + * - API 연동 완료 (2025-01-08) */ -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import { FileText, @@ -20,6 +21,7 @@ import { ClipboardList, Truck, Eye, + Loader2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -31,11 +33,7 @@ import { } from "@/components/templates/IntegratedListTemplateV2"; import { toast } from "sonner"; import { - Table, - TableHeader, TableRow, - TableHead, - TableBody, TableCell, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; @@ -51,149 +49,16 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { + getOrders, + getOrderStats, + deleteOrder, + deleteOrders, + type Order, + type OrderStatus, + type OrderStats, +} from "@/components/orders/actions"; -// 수주 상태 타입 -type OrderStatus = - | "order_registered" // 수주등록 - | "order_confirmed" // 수주확정 - | "production_ordered" // 생산지시완료 - | "in_production" // 생산중 - | "rework" // 재작업중 - | "work_completed" // 작업완료 - | "shipped" // 출하완료 - | "cancelled"; // 취소 - -// 수주 타입 -interface Order { - id: string; - lotNumber: string; // 로트번호 KD-TS-XXXXXX-XX - quoteNumber: string; // 견적번호 KD-PR-XXXXXX-XX - orderDate: string; // 수주일자 - client: string; // 발주처 - siteName: string; // 현장명 - status: OrderStatus; - expectedShipDate?: string; // 출고예정일 - deliveryMethod?: string; // 배송방식 - amount: number; // 금액 - itemCount: number; // 품목 수 - hasReceivable?: boolean; // 미수 여부 -} - -// 샘플 수주 데이터 -const SAMPLE_ORDERS: Order[] = [ - { - id: "ORD-001", - lotNumber: "KD-TS-251217-01", - quoteNumber: "KD-PR-251210-01", - orderDate: "2024-12-17", - client: "태영건설(주)", - siteName: "데시앙 동탄 파크뷰", - status: "order_confirmed", - expectedShipDate: "2025-01-15", - deliveryMethod: "직접배차", - amount: 38800000, - itemCount: 5, - hasReceivable: false, - }, - { - id: "ORD-002", - lotNumber: "KD-TS-251217-02", - quoteNumber: "KD-PR-251211-02", - orderDate: "2024-12-17", - client: "현대건설(주)", - siteName: "힐스테이트 판교역", - status: "in_production", - expectedShipDate: "2025-01-20", - deliveryMethod: "상차", - amount: 52500000, - itemCount: 8, - hasReceivable: false, - }, - { - id: "ORD-003", - lotNumber: "KD-TS-251216-01", - quoteNumber: "KD-PR-251208-03", - orderDate: "2024-12-16", - client: "GS건설(주)", - siteName: "자이 강남센터", - status: "production_ordered", - expectedShipDate: "2025-01-10", - deliveryMethod: "직접배차", - amount: 45000000, - itemCount: 6, - hasReceivable: false, - }, - { - id: "ORD-004", - lotNumber: "KD-TS-251215-01", - quoteNumber: "KD-PR-251205-04", - orderDate: "2024-12-15", - client: "대우건설(주)", - siteName: "푸르지오 송도", - status: "shipped", - expectedShipDate: "2024-12-20", - deliveryMethod: "상차", - amount: 28900000, - itemCount: 4, - hasReceivable: true, - }, - { - id: "ORD-005", - lotNumber: "KD-TS-251214-01", - quoteNumber: "KD-PR-251201-05", - orderDate: "2024-12-14", - client: "포스코건설", - siteName: "더샵 분당센트럴", - status: "rework", - expectedShipDate: "2025-01-25", - deliveryMethod: "직접배차", - amount: 62000000, - itemCount: 10, - hasReceivable: false, - }, - { - id: "ORD-006", - lotNumber: "KD-TS-251213-01", - quoteNumber: "KD-PR-251128-06", - orderDate: "2024-12-13", - client: "롯데건설(주)", - siteName: "캐슬 잠실파크", - status: "work_completed", - expectedShipDate: "2024-12-25", - deliveryMethod: "직접배차", - amount: 35500000, - itemCount: 5, - hasReceivable: false, - }, - { - id: "ORD-007", - lotNumber: "KD-TS-251212-01", - quoteNumber: "KD-PR-251125-07", - orderDate: "2024-12-12", - client: "삼성물산(주)", - siteName: "래미안 서초", - status: "order_registered", - expectedShipDate: undefined, - deliveryMethod: undefined, - amount: 48000000, - itemCount: 7, - hasReceivable: false, - }, - { - id: "ORD-008", - lotNumber: "KD-TS-251211-01", - quoteNumber: "KD-PR-251120-08", - orderDate: "2024-12-11", - client: "SK에코플랜트", - siteName: "SK VIEW 일산", - status: "shipped", - expectedShipDate: "2024-12-18", - deliveryMethod: "상차", - amount: 31200000, - itemCount: 4, - hasReceivable: true, - }, -]; // 상태 뱃지 헬퍼 함수 function getOrderStatusBadge(status: OrderStatus) { @@ -236,8 +101,42 @@ export default function OrderManagementSalesPage() { const [mobileDisplayCount, setMobileDisplayCount] = useState(20); const sentinelRef = useRef(null); - // 로컬 데이터 state (실제 구현에서는 API 연동) - const [orders, setOrders] = useState(SAMPLE_ORDERS); + // API 연동 state + const [orders, setOrders] = useState([]); + const [apiStats, setApiStats] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isDeleting, setIsDeleting] = useState(false); + + // 데이터 로드 함수 + const loadData = useCallback(async () => { + try { + setIsLoading(true); + const [ordersResult, statsResult] = await Promise.all([ + getOrders(), + getOrderStats(), + ]); + + if (ordersResult.success && ordersResult.data) { + setOrders(ordersResult.data); + } else { + toast.error(ordersResult.error || "수주 목록을 불러오는데 실패했습니다."); + } + + if (statsResult.success && statsResult.data) { + setApiStats(statsResult.data); + } + } catch (error) { + console.error("Error loading orders:", error); + toast.error("데이터를 불러오는 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }, []); + + // 초기 데이터 로드 + useEffect(() => { + loadData(); + }, [loadData]); // 필터링 및 정렬 const filteredOrders = orders @@ -313,7 +212,7 @@ export default function OrderManagementSalesPage() { setMobileDisplayCount(20); }, [searchTerm, filterType]); - // 통계 계산 + // 통계 계산 (API stats 우선 사용, 없으면 로컬 계산) const now = new Date(); const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); @@ -321,18 +220,18 @@ export default function OrderManagementSalesPage() { const thisMonthOrders = orders.filter( (o) => new Date(o.orderDate) >= startOfMonth ); - const thisMonthAmount = thisMonthOrders.reduce((sum, o) => sum + o.amount, 0); + const thisMonthAmount = apiStats?.thisMonthAmount ?? thisMonthOrders.reduce((sum, o) => sum + o.amount, 0); // 분할 대기 (예시: 수주확정 상태) - const splitPendingCount = orders.filter((o) => o.status === "order_confirmed").length; + const splitPendingCount = apiStats?.splitPending ?? orders.filter((o) => o.status === "order_confirmed").length; // 생산지시 대기 (수주확정 상태 중 생산지시 안된 것) - const productionPendingCount = orders.filter( + const productionPendingCount = apiStats?.productionPending ?? orders.filter( (o) => o.status === "order_confirmed" || o.status === "order_registered" ).length; // 출하 대기 (작업완료 상태) - const shipPendingCount = orders.filter((o) => o.status === "work_completed").length; + const shipPendingCount = apiStats?.shipPending ?? orders.filter((o) => o.status === "work_completed").length; const stats = [ { @@ -370,7 +269,8 @@ export default function OrderManagementSalesPage() { router.push(`/sales/order-management-sales/${order.id}/edit`); }; - const handleCancel = (orderId: string) => { + // 개별 취소 기능은 상세 페이지에서 처리 + const _handleCancel = (orderId: string) => { setCancelTargetId(orderId); setIsCancelDialogOpen(true); }; @@ -399,25 +299,72 @@ export default function OrderManagementSalesPage() { // 다중 선택 삭제 (IntegratedListTemplateV2에서 확인 후 호출됨) // 템플릿 내부에서 이미 확인 팝업을 처리하므로 바로 삭제 실행 - const handleBulkDelete = () => { + const handleBulkDelete = async () => { const selectedIds = Array.from(selectedItems); if (selectedIds.length > 0) { - setOrders(orders.filter((o) => !selectedIds.includes(o.id))); - setSelectedItems(new Set()); - toast.success(`${selectedIds.length}개의 수주가 삭제되었습니다.`); + setIsDeleting(true); + try { + const result = await deleteOrders(selectedIds); + if (result.success) { + setOrders(orders.filter((o) => !selectedIds.includes(o.id))); + setSelectedItems(new Set()); + toast.success(`${selectedIds.length}개의 수주가 삭제되었습니다.`); + // 통계 새로고침 + const statsResult = await getOrderStats(); + if (statsResult.success && statsResult.data) { + setApiStats(statsResult.data); + } + } else { + toast.error(result.error || "삭제에 실패했습니다."); + } + } catch (error) { + console.error("Error deleting orders:", error); + toast.error("삭제 중 오류가 발생했습니다."); + } finally { + setIsDeleting(false); + } } }; // 삭제 확정 (단일/다중 모두 처리) - const handleConfirmDelete = () => { + const handleConfirmDelete = async () => { if (deleteTargetIds.length > 0) { const count = deleteTargetIds.length; - setOrders(orders.filter((o) => !deleteTargetIds.includes(o.id))); - // 선택 상태 초기화 - setSelectedItems(new Set()); - toast.success(`${count}개의 수주가 삭제되었습니다.`); - setIsDeleteDialogOpen(false); - setDeleteTargetIds([]); + setIsDeleting(true); + try { + let success = false; + if (count === 1) { + const result = await deleteOrder(deleteTargetIds[0]); + success = result.success; + if (!success) { + toast.error(result.error || "삭제에 실패했습니다."); + } + } else { + const result = await deleteOrders(deleteTargetIds); + success = result.success; + if (!success) { + toast.error(result.error || "삭제에 실패했습니다."); + } + } + + if (success) { + setOrders(orders.filter((o) => !deleteTargetIds.includes(o.id))); + setSelectedItems(new Set()); + toast.success(`${count}개의 수주가 삭제되었습니다.`); + // 통계 새로고침 + const statsResult = await getOrderStats(); + if (statsResult.success && statsResult.data) { + setApiStats(statsResult.data); + } + } + } catch (error) { + console.error("Error deleting orders:", error); + toast.error("삭제 중 오류가 발생했습니다."); + } finally { + setIsDeleting(false); + setIsDeleteDialogOpen(false); + setDeleteTargetIds([]); + } } }; @@ -654,6 +601,18 @@ export default function OrderManagementSalesPage() { ); }; + // 로딩 상태 표시 + if (isLoading) { + return ( +
+
+ +

수주 목록을 불러오는 중...

+
+
+ ); + } + return ( <> - 취소 + 취소 - - 삭제 + {isDeleting ? ( + + ) : ( + + )} + {isDeleting ? "삭제 중..." : "삭제"} diff --git a/src/components/orders/actions.ts b/src/components/orders/actions.ts new file mode 100644 index 00000000..e2c8dfaa --- /dev/null +++ b/src/components/orders/actions.ts @@ -0,0 +1,630 @@ +'use server'; + +import { serverFetch } from '@/lib/api/fetch-wrapper'; + +// ============================================================================ +// API 타입 정의 +// ============================================================================ + +interface ApiOrder { + id: number; + tenant_id: number; + quote_id: number | null; + order_no: string; + order_type_code: string; + status_code: string; + category_code: string | null; + client_id: number | null; + client_name: string | null; + client_contact: string | null; + site_name: string | null; + quantity: number; + supply_amount: number; + tax_amount: number; + total_amount: number; + discount_rate: number; + discount_amount: number; + delivery_date: string | null; + delivery_method_code: string | null; + received_at: string | null; + memo: string | null; + remarks: string | null; + note: string | null; + created_by: number | null; + updated_by: number | null; + created_at: string; + updated_at: string; + client?: ApiClient | null; + items?: ApiOrderItem[]; + quote?: ApiQuote | null; +} + +interface ApiOrderItem { + id: number; + order_id: number; + item_id: number | null; + item_name: string; + specification: string | null; + quantity: number; + unit: string | null; + unit_price: number; + supply_amount: number; + tax_amount: number; + total_amount: number; + sort_order: number; +} + +interface ApiClient { + id: number; + name: string; + business_no?: string; + representative?: string; + phone?: string; + email?: string; +} + +interface ApiQuote { + id: number; + quote_no: string; + site_name: string | null; +} + +interface ApiOrderStats { + total: number; + draft: number; + confirmed: number; + in_progress: number; + completed: number; + cancelled: number; + total_amount: number; + confirmed_amount: number; +} + +interface ApiResponse { + success: boolean; + message: string; + data: T; +} + +interface PaginatedResponse { + current_page: number; + data: T[]; + last_page: number; + per_page: number; + total: number; +} + +// ============================================================================ +// Frontend 타입 정의 +// ============================================================================ + +// 수주 상태 타입 (API와 매핑) +export type OrderStatus = + | 'order_registered' // DRAFT + | 'order_confirmed' // CONFIRMED + | 'production_ordered' // IN_PROGRESS + | 'in_production' // IN_PROGRESS (세부) + | 'rework' // IN_PROGRESS (세부) + | 'work_completed' // IN_PROGRESS (세부) + | 'shipped' // COMPLETED + | 'cancelled'; // CANCELLED + +export interface Order { + id: string; + lotNumber: string; // order_no + quoteNumber: string; // quote.quote_no + quoteId?: number; + orderDate: string; // received_at + client: string; // client_name + clientId?: number; + siteName: string; // site_name + status: OrderStatus; + statusCode: string; // 원본 status_code + expectedShipDate?: string; // delivery_date + deliveryMethod?: string; // delivery_method_code + amount: number; // total_amount + supplyAmount: number; + taxAmount: number; + itemCount: number; // items.length + hasReceivable?: boolean; // 미수 여부 (추후 구현) + memo?: string; + remarks?: string; + note?: string; + items?: OrderItem[]; +} + +export interface OrderItem { + id: string; + itemId?: number; + itemName: string; + specification?: string; + quantity: number; + unit?: string; + unitPrice: number; + supplyAmount: number; + taxAmount: number; + totalAmount: number; + sortOrder: number; +} + +export interface OrderFormData { + orderTypeCode?: string; + categoryCode?: string; + clientId?: number; + clientName?: string; + clientContact?: string; + siteName?: string; + supplyAmount?: number; + taxAmount?: number; + totalAmount?: number; + discountRate?: number; + discountAmount?: number; + deliveryDate?: string; + deliveryMethodCode?: string; + receivedAt?: string; + memo?: string; + remarks?: string; + note?: string; + items?: OrderItemFormData[]; +} + +export interface OrderItemFormData { + itemId?: number; + itemName: string; + specification?: string; + quantity: number; + unit?: string; + unitPrice: number; +} + +export interface OrderStats { + total: number; + draft: number; + confirmed: number; + inProgress: number; + completed: number; + cancelled: number; + totalAmount: number; + confirmedAmount: number; +} + +// ============================================================================ +// 상태 매핑 +// ============================================================================ + +const API_TO_FRONTEND_STATUS: Record = { + 'DRAFT': 'order_registered', + 'CONFIRMED': 'order_confirmed', + 'IN_PROGRESS': 'production_ordered', + 'COMPLETED': 'shipped', + 'CANCELLED': 'cancelled', +}; + +const FRONTEND_TO_API_STATUS: Record = { + 'order_registered': 'DRAFT', + 'order_confirmed': 'CONFIRMED', + 'production_ordered': 'IN_PROGRESS', + 'in_production': 'IN_PROGRESS', + 'rework': 'IN_PROGRESS', + 'work_completed': 'IN_PROGRESS', + 'shipped': 'COMPLETED', + 'cancelled': 'CANCELLED', +}; + +// ============================================================================ +// 데이터 변환 함수 +// ============================================================================ + +function transformApiToFrontend(apiData: ApiOrder): Order { + return { + id: String(apiData.id), + lotNumber: apiData.order_no, + quoteNumber: apiData.quote?.quote_no || '', + quoteId: apiData.quote_id ?? undefined, + orderDate: apiData.received_at || apiData.created_at.split('T')[0], + client: apiData.client_name || apiData.client?.name || '', + clientId: apiData.client_id ?? undefined, + siteName: apiData.site_name || '', + status: API_TO_FRONTEND_STATUS[apiData.status_code] || 'order_registered', + statusCode: apiData.status_code, + expectedShipDate: apiData.delivery_date ?? undefined, + deliveryMethod: apiData.delivery_method_code ?? undefined, + amount: apiData.total_amount, + supplyAmount: apiData.supply_amount, + taxAmount: apiData.tax_amount, + itemCount: apiData.items?.length || 0, + hasReceivable: false, // 추후 구현 + memo: apiData.memo ?? undefined, + remarks: apiData.remarks ?? undefined, + note: apiData.note ?? undefined, + items: apiData.items?.map(transformItemApiToFrontend), + }; +} + +function transformItemApiToFrontend(apiItem: ApiOrderItem): OrderItem { + return { + id: String(apiItem.id), + itemId: apiItem.item_id ?? undefined, + itemName: apiItem.item_name, + specification: apiItem.specification ?? undefined, + quantity: apiItem.quantity, + unit: apiItem.unit ?? undefined, + unitPrice: apiItem.unit_price, + supplyAmount: apiItem.supply_amount, + taxAmount: apiItem.tax_amount, + totalAmount: apiItem.total_amount, + sortOrder: apiItem.sort_order, + }; +} + +function transformFrontendToApi(data: OrderFormData): Record { + return { + order_type_code: data.orderTypeCode || 'ORDER', + category_code: data.categoryCode || null, + client_id: data.clientId || null, + client_name: data.clientName || null, + client_contact: data.clientContact || null, + site_name: data.siteName || null, + supply_amount: data.supplyAmount || 0, + tax_amount: data.taxAmount || 0, + total_amount: data.totalAmount || 0, + discount_rate: data.discountRate || 0, + discount_amount: data.discountAmount || 0, + delivery_date: data.deliveryDate || null, + delivery_method_code: data.deliveryMethodCode || null, + received_at: data.receivedAt || null, + memo: data.memo || null, + remarks: data.remarks || null, + note: data.note || null, + items: data.items?.map((item) => ({ + item_id: item.itemId || null, + item_name: item.itemName, + specification: item.specification || null, + quantity: item.quantity, + unit: item.unit || null, + unit_price: item.unitPrice, + })) || [], + }; +} + +// ============================================================================ +// API 함수 +// ============================================================================ + +/** + * 수주 목록 조회 + */ +export async function getOrders(params?: { + page?: number; + size?: number; + q?: string; + status?: string; + order_type?: string; + client_id?: number; + date_from?: string; + date_to?: string; +}): Promise<{ + success: boolean; + data?: { items: Order[]; total: number; page: number; totalPages: number }; + error?: string; + __authError?: boolean; +}> { + try { + const searchParams = new URLSearchParams(); + + if (params?.page) searchParams.set('page', String(params.page)); + if (params?.size) searchParams.set('size', String(params.size)); + if (params?.q) searchParams.set('q', params.q); + if (params?.status) { + // Frontend status를 API status로 변환 + const apiStatus = FRONTEND_TO_API_STATUS[params.status as OrderStatus]; + if (apiStatus) searchParams.set('status', apiStatus); + } + if (params?.order_type) searchParams.set('order_type', params.order_type); + if (params?.client_id) searchParams.set('client_id', String(params.client_id)); + if (params?.date_from) searchParams.set('date_from', params.date_from); + if (params?.date_to) searchParams.set('date_to', params.date_to); + + const { response, error } = await serverFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/orders?${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(transformApiToFrontend), + total: result.data.total, + page: result.data.current_page, + totalPages: result.data.last_page, + }, + }; + } catch (error) { + console.error('[getOrders] Error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +} + +/** + * 수주 상세 조회 + */ +export async function getOrderById(id: string): Promise<{ + success: boolean; + data?: Order; + error?: string; + __authError?: boolean; +}> { + try { + const { response, error } = await serverFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/orders/${id}`, + { 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: transformApiToFrontend(result.data) }; + } catch (error) { + console.error('[getOrderById] Error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +} + +/** + * 수주 생성 + */ +export async function createOrder(data: OrderFormData): Promise<{ + success: boolean; + data?: Order; + error?: string; + __authError?: boolean; +}> { + try { + const apiData = transformFrontendToApi(data); + + const { response, error } = await serverFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/orders`, + { method: 'POST', body: JSON.stringify(apiData) } + ); + + 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: transformApiToFrontend(result.data) }; + } catch (error) { + console.error('[createOrder] Error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +} + +/** + * 수주 수정 + */ +export async function updateOrder(id: string, data: OrderFormData): Promise<{ + success: boolean; + data?: Order; + error?: string; + __authError?: boolean; +}> { + try { + const apiData = transformFrontendToApi(data); + + const { response, error } = await serverFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/orders/${id}`, + { method: 'PUT', body: JSON.stringify(apiData) } + ); + + 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: transformApiToFrontend(result.data) }; + } catch (error) { + console.error('[updateOrder] Error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +} + +/** + * 수주 삭제 + */ +export async function deleteOrder(id: string): Promise<{ + success: boolean; + error?: string; + __authError?: boolean; +}> { + try { + const { response, error } = await serverFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/orders/${id}`, + { method: 'DELETE' } + ); + + 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 }; + } catch (error) { + console.error('[deleteOrder] Error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +} + +/** + * 수주 상태 변경 + */ +export async function updateOrderStatus(id: string, status: OrderStatus): Promise<{ + success: boolean; + data?: Order; + error?: string; + __authError?: boolean; +}> { + try { + const apiStatus = FRONTEND_TO_API_STATUS[status]; + if (!apiStatus) { + return { success: false, error: '유효하지 않은 상태입니다.' }; + } + + const { response, error } = await serverFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/orders/${id}/status`, + { method: 'PATCH', body: JSON.stringify({ status: apiStatus }) } + ); + + 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: transformApiToFrontend(result.data) }; + } catch (error) { + console.error('[updateOrderStatus] Error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +} + +/** + * 수주 통계 조회 + */ +export async function getOrderStats(): Promise<{ + success: boolean; + data?: OrderStats; + error?: string; + __authError?: boolean; +}> { + try { + const { response, error } = await serverFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/orders/stats`, + { 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: { + total: result.data.total, + draft: result.data.draft, + confirmed: result.data.confirmed, + inProgress: result.data.in_progress, + completed: result.data.completed, + cancelled: result.data.cancelled, + totalAmount: result.data.total_amount, + confirmedAmount: result.data.confirmed_amount, + }, + }; + } catch (error) { + console.error('[getOrderStats] Error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +} + +/** + * 수주 일괄 삭제 + */ +export async function deleteOrders(ids: string[]): Promise<{ + success: boolean; + deletedCount?: number; + error?: string; + __authError?: boolean; +}> { + try { + // 순차적으로 삭제 (API에 bulk delete가 없으므로) + let deletedCount = 0; + const errors: string[] = []; + + for (const id of ids) { + const result = await deleteOrder(id); + if (result.success) { + deletedCount++; + } else { + errors.push(result.error || `ID ${id} 삭제 실패`); + } + } + + if (deletedCount === 0 && errors.length > 0) { + return { success: false, error: errors[0] }; + } + + return { success: true, deletedCount }; + } catch (error) { + console.error('[deleteOrders] Error:', error); + return { success: false, error: '서버 오류가 발생했습니다.' }; + } +} diff --git a/src/components/orders/index.ts b/src/components/orders/index.ts index 8f264045..bdceb67d 100644 --- a/src/components/orders/index.ts +++ b/src/components/orders/index.ts @@ -1,7 +1,26 @@ /** - * 수주 관련 컴포넌트 + * 수주 관련 컴포넌트 및 API 함수 */ +// API Actions +export { + getOrders, + getOrderById, + createOrder, + updateOrder, + deleteOrder, + deleteOrders, + updateOrderStatus, + getOrderStats, + type Order, + type OrderItem as OrderItemApi, + type OrderFormData as OrderApiFormData, + type OrderItemFormData, + type OrderStats, + type OrderStatus, +} from "./actions"; + +// Components export { OrderRegistration, type OrderFormData } from "./OrderRegistration"; export { QuotationSelectDialog, type QuotationForSelect, type QuotationItem } from "./QuotationSelectDialog"; export { ItemAddDialog, type OrderItem } from "./ItemAddDialog";