feat: 수주/견적 기능 개선 및 PDF 생성 업데이트

- 수주 상세 뷰/수정 컴포넌트 개선
- 견적 위치 패널 업데이트
- PDF 생성 API 수정
- 레이아웃 및 공통코드 API 업데이트
- 패키지 의존성 업데이트
This commit is contained in:
2026-01-29 01:12:58 +09:00
parent d2a39de576
commit 6bcd298995
12 changed files with 272 additions and 81 deletions

View File

@@ -45,6 +45,7 @@ import {
updateOrder,
type OrderStatus,
} from "@/components/orders";
import { getDeliveryMethodOptions, getCommonCodeOptions } from "@/lib/api/common-codes";
// 수정 폼 데이터
interface EditFormData {
@@ -88,22 +89,11 @@ interface EditFormData {
}>;
}
// 배송방식 옵션
const DELIVERY_METHODS = [
{ value: "direct", label: "직접배차" },
{ value: "pickup", label: "상차" },
{ value: "courier", label: "택배" },
{ value: "self", label: "직접수령" },
{ value: "freight", label: "화물" },
];
// 운임비용 옵션
const SHIPPING_COSTS = [
{ value: "free", label: "무료" },
{ value: "prepaid", label: "선불" },
{ value: "collect", label: "착불" },
{ value: "negotiable", label: "협의" },
];
// 옵션 타입 정의
interface SelectOption {
value: string;
label: string;
}
// 상태 뱃지 헬퍼
@@ -141,6 +131,10 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
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) => {
@@ -265,6 +259,24 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
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`);
@@ -453,7 +465,7 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{DELIVERY_METHODS.map((method) => (
{deliveryMethods.map((method) => (
<SelectItem key={method.value} value={method.value}>
{method.label}
</SelectItem>
@@ -476,7 +488,7 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{SHIPPING_COSTS.map((cost) => (
{shippingCosts.map((cost) => (
<SelectItem key={cost.value} value={cost.value}>
{cost.label}
</SelectItem>

View File

@@ -354,7 +354,7 @@ export function OrderSalesDetailView({ orderId }: OrderSalesDetailViewProps) {
<InfoItem label="출고예정일" value={order.expectedShipDate || "미정"} />
<InfoItem label="납품요청일" value={order.deliveryRequestDate} />
<InfoItem label="배송방식" value={order.deliveryMethodLabel} />
<InfoItem label="운임비용" value={order.shippingCost} />
<InfoItem label="운임비용" value={order.shippingCostLabel} />
<InfoItem label="수신(반장/업체)" value={order.receiver} />
<InfoItem label="수신처 연락처" value={order.receiverContact} />
<InfoItem label="수신처 주소" value={order.address} />

View File

@@ -27,6 +27,7 @@ interface ApiOrder {
delivery_date: string | null;
delivery_method_code: string | null;
delivery_method_label?: string; // API에서 조회한 배송방식 라벨
shipping_cost_label?: string; // API에서 조회한 운임비용 라벨
received_at: string | null;
memo: string | null;
remarks: string | null;
@@ -231,11 +232,17 @@ export interface Order {
remarks?: string;
note?: string;
items?: OrderItem[];
// 목록 페이지용 추가 필드
productName?: string; // 제품명 (첫 번째 품목명)
receiverAddress?: string; // 수신주소
receiverPlace?: string; // 수신처 (전화번호)
frameCount?: number; // 틀수 (수량)
// 상세 페이지용 추가 필드
manager?: string; // 담당자
contact?: string; // 연락처 (client_contact)
deliveryRequestDate?: string; // 납품요청일
shippingCost?: string; // 운임비용
shippingCost?: string; // 운임비용 (코드)
shippingCostLabel?: string; // 운임비용 (라벨)
receiver?: string; // 수신자
receiverContact?: string; // 수신처 연락처
address?: string; // 수신처 주소
@@ -457,12 +464,19 @@ function transformApiToFrontend(apiData: ApiOrder): Order {
memo: apiData.memo ?? undefined,
remarks: apiData.remarks ?? undefined,
note: apiData.note ?? undefined,
items: apiData.items?.map(transformItemApiToFrontend) || [], // 상세 페이지용 추가 필드 (API에서 매핑)
items: apiData.items?.map(transformItemApiToFrontend) || [],
// 목록 페이지용 추가 필드
productName: apiData.items?.[0]?.item_name ?? undefined,
receiverAddress: apiData.options?.shipping_address ?? undefined,
receiverPlace: apiData.options?.receiver_contact ?? undefined,
frameCount: apiData.quantity ?? undefined,
// 상세 페이지용 추가 필드 (API에서 매핑)
manager: apiData.client?.manager_name ?? undefined,
contact: apiData.client_contact ?? apiData.client?.phone ?? undefined,
deliveryRequestDate: apiData.delivery_date ?? undefined, // delivery_date를 공유
// options JSON에서 추출
shippingCost: apiData.options?.shipping_cost_code ?? undefined,
shippingCostLabel: apiData.shipping_cost_label ?? undefined,
receiver: apiData.options?.receiver ?? undefined,
receiverContact: apiData.options?.receiver_contact ?? undefined,
address: apiData.options?.shipping_address ?? undefined,

View File

@@ -402,7 +402,7 @@ export function LocationDetailPanel({
<SelectContent>
{finishedGoods.map((fg) => (
<SelectItem key={fg.item_code} value={fg.item_code}>
{fg.item_code}
{fg.item_code} {fg.item_name}
</SelectItem>
))}
</SelectContent>
@@ -477,8 +477,8 @@ export function LocationDetailPanel({
</div>
</div>
{/* 3행: 제작사이즈, 산출중량, 산출면적, 수량 */}
<div className="grid grid-cols-4 gap-3 text-sm pt-2 border-t border-gray-200">
{/* 3행: 제작사이즈, 산출중량, 산출면적, 수량, 산출하기 */}
<div className="grid grid-cols-5 gap-3 text-sm pt-2 border-t border-gray-200">
<div>
<span className="text-xs text-gray-500"></span>
<p className="font-semibold">
@@ -503,6 +503,24 @@ export function LocationDetailPanel({
: "-"}
</p>
</div>
<div>
<span className="text-xs text-gray-500"> (QTY)</span>
<QuantityInput
value={location.quantity}
onChange={(newQty) => {
if (!location || disabled) return;
// 수량 변경 시 totalPrice 재계산
const unitPrice = location.unitPrice || 0;
onUpdateLocation(location.id, {
quantity: newQty,
totalPrice: unitPrice * newQty,
});
}}
className="h-8 text-sm font-semibold"
min={1}
disabled={disabled}
/>
</div>
<div className="flex items-end">
<Button
onClick={() => onCalculateLocation?.(location.id)}
@@ -600,7 +618,41 @@ export function LocationDetailPanel({
<TableCell className="text-center">
<QuantityInput
value={item.quantity}
onChange={() => {}}
onChange={(newQty) => {
if (!location || disabled) return;
const existingBomResult = location.bomResult;
if (!existingBomResult) return;
// 해당 아이템 찾아서 수량 및 금액 업데이트
const updatedItems = (existingBomResult.items || []).map((bomItem: any, i: number) => {
if (bomItemsByTab[tab.value]?.[index] === bomItem) {
const newTotalPrice = (bomItem.unit_price || 0) * newQty;
return {
...bomItem,
quantity: newQty,
total_price: newTotalPrice,
};
}
return bomItem;
});
// grand_total 재계산
const newGrandTotal = updatedItems.reduce(
(sum: number, item: any) => sum + (item.total_price || 0),
0
);
// location 업데이트 (unitPrice, totalPrice 포함)
onUpdateLocation(location.id, {
unitPrice: newGrandTotal,
totalPrice: newGrandTotal * location.quantity,
bomResult: {
...existingBomResult,
items: updatedItems,
grand_total: newGrandTotal,
},
});
}}
className="w-14 h-7 text-center text-xs"
min={1}
disabled={disabled}

View File

@@ -324,7 +324,7 @@ export function LocationListPanel({
<SelectContent>
{finishedGoods.map((fg) => (
<SelectItem key={fg.item_code} value={fg.item_code}>
{fg.item_code}
{fg.item_code} {fg.item_name}
</SelectItem>
))}
</SelectContent>