Files
sam-react-prod/src/components/orders/OrderSalesDetailEdit.tsx
권혁성 6bcd298995 feat: 수주/견적 기능 개선 및 PDF 생성 업데이트
- 수주 상세 뷰/수정 컴포넌트 개선
- 견적 위치 패널 업데이트
- PDF 생성 API 수정
- 레이아웃 및 공통코드 API 업데이트
- 패키지 의존성 업데이트
2026-01-29 01:12:58 +09:00

753 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* 수주 수정 컴포넌트 (Edit Mode)
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
*
* - 기본 정보 (읽기전용)
* - 수주/배송 정보 (편집 가능)
* - 비고 (편집 가능)
* - 품목 내역 (생산 시작 후 수정 불가)
*/
import { useState, useEffect, useMemo, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { PhoneInput } from "@/components/ui/phone-input";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { AlertTriangle, ChevronDown, ChevronRight, ChevronsUpDown, Package } 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 "@/utils/formatAmount";
import {
OrderItem,
getOrderById,
updateOrder,
type OrderStatus,
} from "@/components/orders";
import { getDeliveryMethodOptions, getCommonCodeOptions } from "@/lib/api/common-codes";
// 수정 폼 데이터
interface EditFormData {
// 읽기전용 정보
lotNumber: string;
orderDate: string; // 접수일
quoteNumber: string;
client: string;
siteName: string;
manager: string;
contact: string;
status: OrderStatus;
// 수정 가능 정보
expectedShipDate: string;
expectedShipDateUndecided: boolean;
deliveryRequestDate: string;
deliveryMethod: string;
shippingCost: string;
receiver: string;
receiverContact: string;
address: string;
addressDetail: string;
remarks: string;
// 품목 (수정 제한)
items: OrderItem[];
canEditItems: boolean;
subtotal: number;
discountRate: number;
totalAmount: number;
// 제품 정보 (아코디언용)
products: Array<{
productName: string;
productCategory?: string;
openWidth?: string;
openHeight?: string;
quantity: number;
floor?: string;
code?: string;
}>;
}
// 옵션 타입 정의
interface SelectOption {
value: string;
label: string;
}
// 상태 뱃지 헬퍼
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>
);
}
interface OrderSalesDetailEditProps {
orderId: string;
}
export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
const router = useRouter();
const [form, setForm] = useState<EditFormData | null>(null);
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set());
// 공통코드 옵션
const [deliveryMethods, setDeliveryMethods] = useState<SelectOption[]>([]);
const [shippingCosts, setShippingCosts] = useState<SelectOption[]>([]);
// 제품-부품 트리 토글
const toggleProduct = (key: string) => {
setExpandedProducts((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
// 모든 제품 확장
const expandAllProducts = () => {
if (form?.products) {
const allKeys = form.products.map((p) => `${p.floor || ""}-${p.code || ""}`);
allKeys.push("other-parts"); // 기타부품도 포함
setExpandedProducts(new Set(allKeys));
}
};
// 모든 제품 축소
const collapseAllProducts = () => {
setExpandedProducts(new Set());
};
// 제품별로 부품 그룹화 (floor_code, symbol_code 매칭)
const getItemsForProduct = (floor: string | undefined, code: string | undefined) => {
if (!form?.items) return [];
return form.items.filter((item) => {
const itemFloor = item.type || "";
const itemSymbol = item.symbol || "";
const productFloor = floor || "";
const productCode = code || "";
return itemFloor === productFloor && itemSymbol === productCode;
});
};
// 매칭되지 않은 부품 (orphan items)
const getUnmatchedItems = () => {
if (!form?.items || !form?.products) return form?.items || [];
const matchedIds = new Set<string>();
form.products.forEach((product) => {
const items = getItemsForProduct(product.floor, product.code);
items.forEach((item) => matchedIds.add(item.id));
});
return form.items.filter((item) => !matchedIds.has(item.id));
};
/**
* 수량 포맷 함수
* - EA, SET, PCS 등 개수 단위: 정수로 표시
* - M, M2, KG, L 등 측정 단위: 소수점 이하 불필요한 0 제거
*/
const formatQuantity = (quantity: number, unit?: string): string => {
const countableUnits = ["EA", "SET", "PCS", "개", "세트", "BOX", "ROLL"];
const upperUnit = (unit || "").toUpperCase();
if (countableUnits.includes(upperUnit)) {
return Math.round(quantity).toLocaleString();
}
const rounded = Math.round(quantity * 10000) / 10000;
return rounded.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 4
});
};
// 데이터 로드 (API)
useEffect(() => {
async function loadOrder() {
try {
setLoading(true);
const result = await getOrderById(orderId);
if (result.success && result.data) {
const order = result.data;
// 상태에 따라 품목 수정 가능 여부 결정
const canEditItems = !["in_production", "rework", "work_completed", "shipped"].includes(
order.status
);
// Order 데이터를 EditFormData로 변환
setForm({
lotNumber: order.lotNumber,
orderDate: order.orderDate || "",
quoteNumber: order.quoteNumber || "",
client: order.client,
siteName: order.siteName,
manager: order.manager || "",
contact: order.contact || "",
status: order.status,
expectedShipDate: order.expectedShipDate || "",
expectedShipDateUndecided: !order.expectedShipDate,
deliveryRequestDate: order.deliveryRequestDate || "",
deliveryMethod: order.deliveryMethod || "",
shippingCost: order.shippingCost || "",
receiver: order.receiver || "",
receiverContact: order.receiverContact || "",
address: order.address || "",
addressDetail: order.addressDetail || "",
remarks: order.remarks || "",
items: order.items || [],
canEditItems,
subtotal: order.subtotal || order.amount,
discountRate: order.discountRate || 0,
totalAmount: order.amount,
products: order.products || [],
});
} else {
toast.error(result.error || "수주 정보를 불러오는데 실패했습니다.");
router.push("/sales/order-management-sales");
}
} catch (error) {
console.error("Error loading order:", error);
toast.error("수주 정보를 불러오는 중 오류가 발생했습니다.");
router.push("/sales/order-management-sales");
} finally {
setLoading(false);
}
}
loadOrder();
}, [orderId, router]);
// 공통코드 옵션 로드
useEffect(() => {
async function loadCommonCodes() {
const [deliveryResult, shippingResult] = await Promise.all([
getDeliveryMethodOptions(),
getCommonCodeOptions('shipping_cost'),
]);
if (deliveryResult.success && deliveryResult.data) {
setDeliveryMethods(deliveryResult.data);
}
if (shippingResult.success && shippingResult.data) {
setShippingCosts(shippingResult.data);
}
}
loadCommonCodes();
}, []);
const handleCancel = () => {
// V2 패턴: ?mode=view로 이동
router.push(`/sales/order-management-sales/${orderId}?mode=view`);
};
// IntegratedDetailTemplate용 onSubmit 핸들러
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!form) return { success: false, error: "폼 데이터가 없습니다." };
// 유효성 검사
if (!form.deliveryRequestDate) {
return { success: false, error: "납품요청일을 입력해주세요." };
}
if (!form.receiver.trim()) {
return { success: false, error: "수신자를 입력해주세요." };
}
if (!form.receiverContact.trim()) {
return { success: false, error: "수신처를 입력해주세요." };
}
try {
// API 연동
const result = await updateOrder(orderId, {
clientId: undefined, // 기존 값 유지
siteName: form.siteName,
expectedShipDate: form.expectedShipDateUndecided ? undefined : form.expectedShipDate,
deliveryRequestDate: form.deliveryRequestDate,
deliveryMethod: form.deliveryMethod,
shippingCost: form.shippingCost,
receiver: form.receiver,
receiverContact: form.receiverContact,
address: form.address,
addressDetail: form.addressDetail,
remarks: form.remarks,
items: form.items.map((item) => ({
itemId: item.id ? parseInt(item.id, 10) : undefined,
itemCode: item.itemCode,
itemName: item.itemName,
specification: item.spec,
quantity: item.quantity,
unit: item.unit,
unitPrice: item.unitPrice,
})),
});
if (result.success) {
toast.success("수주가 수정되었습니다.");
// V2 패턴: 저장 후 view 모드로 이동
router.push(`/sales/order-management-sales/${orderId}?mode=view`);
return { success: true };
} else {
return { success: false, error: result.error || "수주 수정에 실패했습니다." };
}
} catch (error) {
console.error("Error updating order:", error);
return { success: false, error: "수주 수정 중 오류가 발생했습니다." };
}
}, [form, orderId, router]);
// 동적 config (수정 모드용 타이틀)
const dynamicConfig = useMemo(() => {
return {
...orderSalesConfig,
title: "수주",
actions: {
...orderSalesConfig.actions,
showEdit: false, // 수정 모드에서는 수정 버튼 숨김
showDelete: false,
},
};
}, []);
// 커스텀 헤더 액션 (상태 뱃지)
const customHeaderActions = useMemo(() => {
if (!form) return null;
return (
<div className="flex items-center gap-2">
<code className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
{form.lotNumber}
</code>
{getOrderStatusBadge(form.status)}
</div>
);
}, [form]);
// 폼 콘텐츠 렌더링
const renderFormContent = useCallback(() => {
if (!form) return null;
return (
<div className="space-y-6">
{/* 기본 정보 (읽기전용) */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<span className="text-sm font-normal text-muted-foreground">()</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.lotNumber}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.orderDate || "-"}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.client}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.siteName}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.manager || "-"}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.contact || "-"}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<div className="mt-1">{getOrderStatusBadge(form.status)}</div>
</div>
</div>
</CardContent>
</Card>
{/* 수주/배송 정보 (편집 가능) */}
<Card>
<CardHeader>
<CardTitle className="text-lg">/ </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* 첫 번째 줄: 수주일, 납품요청일, 출고예정일, 배송방식 */}
<div className="space-y-2">
<Label className="text-muted-foreground"></Label>
<Input
value={form.orderDate || ""}
disabled
className="bg-muted"
/>
</div>
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<Input
type="date"
value={form.deliveryRequestDate}
onChange={(e) =>
setForm({ ...form, deliveryRequestDate: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="date"
value={form.expectedShipDate}
onChange={(e) =>
setForm({ ...form, expectedShipDate: e.target.value })
}
disabled={form.expectedShipDateUndecided}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select
key={`deliveryMethod-${form.deliveryMethod}`}
value={form.deliveryMethod}
onValueChange={(value) =>
setForm({ ...form, deliveryMethod: value })
}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{deliveryMethods.map((method) => (
<SelectItem key={method.value} value={method.value}>
{method.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 두 번째 줄: 운임비용, 수신자, 수신처 */}
<div className="space-y-2">
<Label></Label>
<Select
key={`shippingCost-${form.shippingCost}`}
value={form.shippingCost}
onValueChange={(value) =>
setForm({ ...form, shippingCost: value })
}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{shippingCosts.map((cost) => (
<SelectItem key={cost.value} value={cost.value}>
{cost.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<Input
value={form.receiver}
onChange={(e) =>
setForm({ ...form, receiver: e.target.value })
}
placeholder="수신자명 입력"
/>
</div>
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<Input
value={form.receiverContact}
onChange={(e) =>
setForm({ ...form, receiverContact: e.target.value })
}
placeholder="수신처 입력"
/>
</div>
{/* 주소 - 전체 너비 */}
<div className="space-y-2 md:col-span-4">
<Label></Label>
<div className="flex gap-2">
<Input
value={form.address}
onChange={(e) =>
setForm({ ...form, address: e.target.value })
}
placeholder="주소"
className="flex-1"
/>
</div>
</div>
</div>
</CardContent>
</Card>
{/* 비고 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={form.remarks}
onChange={(e) => setForm({ ...form, remarks: e.target.value })}
placeholder="특이사항을 입력하세요"
rows={4}
/>
</CardContent>
</Card>
{/* 제품내용 (아코디언) */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
{!form.canEditItems && (
<span className="flex items-center gap-1 text-sm font-normal text-orange-600">
<AlertTriangle className="h-4 w-4" />
</span>
)}
</CardTitle>
{form.products && form.products.length > 0 && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={expandAllProducts}
className="text-xs text-gray-500 hover:text-gray-700 flex items-center gap-1"
>
<ChevronsUpDown className="h-3 w-3" />
</button>
<button
type="button"
onClick={collapseAllProducts}
className="text-xs text-gray-500 hover:text-gray-700"
>
</button>
</div>
)}
</div>
</CardHeader>
<CardContent>
{form.products && form.products.length > 0 ? (
<div className="space-y-3">
{form.products.map((product, productIndex) => {
const productKey = `${product.floor || ""}-${product.code || ""}`;
const isExpanded = expandedProducts.has(productKey);
const productItems = getItemsForProduct(product.floor, product.code);
return (
<div
key={productIndex}
className="border border-gray-200 rounded-lg overflow-hidden"
>
{/* 제품 헤더 (클릭하면 확장/축소) */}
<button
type="button"
onClick={() => toggleProduct(productKey)}
className="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors text-left"
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-5 w-5 text-gray-500" />
) : (
<ChevronRight className="h-5 w-5 text-gray-500" />
)}
<Package className="h-5 w-5 text-blue-600" />
<div>
<span className="font-medium">{product.productName}</span>
{product.openWidth && product.openHeight && (
<span className="ml-2 text-sm text-gray-500">
({product.openWidth} × {product.openHeight})
</span>
)}
</div>
</div>
<span className="text-sm text-gray-500">
{productItems.length}
</span>
</button>
{/* 부품 목록 (확장 시 표시) */}
{isExpanded && (
<div className="border-t">
{productItems.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{productItems.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell>{item.spec || "-"}</TableCell>
<TableCell className="text-center">{formatQuantity(item.quantity, item.unit)}</TableCell>
<TableCell className="text-center">{item.unit || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="p-4 text-center text-gray-400 text-sm">
</div>
)}
</div>
)}
</div>
);
})}
</div>
) : null}
</CardContent>
</Card>
{/* 기타부품 (아코디언) */}
{(() => {
const unmatchedItems = getUnmatchedItems();
if (unmatchedItems.length === 0) return null;
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="border border-gray-200 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => toggleProduct("other-parts")}
className="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors text-left"
>
<div className="flex items-center gap-3">
{expandedProducts.has("other-parts") ? (
<ChevronDown className="h-5 w-5 text-gray-500" />
) : (
<ChevronRight className="h-5 w-5 text-gray-500" />
)}
<Package className="h-5 w-5 text-gray-400" />
<span className="font-medium text-gray-600"></span>
</div>
<span className="text-sm text-gray-500">
{unmatchedItems.length}
</span>
</button>
{expandedProducts.has("other-parts") && (
<div className="border-t">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{unmatchedItems.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell>{item.spec || "-"}</TableCell>
<TableCell className="text-center">{formatQuantity(item.quantity, item.unit)}</TableCell>
<TableCell className="text-center">{item.unit || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</CardContent>
</Card>
);
})()}
</div>
);
}, [form]);
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode="edit"
initialData={form || {}}
itemId={orderId}
isLoading={loading}
headerActions={customHeaderActions}
onSubmit={handleSubmit}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
);
}