- 미사용 코드 삭제: ThemeContext, itemStore, utils/date.ts, utils/formatAmount.ts - 유틸리티 이동: date, formatAmount → src/lib/utils/ (중앙 집중화) - 다수 page.tsx 클라이언트 컴포넌트 패턴 통일 - DateRangeSelector 리팩토링 및 date-range-picker UI 컴포넌트 추가 - ThemeSelect/themeStore Zustand 직접 연동으로 전환 - 건설/회계/영업/품목/출하 등 전반적 컴포넌트 개선 - UniversalListPage, IntegratedListTemplateV2 타입 확장 - 프론트엔드 종합 리뷰 문서 및 개선 체크리스트 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
824 lines
31 KiB
TypeScript
824 lines
31 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 수주 상세 보기 컴포넌트 (View Mode)
|
|
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
|
|
*
|
|
* - 문서 모달: 계약서, 거래명세서, 발주서
|
|
* - 기본 정보, 수주/배송 정보, 비고
|
|
* - 제품 내역 테이블
|
|
* - 상태별 버튼 차이
|
|
*/
|
|
|
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
FileText,
|
|
Factory,
|
|
XCircle,
|
|
FileSpreadsheet,
|
|
FileCheck,
|
|
ClipboardList,
|
|
CheckCircle2,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
MapPin,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate";
|
|
import { orderSalesConfig } from "./orderSalesConfig";
|
|
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
|
import { formatAmount } from "@/lib/utils/amount";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
OrderDocumentModal,
|
|
type OrderDocumentType,
|
|
getOrderById,
|
|
updateOrderStatus,
|
|
type Order,
|
|
type OrderNode,
|
|
type OrderStatus,
|
|
} from "@/components/orders";
|
|
import { ServerErrorPage } from "@/components/common/ServerErrorPage";
|
|
|
|
|
|
// 상태 뱃지 헬퍼
|
|
function getOrderStatusBadge(status: OrderStatus) {
|
|
const statusConfig: Record<OrderStatus, { label: string; className: string }> = {
|
|
order_registered: { label: "수주등록", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
|
order_confirmed: { label: "수주확정", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
|
production_ordered: { 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-100 text-blue-700 border-blue-200" },
|
|
rework: { label: "재작업중", className: "bg-orange-100 text-orange-700 border-orange-200" },
|
|
work_completed: { label: "작업완료", className: "bg-blue-600 text-white border-blue-600" },
|
|
shipping: { label: "출하중", className: "bg-purple-100 text-purple-700 border-purple-200" },
|
|
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" },
|
|
};
|
|
const config = statusConfig[status] || { label: status, className: "bg-gray-100 text-gray-700 border-gray-200" };
|
|
return (
|
|
<BadgeSm className={config.className}>
|
|
{config.label}
|
|
</BadgeSm>
|
|
);
|
|
}
|
|
|
|
// 정보 표시 컴포넌트
|
|
function InfoItem({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">{label}</p>
|
|
<p className="font-medium">{value || "-"}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 노드 상태 뱃지
|
|
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">
|
|
({String(options.product_name)})
|
|
</span>
|
|
) : null}
|
|
{(options.open_width || options.open_height) ? (
|
|
<span className="text-xs text-muted-foreground">
|
|
{String(options.open_width ?? '')}x{String(options.open_height ?? '')}mm
|
|
</span>
|
|
) : null}
|
|
</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;
|
|
}
|
|
|
|
export function OrderSalesDetailView({ orderId }: OrderSalesDetailViewProps) {
|
|
const router = useRouter();
|
|
|
|
const [order, setOrder] = useState<Order | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
|
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
|
const [isConfirming, setIsConfirming] = useState(false);
|
|
|
|
// 취소 폼 상태
|
|
const [cancelReason, setCancelReason] = useState("");
|
|
const [cancelDetail, setCancelDetail] = useState("");
|
|
|
|
// 문서 모달 상태
|
|
const [documentModalOpen, setDocumentModalOpen] = useState(false);
|
|
const [documentType, setDocumentType] = useState<OrderDocumentType>("contract");
|
|
|
|
// 데이터 로드 (API 호출)
|
|
useEffect(() => {
|
|
async function loadOrder() {
|
|
try {
|
|
setLoading(true);
|
|
const result = await getOrderById(orderId);
|
|
if (result.success && result.data) {
|
|
setOrder(result.data);
|
|
} else {
|
|
toast.error(result.error || "수주 정보를 불러오는데 실패했습니다.");
|
|
setOrder(null);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error loading order:", error);
|
|
toast.error("수주 정보를 불러오는 중 오류가 발생했습니다.");
|
|
setOrder(null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
loadOrder();
|
|
}, [orderId]);
|
|
|
|
const handleBack = () => {
|
|
router.push("/sales/order-management-sales");
|
|
};
|
|
|
|
const handleEdit = () => {
|
|
// V2 패턴: ?mode=edit로 이동
|
|
router.push(`/sales/order-management-sales/${orderId}?mode=edit`);
|
|
};
|
|
|
|
const handleProductionOrder = () => {
|
|
// 생산지시 생성 페이지로 이동
|
|
router.push(`/sales/order-management-sales/${orderId}/production-order`);
|
|
};
|
|
|
|
const handleViewProductionOrder = () => {
|
|
// 생산지시 목록 페이지로 이동 (수주관리 내부)
|
|
router.push(`/sales/order-management-sales/production-orders`);
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
setCancelReason("");
|
|
setCancelDetail("");
|
|
setIsCancelDialogOpen(true);
|
|
};
|
|
|
|
const handleConfirmCancel = async () => {
|
|
if (!cancelReason) {
|
|
toast.error("취소 사유를 선택해주세요.");
|
|
return;
|
|
}
|
|
if (order) {
|
|
setIsCancelling(true);
|
|
try {
|
|
const result = await updateOrderStatus(order.id, "cancelled");
|
|
if (result.success) {
|
|
setOrder({ ...order, status: "cancelled" });
|
|
toast.success("수주가 취소되었습니다.");
|
|
setIsCancelDialogOpen(false);
|
|
setCancelReason("");
|
|
setCancelDetail("");
|
|
} else {
|
|
toast.error(result.error || "수주 취소에 실패했습니다.");
|
|
}
|
|
} catch (error) {
|
|
console.error("Error cancelling order:", error);
|
|
toast.error("수주 취소 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setIsCancelling(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
// 수주 확정 처리
|
|
const handleConfirmOrder = () => {
|
|
setIsConfirmDialogOpen(true);
|
|
};
|
|
|
|
const handleConfirmOrderSubmit = async () => {
|
|
if (order) {
|
|
setIsConfirming(true);
|
|
try {
|
|
const result = await updateOrderStatus(order.id, "order_confirmed");
|
|
if (result.success && result.data) {
|
|
setOrder(result.data);
|
|
toast.success("수주가 확정되었습니다.");
|
|
setIsConfirmDialogOpen(false);
|
|
} else {
|
|
toast.error(result.error || "수주 확정에 실패했습니다.");
|
|
}
|
|
} catch (error) {
|
|
console.error("Error confirming order:", error);
|
|
toast.error("수주 확정 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setIsConfirming(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
// 문서 모달 열기
|
|
const openDocumentModal = useCallback((type: OrderDocumentType) => {
|
|
setDocumentType(type);
|
|
setDocumentModalOpen(true);
|
|
}, []);
|
|
|
|
// 동적 config (상태별 수정 버튼 표시)
|
|
const dynamicConfig = useMemo(() => {
|
|
const canEdit = order?.status !== "shipped" && order?.status !== "cancelled";
|
|
return {
|
|
...orderSalesConfig,
|
|
actions: {
|
|
...orderSalesConfig.actions,
|
|
showEdit: canEdit,
|
|
},
|
|
};
|
|
}, [order?.status]);
|
|
|
|
// 커스텀 헤더 액션 (상태별 버튼들)
|
|
const customHeaderActions = useMemo(() => {
|
|
if (!order) return null;
|
|
|
|
const showConfirmButton = order.status === "order_registered";
|
|
const showProductionCreateButton =
|
|
order.status !== "shipped" &&
|
|
order.status !== "cancelled" &&
|
|
order.status !== "production_ordered";
|
|
const showCancelButton =
|
|
order.status !== "shipped" &&
|
|
order.status !== "cancelled" &&
|
|
order.status !== "production_ordered";
|
|
|
|
return (
|
|
<>
|
|
{showConfirmButton && (
|
|
<Button onClick={handleConfirmOrder} className="bg-green-600 hover:bg-green-700" size="sm">
|
|
<CheckCircle2 className="h-4 w-4 md:mr-2" />
|
|
<span className="hidden md:inline">수주 확정</span>
|
|
</Button>
|
|
)}
|
|
{showProductionCreateButton && (
|
|
<Button onClick={handleProductionOrder} size="sm">
|
|
<Factory className="h-4 w-4 md:mr-2" />
|
|
<span className="hidden md:inline">생산지시 생성</span>
|
|
</Button>
|
|
)}
|
|
{showCancelButton && (
|
|
<Button variant="outline" onClick={handleCancel} className="border-orange-200 text-orange-600 hover:border-orange-300" size="sm">
|
|
<XCircle className="h-4 w-4 md:mr-2" />
|
|
<span className="hidden md:inline">취소</span>
|
|
</Button>
|
|
)}
|
|
</>
|
|
);
|
|
}, [order, handleConfirmOrder, handleProductionOrder, handleCancel]);
|
|
|
|
// 폼 콘텐츠 렌더링
|
|
const renderFormContent = useCallback(() => {
|
|
if (!order) return null;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* 수주 정보 헤더 */}
|
|
<Card>
|
|
<CardHeader className="pb-4">
|
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
<div className="flex items-center gap-3">
|
|
<code className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
|
|
{order.lotNumber}
|
|
</code>
|
|
{getOrderStatusBadge(order.status)}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
수주일: {order.orderDate}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
{/* 문서 버튼들 */}
|
|
<div className="flex items-center gap-2 pt-4 border-t">
|
|
<span className="text-sm text-muted-foreground mr-2">문서:</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => openDocumentModal("contract")}
|
|
>
|
|
<FileCheck className="h-4 w-4 mr-1" />
|
|
계약서
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => openDocumentModal("transaction")}
|
|
>
|
|
<FileSpreadsheet className="h-4 w-4 mr-1" />
|
|
거래명세서
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => openDocumentModal("purchaseOrder")}
|
|
>
|
|
<ClipboardList className="h-4 w-4 mr-1" />
|
|
발주서
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 기본 정보 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">기본 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
|
<InfoItem label="발주처" value={order.client} />
|
|
<InfoItem label="현장명" value={order.siteName} />
|
|
<InfoItem label="담당자" value={order.manager || ""} />
|
|
<InfoItem label="연락처" value={order.contact || ""} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 수주/배송 정보 */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">수주/배송 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
|
<InfoItem label="수주일자" value={order.orderDate} />
|
|
<InfoItem label="출고예정일" value={order.expectedShipDate || "미정"} />
|
|
<InfoItem label="납품요청일" value={order.deliveryRequestDate || ""} />
|
|
<InfoItem label="배송방식" value={order.deliveryMethodLabel || ""} />
|
|
<InfoItem label="운임비용" value={order.shippingCostLabel || ""} />
|
|
<InfoItem label="수신(반장/업체)" value={order.receiver || ""} />
|
|
<InfoItem label="수신처 연락처" value={order.receiverContact || ""} />
|
|
<InfoItem label="수신처 주소" value={order.address || ""} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 비고 */}
|
|
{order.remarks && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">비고</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="whitespace-pre-wrap">{order.remarks}</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* 제품 내역 */}
|
|
{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>
|
|
<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>
|
|
</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 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>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}, [order, openDocumentModal]);
|
|
|
|
// 에러 상태
|
|
if (!loading && !order) {
|
|
return (
|
|
<ServerErrorPage
|
|
title="수주 정보를 불러올 수 없습니다"
|
|
message="수주 정보를 찾을 수 없습니다."
|
|
showBackButton={true}
|
|
showHomeButton={true}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<IntegratedDetailTemplate
|
|
config={dynamicConfig}
|
|
mode="view"
|
|
initialData={order || {}}
|
|
itemId={orderId}
|
|
isLoading={loading}
|
|
headerActions={customHeaderActions}
|
|
renderView={() => renderFormContent()}
|
|
renderForm={() => renderFormContent()}
|
|
/>
|
|
|
|
{/* 문서 모달 */}
|
|
{order && (
|
|
<OrderDocumentModal
|
|
open={documentModalOpen}
|
|
onOpenChange={setDocumentModalOpen}
|
|
documentType={documentType}
|
|
data={{
|
|
lotNumber: order.lotNumber,
|
|
orderDate: order.orderDate,
|
|
client: order.client,
|
|
siteName: order.siteName,
|
|
manager: order.manager,
|
|
managerContact: order.contact,
|
|
deliveryRequestDate: order.deliveryRequestDate,
|
|
expectedShipDate: order.expectedShipDate,
|
|
deliveryMethod: order.deliveryMethodLabel,
|
|
address: order.address,
|
|
items: order.items,
|
|
subtotal: order.subtotal,
|
|
discountRate: order.discountRate,
|
|
totalAmount: order.totalAmount,
|
|
remarks: order.remarks,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 취소 확인 다이얼로그 */}
|
|
{order && (
|
|
<Dialog open={isCancelDialogOpen} onOpenChange={setIsCancelDialogOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<XCircle className="h-5 w-5" />
|
|
수주 취소
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
{/* 수주 정보 박스 */}
|
|
<div className="border rounded-lg p-4 space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">수주번호</span>
|
|
<span className="font-medium">{order.lotNumber}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">발주처</span>
|
|
<span>{order.client}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">현장명</span>
|
|
<span>{order.siteName}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-muted-foreground">현재 상태</span>
|
|
{getOrderStatusBadge(order.status)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 취소 사유 선택 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="cancelReason">
|
|
취소 사유 <span className="text-red-500">*</span>
|
|
</Label>
|
|
<Select value={cancelReason} onValueChange={setCancelReason}>
|
|
<SelectTrigger id="cancelReason">
|
|
<SelectValue placeholder="취소 사유를 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="customer_request">고객 요청</SelectItem>
|
|
<SelectItem value="spec_change">사양 변경</SelectItem>
|
|
<SelectItem value="price_issue">가격 문제</SelectItem>
|
|
<SelectItem value="delivery_issue">납기 문제</SelectItem>
|
|
<SelectItem value="duplicate_order">중복 수주</SelectItem>
|
|
<SelectItem value="other">기타</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 상세 사유 입력 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="cancelDetail">상세 사유</Label>
|
|
<Textarea
|
|
id="cancelDetail"
|
|
placeholder="취소 사유에 대한 상세 내용을 입력하세요"
|
|
value={cancelDetail}
|
|
onChange={(e) => setCancelDetail(e.target.value)}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
{/* 취소 시 유의사항 */}
|
|
<div className="bg-gray-50 border rounded-lg p-4 text-sm space-y-1">
|
|
<p className="font-medium mb-2">취소 시 유의사항</p>
|
|
<ul className="space-y-1 text-muted-foreground">
|
|
<li>• 취소된 수주는 목록에서 '취소' 상태로 표시됩니다</li>
|
|
<li>• 취소 후에는 수정이 불가능합니다</li>
|
|
<li>• 관련된 생산지시가 있는 경우 먼저 생산지시를 취소해야 합니다</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsCancelDialogOpen(false)}
|
|
>
|
|
닫기
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleConfirmCancel}
|
|
className="border-gray-300"
|
|
disabled={isCancelling}
|
|
>
|
|
<XCircle className="h-4 w-4 mr-1" />
|
|
{isCancelling ? "취소 중..." : "취소 확정"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
|
|
{/* 수주 확정 다이얼로그 */}
|
|
{order && (
|
|
<Dialog open={isConfirmDialogOpen} onOpenChange={setIsConfirmDialogOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
|
수주 확정
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
{/* 수주 정보 박스 */}
|
|
<div className="border rounded-lg p-4 space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">수주번호</span>
|
|
<span className="font-medium">{order.lotNumber}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">발주처</span>
|
|
<span>{order.client}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">현장명</span>
|
|
<span>{order.siteName}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">총금액</span>
|
|
<span className="font-medium text-green-600">
|
|
{formatAmount(order.totalAmount ?? 0)}원
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-muted-foreground">현재 상태</span>
|
|
{getOrderStatusBadge(order.status)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 확정 안내 */}
|
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-sm space-y-1">
|
|
<p className="font-medium mb-2 text-green-700">확정 후 변경사항</p>
|
|
<ul className="space-y-1 text-green-600">
|
|
<li>• 수주 상태가 '수주확정'으로 변경됩니다</li>
|
|
<li>• 생산지시를 생성할 수 있습니다</li>
|
|
<li>• 확정 후에도 수정이 가능합니다</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsConfirmDialogOpen(false)}
|
|
>
|
|
닫기
|
|
</Button>
|
|
<Button
|
|
onClick={handleConfirmOrderSubmit}
|
|
className="bg-green-600 hover:bg-green-700"
|
|
disabled={isConfirming}
|
|
>
|
|
<CheckCircle2 className="h-4 w-4 mr-1" />
|
|
{isConfirming ? "확정 중..." : "확정"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</>
|
|
);
|
|
}
|