feat(orders): Phase 2 - Frontend API 연동 완료

- 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 연동
This commit is contained in:
2026-01-08 17:29:06 +09:00
parent 29e7b41615
commit 572ffe81cf
7 changed files with 1026 additions and 537 deletions

View File

@@ -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 구현 (진행 중)

View File

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

View File

@@ -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<string, OrderDetail> = {
"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<OrderDetail | null>(null);
const [order, setOrder] = useState<Order | null>(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<OrderDocumentType>("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}
>
<XCircle className="h-4 w-4 mr-1" />
{isCancelling ? "취소 중..." : "취소 확정"}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -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 <OrderRegistration onBack={handleBack} onSave={handleSave} />;

View File

@@ -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<HTMLDivElement>(null);
// 로컬 데이터 state (실제 구현에서는 API 연동)
const [orders, setOrders] = useState<Order[]>(SAMPLE_ORDERS);
// API 연동 state
const [orders, setOrders] = useState<Order[]>([]);
const [apiStats, setApiStats] = useState<OrderStats | null>(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 (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
}
return (
<>
<IntegratedListTemplateV2
@@ -749,13 +708,18 @@ export default function OrderManagementSalesPage() {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={isDeleting}
className="bg-gray-900 hover:bg-gray-800 text-white gap-2"
>
<Trash2 className="h-4 w-4" />
{isDeleting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
{isDeleting ? "삭제 중..." : "삭제"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -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<T> {
success: boolean;
message: string;
data: T;
}
interface PaginatedResponse<T> {
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<string, OrderStatus> = {
'DRAFT': 'order_registered',
'CONFIRMED': 'order_confirmed',
'IN_PROGRESS': 'production_ordered',
'COMPLETED': 'shipped',
'CANCELLED': 'cancelled',
};
const FRONTEND_TO_API_STATUS: Record<OrderStatus, string> = {
'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<string, unknown> {
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<PaginatedResponse<ApiOrder>> = 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<ApiOrder> = 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<ApiOrder> = 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<ApiOrder> = 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<string> = 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<ApiOrder> = 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<ApiOrderStats> = 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: '서버 오류가 발생했습니다.' };
}
}

View File

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