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]);
|
||||
|
||||
Reference in New Issue
Block a user