ERP-node/frontend/app/(main)/sales/shipping-order/page.tsx

827 lines
42 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import {
getShippingOrderList,
saveShippingOrder,
deleteShippingOrders,
previewShippingOrderNo,
getShipmentPlanSource,
getSalesOrderSource,
getItemSource,
} from "@/lib/api/shipping";
type DataSourceType = "shipmentPlan" | "salesOrder" | "itemInfo";
const STATUS_OPTIONS = [
{ value: "all", label: "전체" },
{ value: "READY", label: "준비중" },
{ value: "IN_PROGRESS", label: "진행중" },
{ value: "COMPLETED", label: "완료" },
];
const getStatusLabel = (s: string) => STATUS_OPTIONS.find(o => o.value === s)?.label || s;
const getStatusColor = (s: string) => {
switch (s) {
case "READY": return "bg-amber-100 text-amber-800 border-amber-200";
case "IN_PROGRESS": return "bg-blue-100 text-blue-800 border-blue-200";
case "COMPLETED": return "bg-emerald-100 text-emerald-800 border-emerald-200";
default: return "bg-gray-100 text-gray-800 border-gray-200";
}
};
const getSourceBadge = (s: string) => {
switch (s) {
case "shipmentPlan": return { label: "출하계획", cls: "bg-blue-100 text-blue-700" };
case "salesOrder": return { label: "수주", cls: "bg-emerald-100 text-emerald-700" };
case "itemInfo": return { label: "품목", cls: "bg-purple-100 text-purple-700" };
default: return { label: s, cls: "bg-gray-100 text-gray-700" };
}
};
interface SelectedItem {
id: string | number;
itemCode: string;
itemName: string;
spec: string;
material: string;
customer: string;
planQty: number;
orderQty: number;
sourceType: DataSourceType;
shipmentPlanId?: number;
salesOrderId?: number;
detailId?: string;
partnerCode?: string;
}
export default function ShippingOrderPage() {
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [checkedIds, setCheckedIds] = useState<number[]>([]);
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
const [searchCustomer, setSearchCustomer] = useState("");
const [debouncedKeyword, setDebouncedKeyword] = useState("");
const [debouncedCustomer, setDebouncedCustomer] = useState("");
const [searchStatus, setSearchStatus] = useState("all");
const [searchDateFrom, setSearchDateFrom] = useState("");
const [searchDateTo, setSearchDateTo] = useState("");
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
// 모달 폼
const [formOrderNumber, setFormOrderNumber] = useState("");
const [formOrderDate, setFormOrderDate] = useState("");
const [formCustomer, setFormCustomer] = useState("");
const [formPartnerId, setFormPartnerId] = useState("");
const [formStatus, setFormStatus] = useState("READY");
const [formCarrier, setFormCarrier] = useState("");
const [formVehicle, setFormVehicle] = useState("");
const [formDriver, setFormDriver] = useState("");
const [formDriverPhone, setFormDriverPhone] = useState("");
const [formArrival, setFormArrival] = useState("");
const [formAddress, setFormAddress] = useState("");
const [formMemo, setFormMemo] = useState("");
const [isTransportCollapsed, setIsTransportCollapsed] = useState(false);
// 모달 왼쪽 패널
const [dataSource, setDataSource] = useState<DataSourceType>("shipmentPlan");
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceData, setSourceData] = useState<any[]>([]);
const [sourceLoading, setSourceLoading] = useState(false);
const [selectedItems, setSelectedItems] = useState<SelectedItem[]>([]);
const [sourcePage, setSourcePage] = useState(1);
const [sourcePageSize] = useState(20);
const [sourceTotalCount, setSourceTotalCount] = useState(0);
// 텍스트 입력 debounce (500ms)
useEffect(() => {
const t = setTimeout(() => setDebouncedKeyword(searchKeyword), 500);
return () => clearTimeout(t);
}, [searchKeyword]);
useEffect(() => {
const t = setTimeout(() => setDebouncedCustomer(searchCustomer), 500);
return () => clearTimeout(t);
}, [searchCustomer]);
// 초기 날짜
useEffect(() => {
const today = new Date();
const from = new Date(today);
from.setMonth(from.getMonth() - 1);
setSearchDateFrom(from.toISOString().split("T")[0]);
setSearchDateTo(today.toISOString().split("T")[0]);
}, []);
// 데이터 조회
const fetchOrders = useCallback(async () => {
setLoading(true);
try {
const params: any = {};
if (searchDateFrom) params.dateFrom = searchDateFrom;
if (searchDateTo) params.dateTo = searchDateTo;
if (searchStatus !== "all") params.status = searchStatus;
if (debouncedCustomer.trim()) params.customer = debouncedCustomer.trim();
if (debouncedKeyword.trim()) params.keyword = debouncedKeyword.trim();
const result = await getShippingOrderList(params);
if (result.success) setOrders(result.data || []);
} catch (err) {
console.error("출하지시 조회 실패:", err);
} finally {
setLoading(false);
}
}, [searchDateFrom, searchDateTo, searchStatus, debouncedCustomer, debouncedKeyword]);
useEffect(() => {
if (searchDateFrom && searchDateTo) fetchOrders();
}, [fetchOrders]);
// 소스 데이터 조회
const fetchSourceData = useCallback(async (pageOverride?: number) => {
setSourceLoading(true);
try {
const currentPage = pageOverride ?? sourcePage;
const params: any = { page: currentPage, pageSize: sourcePageSize };
if (sourceKeyword.trim()) params.keyword = sourceKeyword.trim();
let result;
switch (dataSource) {
case "shipmentPlan":
result = await getShipmentPlanSource(params);
break;
case "salesOrder":
result = await getSalesOrderSource(params);
break;
case "itemInfo":
result = await getItemSource(params);
break;
}
if (result?.success) {
setSourceData(result.data || []);
setSourceTotalCount(result.totalCount || 0);
}
} catch (err) {
console.error("소스 데이터 조회 실패:", err);
} finally {
setSourceLoading(false);
}
}, [dataSource, sourceKeyword, sourcePage, sourcePageSize]);
useEffect(() => {
if (isModalOpen) {
setSourcePage(1);
fetchSourceData(1);
}
}, [isModalOpen, dataSource]);
// 핸들러
const handleResetSearch = () => {
setSearchKeyword("");
setSearchCustomer("");
setDebouncedKeyword("");
setDebouncedCustomer("");
setSearchStatus("all");
const today = new Date();
const from = new Date(today);
from.setMonth(from.getMonth() - 1);
setSearchDateFrom(from.toISOString().split("T")[0]);
setSearchDateTo(today.toISOString().split("T")[0]);
};
const handleCheckAll = (checked: boolean) => {
setCheckedIds(checked ? orders.map((o: any) => o.id) : []);
};
const handleDeleteSelected = async () => {
if (checkedIds.length === 0) return;
if (!confirm(`선택한 ${checkedIds.length}개의 출하지시를 삭제하시겠습니까?`)) return;
try {
const result = await deleteShippingOrders(checkedIds);
if (result.success) {
setCheckedIds([]);
fetchOrders();
alert("삭제되었습니다.");
}
} catch (err: any) {
alert(err.message || "삭제 실패");
}
};
// 모달 열기
const openModal = (order?: any) => {
if (order) {
setIsEditMode(true);
setEditId(order.id);
setFormOrderNumber(order.instruction_no || "");
setFormOrderDate(order.instruction_date ? order.instruction_date.split("T")[0] : "");
setFormCustomer(order.customer_name || "");
setFormPartnerId(order.partner_id || "");
setFormStatus(order.status || "READY");
setFormCarrier(order.carrier_name || "");
setFormVehicle(order.vehicle_no || "");
setFormDriver(order.driver_name || "");
setFormDriverPhone(order.driver_contact || "");
setFormArrival(order.arrival_time ? order.arrival_time.slice(0, 16) : "");
setFormAddress(order.delivery_address || "");
setFormMemo(order.memo || "");
const items = order.items || [];
setSelectedItems(items.filter((it: any) => it.id).map((it: any) => {
const srcType = it.source_type || "shipmentPlan";
// 소스 데이터와 매칭할 수 있도록 원래 소스 id를 사용
let sourceId: string | number = it.id;
if (srcType === "shipmentPlan" && it.shipment_plan_id) sourceId = it.shipment_plan_id;
else if (srcType === "salesOrder" && it.detail_id) sourceId = it.detail_id;
else if (srcType === "itemInfo") sourceId = it.item_code || "";
return {
id: sourceId,
itemCode: it.item_code || "",
itemName: it.item_name || "",
spec: it.spec || "",
material: it.material || "",
customer: order.customer_name || "",
planQty: Number(it.plan_qty || 0),
orderQty: Number(it.order_qty || 0),
sourceType: srcType,
shipmentPlanId: it.shipment_plan_id,
salesOrderId: it.sales_order_id,
detailId: it.detail_id,
partnerCode: order.partner_id,
};
}));
} else {
setIsEditMode(false);
setEditId(null);
setFormOrderNumber("불러오는 중...");
setFormOrderDate(new Date().toISOString().split("T")[0]);
previewShippingOrderNo().then(r => {
if (r.success) setFormOrderNumber(r.instructionNo);
else setFormOrderNumber("(자동생성)");
}).catch(() => setFormOrderNumber("(자동생성)"));
setFormCustomer("");
setFormPartnerId("");
setFormStatus("READY");
setFormCarrier("");
setFormVehicle("");
setFormDriver("");
setFormDriverPhone("");
setFormArrival("");
setFormAddress("");
setFormMemo("");
setSelectedItems([]);
}
setDataSource("shipmentPlan");
setSourceKeyword("");
setSourceData([]);
setIsTransportCollapsed(false);
setIsModalOpen(true);
};
// 소스 아이템 선택 토글
const toggleSourceItem = (item: any) => {
const key = dataSource === "shipmentPlan" ? item.id
: dataSource === "salesOrder" ? item.id
: item.item_code;
const exists = selectedItems.findIndex(s => {
// 같은 소스 타입에서 id 매칭
if (s.sourceType === dataSource) {
if (dataSource === "itemInfo") return s.itemCode === key;
return String(s.id) === String(key);
}
// 다른 소스 타입이라도 원래 소스 id로 매칭
if (dataSource === "shipmentPlan" && s.shipmentPlanId) return String(s.shipmentPlanId) === String(item.id);
if (dataSource === "salesOrder" && s.detailId) return String(s.detailId) === String(item.id);
return false;
});
if (exists > -1) {
setSelectedItems(prev => prev.filter((_, i) => i !== exists));
} else {
const newItem: SelectedItem = {
id: key,
itemCode: item.item_code || "",
itemName: item.item_name || "",
spec: item.spec || "",
material: item.material || "",
customer: item.customer_name || "",
planQty: Number(item.plan_qty || item.qty || item.balance_qty || 0),
orderQty: Number(item.plan_qty || item.balance_qty || item.qty || 1),
sourceType: dataSource,
shipmentPlanId: dataSource === "shipmentPlan" ? item.id : undefined,
salesOrderId: dataSource === "salesOrder" ? (item.master_id || undefined) : undefined,
detailId: dataSource === "salesOrder" ? item.id : (dataSource === "shipmentPlan" ? item.detail_id : undefined),
partnerCode: item.partner_code || "",
};
setSelectedItems(prev => [...prev, newItem]);
if (!formCustomer && item.customer_name) {
setFormCustomer(item.customer_name);
setFormPartnerId(item.partner_code || "");
}
}
};
const removeSelectedItem = (idx: number) => {
setSelectedItems(prev => prev.filter((_, i) => i !== idx));
};
const updateOrderQty = (idx: number, val: number) => {
setSelectedItems(prev => prev.map((item, i) => i === idx ? { ...item, orderQty: val } : item));
};
// 저장
const handleSave = async () => {
if (!formOrderDate) { alert("출하지시일을 입력해주세요."); return; }
if (selectedItems.length === 0) { alert("품목을 선택해주세요."); return; }
setSaving(true);
try {
const payload = {
id: isEditMode ? editId : undefined,
instructionDate: formOrderDate,
partnerId: formPartnerId || formCustomer,
status: formStatus,
memo: formMemo,
carrierName: formCarrier,
vehicleNo: formVehicle,
driverName: formDriver,
driverContact: formDriverPhone,
arrivalTime: formArrival || null,
deliveryAddress: formAddress,
items: selectedItems.map(item => ({
itemCode: item.itemCode,
itemName: item.itemName,
spec: item.spec,
material: item.material,
orderQty: item.orderQty,
planQty: item.planQty,
shipQty: 0,
sourceType: item.sourceType,
shipmentPlanId: item.shipmentPlanId,
salesOrderId: item.salesOrderId,
detailId: item.detailId,
})),
};
const result = await saveShippingOrder(payload);
if (result.success) {
setIsModalOpen(false);
fetchOrders();
alert(isEditMode ? "출하지시가 수정되었습니다." : "출하지시가 등록되었습니다.");
} else {
alert(result.message || "저장 실패");
}
} catch (err: any) {
alert(err.message || "저장 중 오류 발생");
} finally {
setSaving(false);
}
};
const formatDate = (d: string) => d ? d.split("T")[0] : "-";
const dataSourceTitle: Record<DataSourceType, string> = {
shipmentPlan: "출하계획 목록",
salesOrder: "수주정보 목록",
itemInfo: "품목정보 목록",
};
return (
<div className="flex flex-col h-[calc(100vh-4rem)] bg-muted/30 p-4 gap-4">
{/* 검색 */}
<Card className="shrink-0">
<CardContent className="p-4 flex flex-wrap items-end gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input placeholder="검색" className="w-[160px] h-9" value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input placeholder="거래처 검색" className="w-[140px] h-9" value={searchCustomer}
onChange={(e) => setSearchCustomer(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchStatus} onValueChange={setSearchStatus}>
<SelectTrigger className="w-[110px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<div className="flex items-center gap-2">
<div className="w-[160px]">
<FormDatePicker value={searchDateFrom} onChange={setSearchDateFrom} placeholder="시작일" />
</div>
<span className="text-muted-foreground">~</span>
<div className="w-[160px]">
<FormDatePicker value={searchDateTo} onChange={setSearchDateTo} placeholder="종료일" />
</div>
</div>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
{/* 메인 테이블 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Truck className="w-5 h-5" />
<Badge variant="secondary" className="font-normal">{orders.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={() => openModal()}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={checkedIds.length === 0} onClick={handleDeleteSelected}>
<Trash2 className="w-4 h-4 mr-1.5" /> {checkedIds.length > 0 && `(${checkedIds.length})`}
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow>
<TableHead className="w-[40px] text-center">
<Checkbox checked={orders.length > 0 && checkedIds.length === orders.length} onCheckedChange={handleCheckAll} />
</TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[90px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[130px]"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.length === 0 ? (
<TableRow>
<TableCell colSpan={13} className="h-40 text-center text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<Truck className="w-12 h-12 text-muted-foreground/30" />
<div className="font-medium"> </div>
<div className="text-sm"> </div>
</div>
</TableCell>
</TableRow>
) : (
orders.map((order: any) => {
const items = Array.isArray(order.items) ? order.items.filter((it: any) => it.id) : [];
if (items.length === 0) {
return (
<TableRow key={order.id} className="cursor-pointer hover:bg-muted/50" onClick={() => openModal(order)}>
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
<Checkbox checked={checkedIds.includes(order.id)} onCheckedChange={(c) => {
if (c) setCheckedIds(p => [...p, order.id]);
else setCheckedIds(p => p.filter(i => i !== order.id));
}} />
</TableCell>
<TableCell className="font-medium">{order.instruction_no}</TableCell>
<TableCell className="text-center">{formatDate(order.instruction_date)}</TableCell>
<TableCell>{order.customer_name || "-"}</TableCell>
<TableCell>{order.carrier_name || "-"}</TableCell>
<TableCell>{order.vehicle_no || "-"}</TableCell>
<TableCell>{order.driver_name || "-"}</TableCell>
<TableCell className="text-center">
<span className={cn("px-2 py-1 rounded-full text-[11px] font-medium border", getStatusColor(order.status))}>{getStatusLabel(order.status)}</span>
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell className="text-right">0</TableCell>
<TableCell className="text-center">-</TableCell>
<TableCell className="text-xs text-muted-foreground truncate max-w-[100px]">{order.memo || "-"}</TableCell>
</TableRow>
);
}
return items.map((item: any, itemIdx: number) => (
<TableRow key={`${order.id}-${item.id}`} className="cursor-pointer hover:bg-muted/50" onClick={() => openModal(order)}>
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
{itemIdx === 0 && <Checkbox checked={checkedIds.includes(order.id)} onCheckedChange={(c) => {
if (c) setCheckedIds(p => [...p, order.id]);
else setCheckedIds(p => p.filter(i => i !== order.id));
}} />}
</TableCell>
<TableCell className="font-medium">{itemIdx === 0 ? order.instruction_no : ""}</TableCell>
<TableCell className="text-center">{itemIdx === 0 ? formatDate(order.instruction_date) : ""}</TableCell>
<TableCell>{itemIdx === 0 ? (order.customer_name || "-") : ""}</TableCell>
<TableCell>{itemIdx === 0 ? (order.carrier_name || "-") : ""}</TableCell>
<TableCell>{itemIdx === 0 ? (order.vehicle_no || "-") : ""}</TableCell>
<TableCell>{itemIdx === 0 ? (order.driver_name || "-") : ""}</TableCell>
<TableCell className="text-center">
{itemIdx === 0 && <span className={cn("px-2 py-1 rounded-full text-[11px] font-medium border", getStatusColor(order.status))}>{getStatusLabel(order.status)}</span>}
</TableCell>
<TableCell className="text-xs text-muted-foreground">{item.item_code}</TableCell>
<TableCell className="font-medium text-sm">{item.item_name}</TableCell>
<TableCell className="text-right">{Number(item.order_qty || 0).toLocaleString()}</TableCell>
<TableCell className="text-center">
{(() => { const b = getSourceBadge(item.source_type || ""); return <span className={cn("px-2 py-0.5 rounded-full text-[10px]", b.cls)}>{b.label}</span>; })()}
</TableCell>
<TableCell className="text-xs text-muted-foreground truncate max-w-[100px]">{itemIdx === 0 ? (order.memo || "-") : ""}</TableCell>
</TableRow>
));
})
)}
</TableBody>
</Table>
)}
</div>
</div>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[90vw] max-h-[90vh] w-[1400px] p-0 flex flex-col overflow-hidden">
<DialogHeader className="p-5 pb-4 border-b bg-primary text-primary-foreground shrink-0">
<DialogTitle className="text-lg">{isEditMode ? "출하지시 수정" : "출하지시 등록"}</DialogTitle>
<DialogDescription className="text-primary-foreground/70">
{isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden">
<ResizablePanelGroup direction="horizontal">
{/* 왼쪽: 데이터 소스 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
<div className="p-3 border-b bg-muted/30 flex flex-wrap items-center gap-2 shrink-0">
<Select value={dataSource} onValueChange={(v) => setDataSource(v as DataSourceType)}>
<SelectTrigger className="w-[130px] h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="shipmentPlan"></SelectItem>
<SelectItem value="salesOrder"></SelectItem>
<SelectItem value="itemInfo"></SelectItem>
</SelectContent>
</Select>
<Input placeholder="품번, 품명 검색" className="flex-1 h-8 text-xs min-w-[120px]"
value={sourceKeyword} onChange={(e) => setSourceKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { setSourcePage(1); fetchSourceData(1); }}} />
<Button size="sm" className="h-8 text-xs" onClick={() => { setSourcePage(1); fetchSourceData(1); }} disabled={sourceLoading}>
{sourceLoading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Search className="w-3 h-3" />}
<span className="ml-1"></span>
</Button>
</div>
<div className="px-4 py-2 flex items-center justify-between border-b shrink-0">
<div className="text-sm font-medium">
{dataSourceTitle[dataSource]}
<span className="text-muted-foreground ml-2 font-normal">
: <span className="text-primary font-semibold">{selectedItems.length}</span>
</span>
</div>
</div>
<div className="flex-1 overflow-auto">
{sourceLoading ? (
<div className="flex items-center justify-center py-12"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
) : sourceData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<div className="text-sm"> </div>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
{dataSource === "shipmentPlan" && <TableHead className="w-[70px] text-center"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{sourceData.map((item: any, idx: number) => {
const itemId = dataSource === "itemInfo" ? item.item_code : item.id;
const isSelected = selectedItems.some(s => {
// 같은 소스 타입에서 id 매칭
if (s.sourceType === dataSource) {
if (dataSource === "itemInfo") return s.itemCode === itemId;
return String(s.id) === String(itemId);
}
// 다른 소스 타입이라도 같은 품번이면 중복 방지
if (dataSource === "shipmentPlan" && s.shipmentPlanId) return String(s.shipmentPlanId) === String(item.id);
if (dataSource === "salesOrder" && s.detailId) return String(s.detailId) === String(item.id);
return false;
});
return (
<TableRow key={`${dataSource}-${itemId}-${idx}`} className={cn("cursor-pointer hover:bg-muted/50", isSelected && "bg-primary/5")} onClick={() => toggleSourceItem(item)}>
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={() => toggleSourceItem(item)} />
</TableCell>
<TableCell className="text-xs">{item.item_code || "-"}</TableCell>
<TableCell className="text-sm font-medium">{item.item_name || "-"}</TableCell>
<TableCell className="text-xs text-muted-foreground">{item.spec || "-"}</TableCell>
<TableCell className="text-xs">{item.customer_name || "-"}</TableCell>
<TableCell className="text-right text-xs">{Number(item.plan_qty || item.qty || item.balance_qty || 0).toLocaleString()}</TableCell>
{dataSource === "shipmentPlan" && (
<TableCell className="text-center">
<Badge variant="secondary" className="text-[10px]">{getStatusLabel(item.status)}</Badge>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
{/* 페이징 */}
{sourceTotalCount > 0 && (
<div className="px-4 py-2 border-t bg-muted/10 flex items-center justify-between shrink-0">
<span className="text-xs text-muted-foreground">
{sourceTotalCount} {(sourcePage - 1) * sourcePageSize + 1}-{Math.min(sourcePage * sourcePageSize, sourceTotalCount)}
</span>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
onClick={() => { const p = sourcePage - 1; setSourcePage(p); fetchSourceData(p); }}>
<ChevronLeft className="w-3.5 h-3.5" />
</Button>
<span className="text-xs font-medium px-2">{sourcePage} / {Math.max(1, Math.ceil(sourceTotalCount / sourcePageSize))}</span>
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage >= Math.ceil(sourceTotalCount / sourcePageSize)}
onClick={() => { const p = sourcePage + 1; setSourcePage(p); fetchSourceData(p); }}>
<ChevronRight className="w-3.5 h-3.5" />
</Button>
</div>
</div>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 폼 */}
<ResizablePanel defaultSize={45} minSize={30}>
<div className="flex flex-col h-full overflow-auto p-5 bg-muted/20 gap-5">
{/* 기본 정보 */}
<div className="bg-background border rounded-lg p-5 shrink-0">
<h3 className="text-sm font-semibold mb-4"> </h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input value={formOrderNumber} readOnly className="h-9 bg-muted/50 text-muted-foreground" />
</div>
<div className="space-y-1.5">
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<FormDatePicker value={formOrderDate} onChange={setFormOrderDate} placeholder="날짜 선택" />
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input value={formCustomer} readOnly placeholder="품목 선택 시 자동" className="h-9 bg-muted/50" />
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Select value={formStatus} onValueChange={setFormStatus}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="READY"></SelectItem>
<SelectItem value="IN_PROGRESS"></SelectItem>
<SelectItem value="COMPLETED"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 운송 정보 */}
<div className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden shrink-0">
<button className="w-full px-5 py-3 flex items-center justify-between text-left" onClick={() => setIsTransportCollapsed(!isTransportCollapsed)}>
<h3 className="text-sm font-semibold text-amber-900 flex items-center gap-2">
<Truck className="w-4 h-4" /> <span className="text-[11px] font-normal text-muted-foreground">()</span>
</h3>
{isTransportCollapsed ? <ChevronRight className="w-4 h-4 text-amber-700" /> : <ChevronDown className="w-4 h-4 text-amber-700" />}
</button>
{!isTransportCollapsed && (
<div className="px-5 pb-4 grid grid-cols-3 gap-3">
<div className="space-y-1.5"><Label className="text-xs"></Label><Input value={formCarrier} onChange={(e) => setFormCarrier(e.target.value)} className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><Input value={formVehicle} onChange={(e) => setFormVehicle(e.target.value)} className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><Input value={formDriver} onChange={(e) => setFormDriver(e.target.value)} className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><Input value={formDriverPhone} onChange={(e) => setFormDriverPhone(e.target.value)} className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><FormDatePicker value={formArrival} onChange={setFormArrival} placeholder="도착예정일시" includeTime /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><Input value={formAddress} onChange={(e) => setFormAddress(e.target.value)} className="h-9" /></div>
</div>
)}
</div>
{/* 선택된 품목 */}
<div className="bg-background border rounded-lg p-5 flex-1 flex flex-col min-h-[200px]">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Badge variant="default" className="text-[10px]">{selectedItems.length}</Badge>
</h3>
<div className="flex-1 overflow-auto min-h-0">
{selectedItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<div className="text-sm"> </div>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center"></TableHead>
<TableHead className="w-[90px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[90px] text-center"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[40px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedItems.map((item, idx) => {
const b = getSourceBadge(item.sourceType);
return (
<TableRow key={`${item.sourceType}-${item.id}-${idx}`}>
<TableCell className="text-center">
<span className={cn("px-1.5 py-0.5 rounded text-[10px]", b.cls)}>{b.label.charAt(0)}</span>
</TableCell>
<TableCell className="text-xs">{item.itemCode}</TableCell>
<TableCell className="text-sm font-medium">{item.itemName}</TableCell>
<TableCell className="text-center">
<Input type="number" value={item.orderQty} onChange={(e) => updateOrderQty(idx, parseInt(e.target.value) || 0)}
min={1} className="h-7 w-[70px] text-xs text-right mx-auto" />
</TableCell>
<TableCell className="text-right text-xs">{item.planQty ? item.planQty.toLocaleString() : "-"}</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeSelectedItem(idx)}>
<X className="w-3.5 h-3.5 text-destructive" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</div>
{/* 메모 */}
<div className="bg-background border rounded-lg p-5 shrink-0">
<h3 className="text-sm font-semibold mb-3"></h3>
<Textarea value={formMemo} onChange={(e) => setFormMemo(e.target.value)} placeholder="출하지시 관련 메모" rows={2} className="resize-y" />
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
<DialogFooter className="p-4 border-t bg-muted/30 shrink-0">
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}