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:
@@ -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 구현 (진행 중)
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
630
src/components/orders/actions.ts
Normal file
630
src/components/orders/actions.ts
Normal 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: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user