929 lines
44 KiB
TypeScript
929 lines
44 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useMemo, useEffect, useCallback } 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
|
import { Plus, Trash2, Save, X, ChevronDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Search, Loader2, FileSpreadsheet, Inbox, Settings2 } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
getShippingOrderList,
|
|
saveShippingOrder,
|
|
deleteShippingOrders,
|
|
previewShippingOrderNo,
|
|
getShipmentPlanSource,
|
|
getSalesOrderSource,
|
|
getItemSource,
|
|
} from "@/lib/api/shipping";
|
|
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
|
|
|
const GRID_COLUMNS = [
|
|
{ key: "instruction_no", label: "출하지시번호" },
|
|
{ key: "ship_date", label: "출하일자" },
|
|
{ key: "customer_name", label: "거래처명" },
|
|
{ key: "transport_company", label: "운송업체" },
|
|
{ key: "vehicle_no", label: "차량번호" },
|
|
{ key: "driver_name", label: "기사명" },
|
|
{ key: "status", label: "상태" },
|
|
{ key: "item_code", label: "품번" },
|
|
{ key: "item_name", label: "품명" },
|
|
{ key: "qty", label: "수량" },
|
|
{ key: "source_type", label: "소스" },
|
|
{ key: "remark", label: "비고" },
|
|
];
|
|
|
|
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-warning/10 text-warning";
|
|
case "IN_PROGRESS": return "bg-primary/10 text-primary";
|
|
case "COMPLETED": return "bg-success/10 text-success";
|
|
default: return "bg-muted text-muted-foreground";
|
|
}
|
|
};
|
|
|
|
const getSourceBadge = (s: string) => {
|
|
switch (s) {
|
|
case "shipmentPlan": return { label: "출하계획", cls: "bg-primary/10 text-primary" };
|
|
case "salesOrder": return { label: "수주", cls: "bg-success/10 text-success" };
|
|
case "itemInfo": return { label: "품목", cls: "bg-secondary text-secondary-foreground" };
|
|
default: return { label: s, cls: "bg-muted text-muted-foreground" };
|
|
}
|
|
};
|
|
|
|
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 ts = useTableSettings("c16-shipping-order", "shipment_instruction", GRID_COLUMNS);
|
|
const [orders, setOrders] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
|
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
|
|
|
|
// 검색 필터 (DynamicSearchFilter에서 관리)
|
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
|
|
|
// 엑셀 업로드
|
|
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
|
|
|
// 모달
|
|
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, setSourcePageSize] = useState(20);
|
|
const [sourceTotalCount, setSourceTotalCount] = useState(0);
|
|
|
|
|
|
// 데이터 조회
|
|
const fetchOrders = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params: any = {};
|
|
for (const f of searchFilters) {
|
|
if (f.columnName === "ship_date" && f.operator === "between" && f.value) {
|
|
const [from, to] = f.value.split(",");
|
|
if (from) params.dateFrom = from;
|
|
if (to) params.dateTo = to;
|
|
} else if (f.columnName === "status") {
|
|
params.status = f.value;
|
|
} else if (f.columnName === "customer_name") {
|
|
params.customer = f.value;
|
|
} else {
|
|
params.keyword = f.value;
|
|
}
|
|
}
|
|
|
|
const result = await getShippingOrderList(params);
|
|
if (result.success) setOrders(result.data || []);
|
|
} catch (err) {
|
|
console.error("출하지시 조회 실패:", err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [searchFilters]);
|
|
|
|
useEffect(() => {
|
|
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 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 ? new Date(order.arrival_time).toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }).replace(" ", "T").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";
|
|
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 => {
|
|
if (s.sourceType === dataSource) {
|
|
if (dataSource === "itemInfo") return s.itemCode === key;
|
|
return String(s.id) === String(key);
|
|
}
|
|
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 ? `${formArrival}+09:00` : 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] : "-";
|
|
|
|
// 출하지시 데이터를 플랫한 행 목록으로 변환 (EDataTable용)
|
|
const flattenedOrders = useMemo(() => {
|
|
const rows: any[] = [];
|
|
for (const order of orders) {
|
|
const items = Array.isArray(order.items) ? order.items.filter((it: any) => it.id) : [];
|
|
if (items.length === 0) {
|
|
rows.push({
|
|
_rowId: String(order.id),
|
|
_orderId: order.id,
|
|
_order: order,
|
|
instruction_no: order.instruction_no,
|
|
ship_date: formatDate(order.instruction_date),
|
|
customer_name: order.customer_name || "-",
|
|
transport_company: order.carrier_name || "-",
|
|
vehicle_no: order.vehicle_no || "-",
|
|
driver_name: order.driver_name || "-",
|
|
status: order.status,
|
|
item_code: "-",
|
|
item_name: "-",
|
|
qty: 0,
|
|
source_type: "-",
|
|
remark: order.memo || "-",
|
|
});
|
|
} else {
|
|
items.forEach((item: any, idx: number) => {
|
|
rows.push({
|
|
_rowId: `${order.id}-${item.id}`,
|
|
_orderId: order.id,
|
|
_order: order,
|
|
instruction_no: idx === 0 ? order.instruction_no : "",
|
|
ship_date: idx === 0 ? formatDate(order.instruction_date) : "",
|
|
customer_name: idx === 0 ? (order.customer_name || "-") : "",
|
|
transport_company: idx === 0 ? (order.carrier_name || "-") : "",
|
|
vehicle_no: idx === 0 ? (order.vehicle_no || "-") : "",
|
|
driver_name: idx === 0 ? (order.driver_name || "-") : "",
|
|
status: idx === 0 ? order.status : "",
|
|
item_code: item.item_code || "",
|
|
item_name: item.item_name || "",
|
|
qty: Number(item.order_qty || 0),
|
|
source_type: item.source_type || "",
|
|
remark: idx === 0 ? (order.memo || "-") : "",
|
|
});
|
|
});
|
|
}
|
|
}
|
|
return rows;
|
|
}, [orders]);
|
|
|
|
// checkedIds를 order.id 기준으로 관리하므로 _orderId로 매핑
|
|
const flatCheckedRowIds = useMemo(() => {
|
|
return flattenedOrders
|
|
.filter((r) => checkedIds.includes(r._orderId))
|
|
.map((r) => r._rowId);
|
|
}, [flattenedOrders, checkedIds]);
|
|
|
|
const handleFlatCheckedChange = useCallback((rowIds: string[]) => {
|
|
const orderIds = new Set<number>();
|
|
for (const rowId of rowIds) {
|
|
const row = flattenedOrders.find((r) => r._rowId === rowId);
|
|
if (row) orderIds.add(row._orderId);
|
|
}
|
|
setCheckedIds(Array.from(orderIds));
|
|
}, [flattenedOrders]);
|
|
|
|
const dataSourceTitle: Record<DataSourceType, string> = {
|
|
shipmentPlan: "출하계획 목록",
|
|
salesOrder: "수주정보 목록",
|
|
itemInfo: "품목정보 목록",
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full p-4 gap-3">
|
|
{/* 브레드크럼 */}
|
|
<nav className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
|
<span>영업관리</span>
|
|
<span className="text-muted-foreground/40">/</span>
|
|
<span className="font-semibold text-foreground">출하지시</span>
|
|
</nav>
|
|
|
|
{/* 검색 필터 (DynamicSearchFilter) */}
|
|
<DynamicSearchFilter
|
|
tableName={ts.tableName}
|
|
filterId="c16-shipping-order"
|
|
onFilterChange={setSearchFilters}
|
|
dataCount={orders.length}
|
|
externalFilterConfig={ts.filterConfig}
|
|
/>
|
|
|
|
{/* 액션 바 */}
|
|
<div className="flex items-center justify-between flex-wrap gap-3 shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-[15px] font-bold text-foreground">출하지시 관리</h2>
|
|
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
|
{orders.length}건
|
|
</span>
|
|
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
|
<FileSpreadsheet className="w-3.5 h-3.5" />
|
|
엑셀 업로드
|
|
</Button>
|
|
<Button size="sm" onClick={() => openModal()}>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
출하지시 등록
|
|
</Button>
|
|
<div className="w-px h-5 bg-border" />
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10"
|
|
disabled={checkedIds.length === 0}
|
|
onClick={handleDeleteSelected}
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
선택삭제 {checkedIds.length > 0 && `(${checkedIds.length})`}
|
|
</Button>
|
|
<div className="w-px h-5 bg-border" />
|
|
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
|
<Settings2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 메인 테이블 */}
|
|
<div className="flex-1 overflow-hidden rounded-lg border bg-card flex flex-col">
|
|
<EDataTable
|
|
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
|
key: col.key,
|
|
label: col.label,
|
|
align: col.key === "qty" ? "right" : col.key === "status" || col.key === "source_type" || col.key === "ship_date" ? "center" : undefined,
|
|
formatNumber: col.key === "qty",
|
|
sortable: false,
|
|
filterable: false,
|
|
render: col.key === "status"
|
|
? (val: any) => val ? (
|
|
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(val))}>
|
|
{getStatusLabel(val)}
|
|
</span>
|
|
) : null
|
|
: col.key === "source_type"
|
|
? (val: any) => {
|
|
if (!val || val === "-") return <span>-</span>;
|
|
const b = getSourceBadge(val);
|
|
return <span className={cn("px-2 py-0.5 rounded-full text-[10px] font-medium", b.cls)}>{b.label}</span>;
|
|
}
|
|
: undefined,
|
|
}))}
|
|
data={ts.groupData(flattenedOrders)}
|
|
rowKey={(row) => row._rowId}
|
|
loading={loading}
|
|
emptyMessage="등록된 출하지시가 없어요"
|
|
showCheckbox
|
|
checkedIds={flatCheckedRowIds}
|
|
onCheckedChange={handleFlatCheckedChange}
|
|
selectedId={selectedOrderId != null ? String(selectedOrderId) : null}
|
|
onRowClick={(row) => setSelectedOrderId(row._orderId)}
|
|
onRowDoubleClick={(row) => openModal(row._order)}
|
|
showPagination
|
|
draggableColumns={false}
|
|
columnOrderKey="c16-shipping-order"
|
|
/>
|
|
</div>
|
|
|
|
{/* 등록/수정 모달 */}
|
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
|
<DialogContent className="max-w-[90vw] w-[1400px] h-[90vh] flex flex-col overflow-hidden p-0 gap-0">
|
|
<DialogHeader className="px-4 py-3 border-b shrink-0">
|
|
<DialogTitle className="text-[15px] font-bold">
|
|
{isEditMode ? "출하지시 수정" : "출하지시 등록"}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
|
|
{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="px-4 py-2 border-b bg-muted/50 flex flex-wrap items-center gap-2 shrink-0">
|
|
<Select value={dataSource} onValueChange={(v) => setDataSource(v as DataSourceType)}>
|
|
<SelectTrigger className="w-[120px] h-9 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-9 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-3 py-1.5 flex items-center gap-2 border-b bg-muted/30 shrink-0">
|
|
<span className="text-[13px] font-bold text-foreground">{dataSourceTitle[dataSource]}</span>
|
|
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
|
선택 {selectedItems.length}개
|
|
</span>
|
|
</div>
|
|
|
|
{/* 소스 테이블 */}
|
|
<div className="flex-1 overflow-auto">
|
|
{sourceLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : sourceData.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground gap-2">
|
|
<div className="w-12 h-12 rounded-full border-2 border-dashed border-muted-foreground/20 flex items-center justify-center">
|
|
<Search className="w-5 h-5 text-muted-foreground/30" />
|
|
</div>
|
|
<p className="text-sm">조회 버튼을 눌러 데이터를 불러와주세요</p>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-[40px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">선택</TableHead>
|
|
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
|
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
|
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
|
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">거래처</TableHead>
|
|
<TableHead className="w-[70px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
|
{dataSource === "shipmentPlan" && <TableHead className="w-[70px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{sourceData.map((item: any, idx: number) => {
|
|
const itemId = dataSource === "itemInfo" ? item.item_code : item.id;
|
|
const isSelected = selectedItems.some(s => {
|
|
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 transition-colors", 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-[13px]">{item.item_code || "-"}</TableCell>
|
|
<TableCell className="text-sm font-medium">{item.item_name || "-"}</TableCell>
|
|
<TableCell className="text-[13px] text-muted-foreground">{item.spec || "-"}</TableCell>
|
|
<TableCell className="text-[13px]">{item.customer_name || "-"}</TableCell>
|
|
<TableCell className="text-right text-[13px]">
|
|
{Number(item.plan_qty || item.qty || item.balance_qty || 0).toLocaleString()}
|
|
</TableCell>
|
|
{dataSource === "shipmentPlan" && (
|
|
<TableCell className="text-center">
|
|
<span className={cn("px-2 py-0.5 rounded-full text-[10px] font-medium", getStatusColor(item.status))}>
|
|
{getStatusLabel(item.status)}
|
|
</span>
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
|
|
{/* 페이징 */}
|
|
{sourceTotalCount > 0 && (
|
|
<div className="px-3 py-1.5 border-t bg-muted/30 flex items-center justify-between shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground text-[11px]">표시:</span>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={500}
|
|
value={sourcePageSize}
|
|
onChange={(e) => {
|
|
const v = parseInt(e.target.value, 10);
|
|
if (v > 0) { setSourcePageSize(v); setSourcePage(1); fetchSourceData(1); }
|
|
}}
|
|
className="h-7 w-[60px] text-center text-[11px]"
|
|
/>
|
|
<span className="text-muted-foreground text-[11px]">총 {sourceTotalCount}건</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
|
|
onClick={() => { setSourcePage(1); fetchSourceData(1); }}>
|
|
<ChevronsLeft className="w-3.5 h-3.5" />
|
|
</Button>
|
|
<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>
|
|
<Button variant="outline" size="icon" className="h-7 w-7"
|
|
disabled={sourcePage >= Math.ceil(sourceTotalCount / sourcePageSize)}
|
|
onClick={() => { const p = Math.ceil(sourceTotalCount / sourcePageSize); setSourcePage(p); fetchSourceData(p); }}>
|
|
<ChevronsRight className="w-3.5 h-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle withHandle onPointerDown={(e) => e.stopPropagation()} />
|
|
|
|
{/* 오른쪽: 폼 */}
|
|
<ResizablePanel defaultSize={45} minSize={30}>
|
|
<div className="flex flex-col h-full overflow-auto p-4 gap-4">
|
|
{/* 기본 정보 */}
|
|
<div className="border rounded-lg p-4 shrink-0">
|
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide mb-3">기본 정보</p>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">출하지시번호</Label>
|
|
<Input value={formOrderNumber} readOnly className="h-9 bg-muted/50 text-muted-foreground" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">
|
|
출하지시일 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
type="date"
|
|
className="h-9"
|
|
value={formOrderDate}
|
|
onChange={(e) => setFormOrderDate(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">거래처</Label>
|
|
<Input value={formCustomer} readOnly placeholder="품목 선택 시 자동" className="h-9 bg-muted/50" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">상태</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-muted/50 border rounded-lg overflow-hidden shrink-0">
|
|
<button
|
|
className="w-full px-4 py-2.5 flex items-center justify-between text-left"
|
|
onClick={() => setIsTransportCollapsed(!isTransportCollapsed)}
|
|
>
|
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
|
운송 정보 <span className="text-[10px] font-normal">(선택사항)</span>
|
|
</p>
|
|
{isTransportCollapsed
|
|
? <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
|
: <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
|
}
|
|
</button>
|
|
{!isTransportCollapsed && (
|
|
<div className="px-4 pb-3 grid grid-cols-3 gap-3">
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">운송업체</Label>
|
|
<Input value={formCarrier} onChange={(e) => setFormCarrier(e.target.value)} className="h-9" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">차량번호</Label>
|
|
<Input value={formVehicle} onChange={(e) => setFormVehicle(e.target.value)} className="h-9" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">기사명</Label>
|
|
<Input value={formDriver} onChange={(e) => setFormDriver(e.target.value)} className="h-9" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">연락처</Label>
|
|
<Input value={formDriverPhone} onChange={(e) => setFormDriverPhone(e.target.value)} className="h-9" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">도착예정일시</Label>
|
|
<Input
|
|
type="datetime-local"
|
|
className="h-9"
|
|
value={formArrival}
|
|
onChange={(e) => setFormArrival(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">배송지</Label>
|
|
<Input value={formAddress} onChange={(e) => setFormAddress(e.target.value)} className="h-9" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 선택된 품목 */}
|
|
<div className="border rounded-lg p-4 flex-1 flex flex-col min-h-[200px]">
|
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide mb-2 flex items-center gap-2">
|
|
선택된 품목
|
|
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
|
{selectedItems.length}
|
|
</span>
|
|
</p>
|
|
<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 border-2 border-dashed rounded-lg gap-2">
|
|
<div className="w-10 h-10 rounded-full border-2 border-dashed border-muted-foreground/20 flex items-center justify-center">
|
|
<Inbox className="w-4 h-4 text-muted-foreground/30" />
|
|
</div>
|
|
<p className="text-sm">왼쪽에서 데이터를 선택해주세요</p>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-[40px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소스</TableHead>
|
|
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
|
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
|
<TableHead className="w-[90px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">출하수량</TableHead>
|
|
<TableHead className="w-[70px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">계획수량</TableHead>
|
|
<TableHead className="w-[40px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">삭제</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] font-medium", b.cls)}>
|
|
{b.label.charAt(0)}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-[13px]">{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-[13px]">
|
|
{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="border rounded-lg p-4 shrink-0">
|
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">메모</p>
|
|
<Textarea
|
|
value={formMemo}
|
|
onChange={(e) => setFormMemo(e.target.value)}
|
|
placeholder="출하지시 관련 메모를 입력해주세요"
|
|
rows={2}
|
|
className="resize-y"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</div>
|
|
|
|
{/* 모달 하단 버튼 */}
|
|
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t shrink-0">
|
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
저장
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 엑셀 업로드 모달 */}
|
|
<ExcelUploadModal
|
|
open={excelUploadOpen}
|
|
onOpenChange={setExcelUploadOpen}
|
|
tableName="shipping-order"
|
|
onSuccess={() => { fetchOrders(); }}
|
|
/>
|
|
|
|
<TableSettingsModal
|
|
open={ts.open}
|
|
onOpenChange={ts.setOpen}
|
|
tableName={ts.tableName}
|
|
settingsId={ts.settingsId}
|
|
defaultVisibleKeys={ts.defaultVisibleKeys}
|
|
onSave={ts.applySettings}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|