feat: 수주/견적 기능 개선 및 PDF 생성 업데이트
- 수주 상세 뷰/수정 컴포넌트 개선 - 견적 위치 패널 업데이트 - PDF 생성 API 수정 - 레이아웃 및 공통코드 API 업데이트 - 패키지 의존성 업데이트
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user