feat: [수주관리] 프론트엔드 노드별 그룹 UI 구현
- OrderNode 인터페이스 + ApiOrderNode 타입 정의 (actions.ts) - transformNodeApiToFrontend 변환 함수 (재귀 children/items 포함) - Order 타입에 nodes 필드, ApiOrder에 root_nodes 필드 추가 - OrderSalesDetailView: 노드 존재 시 개소별 카드 UI, 없으면 레거시 플랫 테이블 - OrderNodeCard: 접기/펼치기, 노드 상태 뱃지, 자재 테이블, 재귀 하위 노드 지원 - index.ts에 OrderNode export 추가
This commit is contained in:
@@ -30,6 +30,9 @@ import {
|
|||||||
FileCheck,
|
FileCheck,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
MapPin,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate";
|
import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate";
|
||||||
@@ -58,6 +61,7 @@ import {
|
|||||||
getOrderById,
|
getOrderById,
|
||||||
updateOrderStatus,
|
updateOrderStatus,
|
||||||
type Order,
|
type Order,
|
||||||
|
type OrderNode,
|
||||||
type OrderStatus,
|
type OrderStatus,
|
||||||
} from "@/components/orders";
|
} from "@/components/orders";
|
||||||
import { ServerErrorPage } from "@/components/common/ServerErrorPage";
|
import { ServerErrorPage } from "@/components/common/ServerErrorPage";
|
||||||
@@ -96,6 +100,119 @@ function InfoItem({ label, value }: { label: string; value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 노드 상태 뱃지
|
||||||
|
const NODE_STATUS_CONFIG: Record<string, { label: string; className: string }> = {
|
||||||
|
PENDING: { label: "대기", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||||
|
CONFIRMED: { label: "확정", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||||
|
IN_PRODUCTION: { label: "생산중", className: "bg-green-100 text-green-700 border-green-200" },
|
||||||
|
PRODUCED: { label: "생산완료", className: "bg-blue-600 text-white border-blue-600" },
|
||||||
|
SHIPPED: { label: "출하완료", className: "bg-gray-500 text-white border-gray-500" },
|
||||||
|
COMPLETED: { label: "완료", className: "bg-gray-500 text-white border-gray-500" },
|
||||||
|
CANCELLED: { label: "취소", className: "bg-red-100 text-red-700 border-red-200" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 노드별 카드 컴포넌트 (재귀 지원)
|
||||||
|
function OrderNodeCard({ node, depth = 0 }: { node: OrderNode; depth?: number }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
|
const statusConfig = NODE_STATUS_CONFIG[node.statusCode] || NODE_STATUS_CONFIG.PENDING;
|
||||||
|
const options = node.options || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`border rounded-lg ${depth > 0 ? 'ml-6' : ''}`}>
|
||||||
|
{/* 노드 헤더 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 transition-colors"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-gray-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-gray-500" />
|
||||||
|
)}
|
||||||
|
<MapPin className="h-4 w-4 text-blue-500" />
|
||||||
|
<span className="font-semibold text-sm">{node.name}</span>
|
||||||
|
{options.product_name && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({options.product_name as string})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(options.open_width || options.open_height) && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{options.open_width as string}x{options.open_height as string}mm
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<BadgeSm className={statusConfig.className}>
|
||||||
|
{statusConfig.label}
|
||||||
|
</BadgeSm>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{formatAmount(node.totalPrice)}원
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 노드 내용 (접기/펼치기) */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="border-t">
|
||||||
|
{/* 해당 노드의 자재 테이블 */}
|
||||||
|
{node.items.length > 0 && (
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[60px] text-center">순번</TableHead>
|
||||||
|
<TableHead>품목코드</TableHead>
|
||||||
|
<TableHead>품명</TableHead>
|
||||||
|
<TableHead>규격</TableHead>
|
||||||
|
<TableHead className="text-center">수량</TableHead>
|
||||||
|
<TableHead className="text-center">단위</TableHead>
|
||||||
|
<TableHead className="text-right">단가</TableHead>
|
||||||
|
<TableHead className="text-right">금액</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{node.items.map((item, index) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="text-center">{index + 1}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||||
|
{item.itemCode}
|
||||||
|
</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.itemName}</TableCell>
|
||||||
|
<TableCell>{item.spec}</TableCell>
|
||||||
|
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||||
|
<TableCell className="text-center">{item.unit}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatAmount(item.unitPrice)}원
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
{formatAmount(item.amount ?? 0)}원
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 하위 노드 재귀 */}
|
||||||
|
{node.children.length > 0 && (
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<OrderNodeCard key={child.id} node={child} depth={depth + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface OrderSalesDetailViewProps {
|
interface OrderSalesDetailViewProps {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
}
|
}
|
||||||
@@ -375,75 +492,113 @@ export function OrderSalesDetailView({ orderId }: OrderSalesDetailViewProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 제품 내역 */}
|
{/* 제품 내역 */}
|
||||||
<Card>
|
{order.nodes && order.nodes.length > 0 ? (
|
||||||
<CardHeader>
|
/* 노드별 그룹 표시 */
|
||||||
<CardTitle className="text-lg">제품 내역</CardTitle>
|
<Card>
|
||||||
</CardHeader>
|
<CardHeader>
|
||||||
<CardContent>
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<MapPin className="h-5 w-5" />
|
||||||
<Table>
|
개소별 내역 ({order.nodes.length}개소)
|
||||||
<TableHeader>
|
</CardTitle>
|
||||||
<TableRow>
|
</CardHeader>
|
||||||
<TableHead className="w-[60px] text-center">순번</TableHead>
|
<CardContent className="space-y-4">
|
||||||
<TableHead>품목코드</TableHead>
|
{order.nodes.map((node) => (
|
||||||
<TableHead>품명</TableHead>
|
<OrderNodeCard key={node.id} node={node} />
|
||||||
<TableHead>층</TableHead>
|
))}
|
||||||
<TableHead>부호</TableHead>
|
|
||||||
<TableHead>규격</TableHead>
|
|
||||||
<TableHead className="text-center">수량</TableHead>
|
|
||||||
<TableHead className="text-center">단위</TableHead>
|
|
||||||
<TableHead className="text-right">단가</TableHead>
|
|
||||||
<TableHead className="text-right">금액</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{(order.items || []).map((item, index) => (
|
|
||||||
<TableRow key={item.id}>
|
|
||||||
<TableCell className="text-center">{index + 1}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
|
||||||
{item.itemCode}
|
|
||||||
</code>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{item.itemName}</TableCell>
|
|
||||||
<TableCell>{item.type || "-"}</TableCell>
|
|
||||||
<TableCell>{item.symbol || "-"}</TableCell>
|
|
||||||
<TableCell>{item.spec}</TableCell>
|
|
||||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
|
||||||
<TableCell className="text-center">{item.unit}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatAmount(item.unitPrice)}원
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right font-medium">
|
|
||||||
{formatAmount(item.amount ?? 0)}원
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 합계 */}
|
{/* 합계 */}
|
||||||
<div className="flex flex-col items-end gap-2 pt-4 mt-4 border-t">
|
<div className="flex flex-col items-end gap-2 pt-4 mt-4 border-t">
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<span className="text-muted-foreground">소계:</span>
|
<span className="text-muted-foreground">소계:</span>
|
||||||
<span className="w-32 text-right">
|
<span className="w-32 text-right">
|
||||||
{formatAmount(order.subtotal ?? 0)}원
|
{formatAmount(order.subtotal ?? 0)}원
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<span className="text-muted-foreground">할인율:</span>
|
||||||
|
<span className="w-32 text-right">{order.discountRate}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-lg font-semibold">
|
||||||
|
<span>총금액:</span>
|
||||||
|
<span className="w-32 text-right text-green-600">
|
||||||
|
{formatAmount(order.totalAmount ?? 0)}원
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm">
|
</CardContent>
|
||||||
<span className="text-muted-foreground">할인율:</span>
|
</Card>
|
||||||
<span className="w-32 text-right">{order.discountRate}%</span>
|
) : (
|
||||||
|
/* 레거시 플랫 테이블 (노드 없는 기존 수주) */
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">제품 내역</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[60px] text-center">순번</TableHead>
|
||||||
|
<TableHead>품목코드</TableHead>
|
||||||
|
<TableHead>품명</TableHead>
|
||||||
|
<TableHead>층</TableHead>
|
||||||
|
<TableHead>부호</TableHead>
|
||||||
|
<TableHead>규격</TableHead>
|
||||||
|
<TableHead className="text-center">수량</TableHead>
|
||||||
|
<TableHead className="text-center">단위</TableHead>
|
||||||
|
<TableHead className="text-right">단가</TableHead>
|
||||||
|
<TableHead className="text-right">금액</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(order.items || []).map((item, index) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="text-center">{index + 1}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||||
|
{item.itemCode}
|
||||||
|
</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.itemName}</TableCell>
|
||||||
|
<TableCell>{item.type || "-"}</TableCell>
|
||||||
|
<TableCell>{item.symbol || "-"}</TableCell>
|
||||||
|
<TableCell>{item.spec}</TableCell>
|
||||||
|
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||||
|
<TableCell className="text-center">{item.unit}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatAmount(item.unitPrice)}원
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
{formatAmount(item.amount ?? 0)}원
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-lg font-semibold">
|
|
||||||
<span>총금액:</span>
|
{/* 합계 */}
|
||||||
<span className="w-32 text-right text-green-600">
|
<div className="flex flex-col items-end gap-2 pt-4 mt-4 border-t">
|
||||||
{formatAmount(order.totalAmount ?? 0)}원
|
<div className="flex items-center gap-4 text-sm">
|
||||||
</span>
|
<span className="text-muted-foreground">소계:</span>
|
||||||
|
<span className="w-32 text-right">
|
||||||
|
{formatAmount(order.subtotal ?? 0)}원
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<span className="text-muted-foreground">할인율:</span>
|
||||||
|
<span className="w-32 text-right">{order.discountRate}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-lg font-semibold">
|
||||||
|
<span>총금액:</span>
|
||||||
|
<span className="w-32 text-right text-green-600">
|
||||||
|
{formatAmount(order.totalAmount ?? 0)}원
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [order, openDocumentModal]);
|
}, [order, openDocumentModal]);
|
||||||
|
|||||||
@@ -45,12 +45,14 @@ interface ApiOrder {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
client?: ApiClient | null;
|
client?: ApiClient | null;
|
||||||
items?: ApiOrderItem[];
|
items?: ApiOrderItem[];
|
||||||
|
root_nodes?: ApiOrderNode[];
|
||||||
quote?: ApiQuote | null;
|
quote?: ApiQuote | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiOrderItem {
|
interface ApiOrderItem {
|
||||||
id: number;
|
id: number;
|
||||||
order_id: number;
|
order_id: number;
|
||||||
|
order_node_id: number | null;
|
||||||
item_id: number | null;
|
item_id: number | null;
|
||||||
item_code: string | null;
|
item_code: string | null;
|
||||||
item_name: string;
|
item_name: string;
|
||||||
@@ -67,6 +69,24 @@ interface ApiOrderItem {
|
|||||||
sort_order: number;
|
sort_order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ApiOrderNode {
|
||||||
|
id: number;
|
||||||
|
order_id: number;
|
||||||
|
parent_id: number | null;
|
||||||
|
node_type: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
status_code: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: number;
|
||||||
|
total_price: number;
|
||||||
|
options: Record<string, unknown> | null;
|
||||||
|
depth: number;
|
||||||
|
sort_order: number;
|
||||||
|
children?: ApiOrderNode[];
|
||||||
|
items?: ApiOrderItem[];
|
||||||
|
}
|
||||||
|
|
||||||
interface ApiClient {
|
interface ApiClient {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -248,6 +268,7 @@ export interface Order {
|
|||||||
remarks?: string;
|
remarks?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
items?: OrderItem[];
|
items?: OrderItem[];
|
||||||
|
nodes?: OrderNode[];
|
||||||
// 목록 페이지용 추가 필드
|
// 목록 페이지용 추가 필드
|
||||||
productName?: string; // 제품명 (첫 번째 품목명)
|
productName?: string; // 제품명 (첫 번째 품목명)
|
||||||
receiverAddress?: string; // 수신주소
|
receiverAddress?: string; // 수신주소
|
||||||
@@ -278,6 +299,23 @@ export interface Order {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OrderNode {
|
||||||
|
id: number;
|
||||||
|
parentId: number | null;
|
||||||
|
nodeType: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
statusCode: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
totalPrice: number;
|
||||||
|
options: Record<string, unknown> | null;
|
||||||
|
depth: number;
|
||||||
|
sortOrder: number;
|
||||||
|
children: OrderNode[];
|
||||||
|
items: OrderItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface OrderItem {
|
export interface OrderItem {
|
||||||
id: string;
|
id: string;
|
||||||
itemId?: number;
|
itemId?: number;
|
||||||
@@ -502,6 +540,7 @@ function transformApiToFrontend(apiData: ApiOrder): Order {
|
|||||||
remarks: apiData.remarks ?? undefined,
|
remarks: apiData.remarks ?? undefined,
|
||||||
note: apiData.note ?? undefined,
|
note: apiData.note ?? undefined,
|
||||||
items: apiData.items?.map(transformItemApiToFrontend) || [],
|
items: apiData.items?.map(transformItemApiToFrontend) || [],
|
||||||
|
nodes: apiData.root_nodes?.map(transformNodeApiToFrontend) || [],
|
||||||
// 목록 페이지용 추가 필드
|
// 목록 페이지용 추가 필드
|
||||||
productName: apiData.items?.[0]?.item_name ?? undefined,
|
productName: apiData.items?.[0]?.item_name ?? undefined,
|
||||||
receiverAddress: apiData.options?.shipping_address ?? undefined,
|
receiverAddress: apiData.options?.shipping_address ?? undefined,
|
||||||
@@ -534,6 +573,25 @@ function transformApiToFrontend(apiData: ApiOrder): Order {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function transformNodeApiToFrontend(apiNode: ApiOrderNode): OrderNode {
|
||||||
|
return {
|
||||||
|
id: apiNode.id,
|
||||||
|
parentId: apiNode.parent_id,
|
||||||
|
nodeType: apiNode.node_type,
|
||||||
|
code: apiNode.code,
|
||||||
|
name: apiNode.name,
|
||||||
|
statusCode: apiNode.status_code,
|
||||||
|
quantity: apiNode.quantity,
|
||||||
|
unitPrice: apiNode.unit_price,
|
||||||
|
totalPrice: apiNode.total_price,
|
||||||
|
options: apiNode.options,
|
||||||
|
depth: apiNode.depth,
|
||||||
|
sortOrder: apiNode.sort_order,
|
||||||
|
children: (apiNode.children || []).map(transformNodeApiToFrontend),
|
||||||
|
items: (apiNode.items || []).map(transformItemApiToFrontend),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function transformItemApiToFrontend(apiItem: ApiOrderItem): OrderItem {
|
function transformItemApiToFrontend(apiItem: ApiOrderItem): OrderItem {
|
||||||
return {
|
return {
|
||||||
id: String(apiItem.id),
|
id: String(apiItem.id),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export {
|
|||||||
type OrderFormData as OrderApiFormData,
|
type OrderFormData as OrderApiFormData,
|
||||||
type OrderItemFormData,
|
type OrderItemFormData,
|
||||||
type OrderStats,
|
type OrderStats,
|
||||||
|
type OrderNode,
|
||||||
type OrderStatus,
|
type OrderStatus,
|
||||||
type QuotationForSelect,
|
type QuotationForSelect,
|
||||||
type QuotationItem,
|
type QuotationItem,
|
||||||
|
|||||||
Reference in New Issue
Block a user