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,
|
||||
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]);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
type OrderFormData as OrderApiFormData,
|
||||
type OrderItemFormData,
|
||||
type OrderStats,
|
||||
type OrderNode,
|
||||
type OrderStatus,
|
||||
type QuotationForSelect,
|
||||
type QuotationItem,
|
||||
|
||||
Reference in New Issue
Block a user