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:
2026-02-06 20:22:31 +09:00
parent 94ee2e9ad6
commit 473cfa0052
3 changed files with 279 additions and 65 deletions

View File

@@ -30,6 +30,9 @@ import {
FileCheck,
ClipboardList,
CheckCircle2,
ChevronDown,
ChevronRight,
MapPin,
} from "lucide-react";
import { toast } from "sonner";
import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate";
@@ -58,6 +61,7 @@ import {
getOrderById,
updateOrderStatus,
type Order,
type OrderNode,
type OrderStatus,
} from "@/components/orders";
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 {
orderId: string;
}
@@ -375,75 +492,113 @@ export function OrderSalesDetailView({ orderId }: OrderSalesDetailViewProps) {
)}
{/* 제품 내역 */}
<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>
{order.nodes && order.nodes.length > 0 ? (
/* 노드별 그룹 표시 */
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<MapPin className="h-5 w-5" />
({order.nodes.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{order.nodes.map((node) => (
<OrderNodeCard key={node.id} node={node} />
))}
{/* 합계 */}
<div className="flex flex-col items-end gap-2 pt-4 mt-4 border-t">
<div className="flex items-center gap-4 text-sm">
<span className="text-muted-foreground">:</span>
<span className="w-32 text-right">
{formatAmount(order.subtotal ?? 0)}
</span>
{/* 합계 */}
<div className="flex flex-col items-end gap-2 pt-4 mt-4 border-t">
<div className="flex items-center gap-4 text-sm">
<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 className="flex items-center gap-4 text-sm">
<span className="text-muted-foreground">:</span>
<span className="w-32 text-right">{order.discountRate}%</span>
</CardContent>
</Card>
) : (
/* 레거시 플랫 테이블 (노드 없는 기존 수주) */
<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 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 className="flex flex-col items-end gap-2 pt-4 mt-4 border-t">
<div className="flex items-center gap-4 text-sm">
<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>
</CardContent>
</Card>
</CardContent>
</Card>
)}
</div>
);
}, [order, openDocumentModal]);

View File

@@ -45,12 +45,14 @@ interface ApiOrder {
updated_at: string;
client?: ApiClient | null;
items?: ApiOrderItem[];
root_nodes?: ApiOrderNode[];
quote?: ApiQuote | null;
}
interface ApiOrderItem {
id: number;
order_id: number;
order_node_id: number | null;
item_id: number | null;
item_code: string | null;
item_name: string;
@@ -67,6 +69,24 @@ interface ApiOrderItem {
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 {
id: number;
name: string;
@@ -248,6 +268,7 @@ export interface Order {
remarks?: string;
note?: string;
items?: OrderItem[];
nodes?: OrderNode[];
// 목록 페이지용 추가 필드
productName?: 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 {
id: string;
itemId?: number;
@@ -502,6 +540,7 @@ function transformApiToFrontend(apiData: ApiOrder): Order {
remarks: apiData.remarks ?? undefined,
note: apiData.note ?? undefined,
items: apiData.items?.map(transformItemApiToFrontend) || [],
nodes: apiData.root_nodes?.map(transformNodeApiToFrontend) || [],
// 목록 페이지용 추가 필드
productName: apiData.items?.[0]?.item_name ?? 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 {
return {
id: String(apiItem.id),

View File

@@ -21,6 +21,7 @@ export {
type OrderFormData as OrderApiFormData,
type OrderItemFormData,
type OrderStats,
type OrderNode,
type OrderStatus,
type QuotationForSelect,
type QuotationItem,