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]);