Files
pipeline/frontend/app/(main)/COMPANY_7/sales/order/page.tsx
T
kjs f416241788 fix: Reset related fields on input mode change in Sales Order page
- Updated the input mode selection logic to clear associated fields (partner_id, delivery_partner_id, delivery_address) when the input mode changes.
- This change ensures that the form state is correctly managed and prevents stale data from being retained, enhancing the user experience across multiple companies.

These modifications aim to improve the clarity and functionality of the Sales Order page by ensuring that changes in input mode reflect accurately in the form state.
2026-04-10 11:12:49 +09:00

1628 lines
79 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback, useMemo } 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, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Truck, Package,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
Settings2, RotateCcw, Filter, Check, ArrowUp, ArrowDown,
} from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
const DETAIL_TABLE = "sales_order_detail";
const MASTER_TABLE = "sales_order_mng";
// 천단위 구분자 표시용
const formatNumber = (val: string) => {
const num = val.replace(/[^\d.-]/g, "");
if (!num) return "";
const parts = num.split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts.join(".");
};
const parseNumber = (val: string) => val.replace(/,/g, "");
// 플랫 테이블 컬럼 정의 (마스터+디테일 통합)
const FLAT_COLUMNS = [
{ key: "order_no", label: "수주번호", source: "master" },
{ key: "partner_id", label: "거래처", source: "master" },
{ key: "order_date", label: "수주일", source: "master" },
{ key: "part_code", label: "품번", source: "detail" },
{ key: "part_name", label: "품명", source: "detail" },
{ key: "spec", label: "규격", source: "detail" },
{ key: "unit", label: "단위", source: "detail" },
{ key: "qty", label: "수량", source: "detail" },
{ key: "ship_qty", label: "출하수량", source: "detail" },
{ key: "balance_qty", label: "잔량", source: "detail" },
{ key: "unit_price", label: "단가", source: "detail" },
{ key: "amount", label: "금액", source: "detail" },
{ key: "due_date", label: "납기일", source: "detail" },
{ key: "memo", label: "메모", source: "master" },
];
const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail");
// 필터용 전체 키
const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label }));
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(14) = 15
const TOTAL_COLS = 15;
// 헤더 필터 Popover
function HeaderFilterPopover({
colKey, colLabel, uniqueValues, filterValues, onToggle, onClear,
}: {
colKey: string;
colLabel: string;
uniqueValues: string[];
filterValues: Set<string>;
onToggle: (colKey: string, value: string) => void;
onClear: (colKey: string) => void;
}) {
const [filterSearch, setFilterSearch] = useState("");
const hasFilter = filterValues.size > 0;
const filteredValues = uniqueValues.filter(
(v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())
);
return (
<Popover>
<PopoverTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className={cn(
"hover:bg-primary/20 rounded p-0.5 transition-colors shrink-0",
hasFilter && "text-primary bg-primary/10",
)}
title="필터"
>
<Filter className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="start" onClick={(e) => e.stopPropagation()}>
<div className="space-y-2">
<div className="flex items-center justify-between border-b pb-2">
<span className="text-xs font-medium">: {colLabel}</span>
{hasFilter && (
<button onClick={() => onClear(colKey)} className="text-primary text-xs hover:underline">
</button>
)}
</div>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
value={filterSearch}
onChange={(e) => setFilterSearch(e.target.value)}
placeholder="검색..."
className="h-7 text-xs pl-7"
/>
</div>
<div className="max-h-52 space-y-0.5 overflow-y-auto">
{filteredValues.slice(0, 100).map((val) => {
const isSelected = filterValues.has(val);
return (
<div
key={val}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => onToggle(colKey, val)}
>
<div className={cn(
"flex h-4 w-4 items-center justify-center rounded border shrink-0",
isSelected ? "bg-primary border-primary" : "border-input",
)}>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{val || "(빈 값)"}</span>
</div>
);
})}
{filteredValues.length > 100 && (
<div className="text-muted-foreground px-2 py-1 text-xs">
... {filteredValues.length - 100}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
}
export default function SalesOrderPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
// 검색 필터 (DynamicSearchFilter에서 관리)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [saving, setSaving] = useState(false);
const [masterForm, setMasterForm] = useState<Record<string, any>>({});
const [detailRows, setDetailRows] = useState<any[]>([]);
const [allowPriceEdit, setAllowPriceEdit] = useState(true);
// 품목 선택 모달
const [itemSelectOpen, setItemSelectOpen] = useState(false);
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
const [itemSearchLoading, setItemSearchLoading] = useState(false);
const [itemSelectedMap, setItemSelectedMap] = useState<Map<string, any>>(new Map());
const [itemSearchDivision, setItemSearchDivision] = useState("all");
const [itemPage, setItemPage] = useState(1);
const [itemPageSize, setItemPageSize] = useState(20);
const [itemTotalPages, setItemTotalPages] = useState(0);
const [itemTotal, setItemTotal] = useState(0);
const [itemPageInput, setItemPageInput] = useState("1");
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 출하계획 모달
const [shippingPlanOpen, setShippingPlanOpen] = useState(false);
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 체크된 행 (다중선택)
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// 납품처 목록
const [deliveryOptions, setDeliveryOptions] = useState<{ code: string; label: string }[]>([]);
// 테이블 설정
const ts = useTableSettings("c16-sales-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
// 헤더 필터 & 정렬
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
// 카테고리 로드
useEffect(() => {
const loadCategories = async () => {
const catColumns = ["sell_mode", "input_mode", "price_mode", "incoterms", "payment_term"];
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
const LABEL_REPLACE: Record<string, string> = {
"공급업체 우선": "거래처 우선",
"공급업체우선": "거래처 우선",
};
const dedup = (items: { code: string; label: string }[]) => {
const seen = new Set<string>();
return items
.map((item) => ({ ...item, label: LABEL_REPLACE[item.label] || item.label }))
.filter((item) => {
const key = item.label.replace(/\s/g, "");
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
await Promise.all(
catColumns.map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[col] = dedup(flatten(res.data.data));
}
} catch { /* skip */ }
})
);
// 거래처 목록
try {
const custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, {
page: 1, size: 500, autoFilter: true,
});
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name }));
} catch { /* skip */ }
// 사용자 목록
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
optMap["manager_id"] = users.map((u: any) => ({
code: u.user_id || u.id,
label: `${u.user_name || u.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`,
}));
} catch { /* skip */ }
// item_info 카테고리
for (const col of ["unit", "material", "division", "type"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[`item_${col}`] = flatten(res.data.data);
}
} catch { /* skip */ }
}
setCategoryOptions(optMap);
// division 기본값
const divs = optMap["item_division"] || [];
const salesDiv = divs.find((o: any) => o.label === "영업관리")
|| divs.find((o: any) => o.label === "제품")
|| divs.find((o: any) => o.label === "판매품");
if (salesDiv) setItemSearchDivision(salesDiv.code);
};
loadCategories();
}, []);
// 데이터 조회
const fetchOrders = useCallback(async () => {
setLoading(true);
try {
const filters = searchFilters.map(f => ({
columnName: f.columnName,
operator: f.operator,
value: f.value,
}));
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "order_no", order: "desc" },
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
// order_no → sales_order_mng 조인
const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))];
let masterMap: Record<string, any> = {};
if (orderNos.length > 0) {
try {
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: orderNos.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "in", value: orderNos }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
for (const m of masters) masterMap[m.order_no] = m;
} catch { /* skip */ }
}
// part_code → item_info 조인
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
let itemMap: Record<string, any> = {};
if (partCodes.length > 0) {
try {
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: partCodes.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: partCodes }] },
autoFilter: true,
});
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
for (const item of items) itemMap[item.item_number] = item;
} catch { /* skip */ }
}
// 조인 적용 + 카테고리 코드→라벨 변환
const resolveLabel = (key: string, code: string) => {
if (!code) return "";
const opts = categoryOptions[key];
if (!opts) return code;
return opts.find((o) => o.code === code)?.label || code;
};
const data = rows.map((row: any) => {
const item = itemMap[row.part_code];
const master = masterMap[row.order_no];
const rawUnit = row.unit || item?.unit || "";
return {
...row,
part_name: row.part_name || item?.item_name || "",
spec: row.spec || item?.size || "",
material: row.material || (item ? (resolveLabel("item_material", item.material) || item.material || "") : ""),
unit: resolveLabel("item_unit", rawUnit) || rawUnit,
memo: row.memo || master?.memo || "",
_master: master || {},
};
});
setOrders(data);
setTotalCount(res.data?.data?.total || data.length);
} catch (err) {
toast.error("수주 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [searchFilters]);
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// 카테고리 코드→라벨 변환
const resolveLabel = useCallback((key: string, code: string) => {
if (!code) return "";
if (key === "partner_id" || key === "manager_id" || key === "price_mode") {
return categoryOptions[key]?.find((o) => o.code === code)?.label || code;
}
return code;
}, [categoryOptions]);
// 플랫 행 생성 (마스터 필드를 각 디테일 행에 병합)
const flatRows = useMemo(() => {
return orders.map((row) => {
const master = row._master || {};
return {
...row,
partner_id: resolveLabel("partner_id", master.partner_id || row.partner_id || ""),
order_date: master.order_date || row.order_date || "",
memo: row.memo || master.memo || "",
};
});
}, [orders, resolveLabel]);
// 컬럼별 고유값 (헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of FLAT_COLUMNS) {
const values = new Set<string>();
flatRows.forEach((row) => {
const val = row[col.key];
if (val !== null && val !== undefined && val !== "") values.add(String(val));
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [flatRows]);
// 필터 + 정렬 적용된 플랫 데이터
const filteredFlatRows = useMemo(() => {
let rows = [...flatRows];
// 1차: 헤더 필터 적용
for (const [colKey, values] of Object.entries(headerFilters)) {
if (values.size === 0) continue;
rows = rows.filter((row) => {
const cellVal = row[colKey] != null ? String(row[colKey]) : "";
return values.has(cellVal);
});
}
// 2차: 정렬
if (sortState) {
const { key, direction } = sortState;
rows.sort((a, b) => {
const av = a[key] ?? "";
const bv = b[key] ?? "";
const na = Number(av); const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return rows;
}, [flatRows, headerFilters, sortState]);
// 헤더 필터 토글/초기화
const toggleHeaderFilter = (colKey: string, value: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
const set = new Set(next[colKey] || []);
if (set.has(value)) set.delete(value); else set.add(value);
if (set.size === 0) delete next[colKey]; else next[colKey] = set;
return next;
});
};
const clearHeaderFilter = (colKey: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
delete next[colKey];
return next;
});
};
const handleSort = (key: string) => {
setSortState((prev) =>
prev?.key === key
? prev.direction === "asc" ? { key, direction: "desc" } : null
: { key, direction: "asc" }
);
};
const getCategoryLabel = (col: string, code: string) => {
if (!code) return "";
const found = categoryOptions[col]?.find((o) => o.code === code);
return found?.label || code;
};
const loadDeliveryOptions = async (customerCode: string) => {
if (!customerCode) { setDeliveryOptions([]); return; }
try {
const res = await apiClient.post(`/table-management/tables/delivery_destination/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: customerCode }] },
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setDeliveryOptions(rows.map((r: any) => ({
code: r.destination_code || r.id,
label: `${r.destination_name}${r.address ? ` (${r.address})` : ""}`,
})));
} catch { setDeliveryOptions([]); }
};
// 등록 모달 열기
const openRegisterModal = () => {
const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || "";
const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || "";
const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || "";
setMasterForm({
input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode,
manager_id: user?.userId || "", order_date: new Date().toISOString().split("T")[0],
});
setDetailRows([]);
setDeliveryOptions([]);
setIsEditMode(false);
setIsModalOpen(true);
};
// 수정 모달 열기
const openEditModal = async (orderNo: string) => {
try {
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masterData = (masterRes.data?.data?.data || masterRes.data?.data?.rows || [])[0];
const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
setMasterForm(masterData || {});
setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` })));
setIsEditMode(true);
setIsModalOpen(true);
} catch (err) {
toast.error("수주 정보를 불러오는데 실패했습니다.");
}
};
// 삭제 (마스터 단위)
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, {
description: "삭제된 데이터는 복구할 수 없습니다.",
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
try {
for (const orderNo of orderNos) {
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
if (masters.length > 0) {
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
data: masters.map((m: any) => ({ id: m.id })),
});
}
}
toast.success("삭제되었습니다.");
setCheckedIds([]);
fetchOrders();
} catch (err) {
toast.error("삭제에 실패했습니다.");
}
};
// 저장 (마스터 + 디테일)
const handleSave = async () => {
if (!masterForm.order_no && !isEditMode) {
toast.error("수주번호는 필수입니다.");
return;
}
if (detailRows.length === 0) {
toast.error("품목을 1개 이상 추가해주세요.");
return;
}
setSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, created_by, updated_by, ...masterFields } = masterForm;
if (isEditMode && id) {
await apiClient.put(`/table-management/tables/${MASTER_TABLE}/edit`, {
originalData: { id },
updatedData: masterFields,
});
const existingDetails = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: masterForm.order_no }] },
autoFilter: true,
});
const existings = existingDetails.data?.data?.data || existingDetails.data?.data?.rows || [];
if (existings.length > 0) {
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: existings.map((d: any) => ({ id: d.id })),
});
}
} else {
await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterFields);
}
for (const row of detailRows) {
const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row;
await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, {
...detailFields,
id: crypto.randomUUID(),
order_no: masterForm.order_no,
});
}
toast.success(isEditMode ? "수정되었습니다." : "등록되었습니다.");
setIsModalOpen(false);
fetchOrders();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 품목 검색
const searchItems = async (page?: number, size?: number) => {
const p = page ?? itemPage;
const s = size ?? itemPageSize;
setItemSearchLoading(true);
try {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
let customerItemIds: Set<string> | null = null;
if (isCustomerPrice && partnerId) {
try {
const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] },
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
} catch { /* skip */ }
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const resData = res.data?.data;
let allRows = resData?.data || resData?.rows || [];
// 거래처우선일 때 연결된 품목만 표시
if (customerItemIds) {
allRows = allRows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
// 관리품목 필터 (코드/라벨 혼재 대응)
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
allRows = allRows.filter((item: any) => {
const div = item.division || "";
return div.includes(itemSearchDivision) || (divLabel && div.includes(divLabel));
});
}
const total = allRows.length;
const start = (p - 1) * s;
setItemSearchResults(allRows.slice(start, start + s));
setItemTotal(total);
setItemTotalPages(Math.max(1, Math.ceil(total / s)));
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
};
const handleItemPageChange = (newPage: number) => {
if (newPage < 1 || newPage > itemTotalPages) return;
setItemPage(newPage);
setItemPageInput(String(newPage));
searchItems(newPage);
};
const commitItemPageInput = () => {
const parsed = parseInt(itemPageInput, 10);
if (isNaN(parsed) || itemPageInput.trim() === "") { setItemPageInput(String(itemPage)); return; }
const clamped = Math.max(1, Math.min(parsed, itemTotalPages || 1));
if (clamped !== itemPage) handleItemPageChange(clamped);
setItemPageInput(String(clamped));
};
const triggerNewSearch = () => {
setItemPage(1);
setItemPageInput("1");
searchItems(1);
};
const addSelectedItemsToDetail = async () => {
const selected = Array.from(itemSelectedMap.values());
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
let customerPriceMap: Record<string, string> = {};
if (isCustomerPrice && partnerId) {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 500,
dataFilter: {
enabled: true,
filters: [
{ columnName: "customer_id", operator: "equals", value: partnerId },
{ columnName: "item_id", operator: "in", value: itemIds },
],
},
autoFilter: true,
});
const prices = res.data?.data?.data || res.data?.data?.rows || [];
const _n = new Date();
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
for (const p of prices) {
const start = p.start_date || "";
const end = p.end_date || "";
if (start && start > today) continue;
if (end && end < today) continue;
const price = p.calculated_price || p.base_price || p.unit_price || "";
if (price && Number(price) > 0) {
const existing = customerPriceMap[p.item_id];
if (!existing || Number(price) > 0) customerPriceMap[p.item_id] = String(price);
}
}
} catch { /* skip */ }
}
const newRows = selected.map((item) => {
const itemCode = item.item_number || item.id;
let unitPrice = "";
if (isStandardPrice) {
unitPrice = item.standard_price || item.selling_price || "";
} else if (isCustomerPrice && partnerId) {
unitPrice = customerPriceMap[itemCode] || "";
}
return {
_id: `new_${Date.now()}_${Math.random()}`,
part_code: itemCode,
part_name: item.item_name,
spec: item.size || "",
material: getCategoryLabel("item_material", item.material) || item.material || "",
packing_material: "",
unit: getCategoryLabel("item_unit", item.unit) || item.unit || "",
qty: "1",
pack_qty: "0",
unit_price: unitPrice,
amount: unitPrice ? String(1 * parseFloat(unitPrice)) : "",
due_date: "",
};
});
setDetailRows((prev) => [...prev, ...newRows]);
toast.success(`${selected.length}개 품목이 추가되었습니다.`);
setItemSelectedMap(new Map());
setItemSelectOpen(false);
};
// 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신
const recalcPrices = useCallback(async (priceMode: string, partnerId: string) => {
if (detailRows.length === 0) return;
const STANDARD_CODES = ["CAT_MM0BUZKL_HJ7U", "CAT_MLKG792S_54WJ"];
const CUSTOMER_CODES = ["CAT_MM0BV3OS_41DX", "CAT_MLKG7D8K_N8SI"];
const isStandard = STANDARD_CODES.includes(priceMode);
const isCustomer = CUSTOMER_CODES.includes(priceMode);
if (isStandard) {
// 품목 기준단가 조회
const itemCodes = detailRows.map((r) => r.part_code).filter(Boolean);
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
autoFilter: true,
});
const items = res.data?.data?.data || res.data?.data?.rows || [];
const priceMap: Record<string, string> = {};
for (const item of items) {
const price = item.standard_price || item.selling_price || "";
if (price) priceMap[item.item_number] = String(price);
}
setDetailRows((prev) => prev.map((row) => {
const up = priceMap[row.part_code] || "";
const qty = parseFloat(row.qty) || 0;
const price = parseFloat(up) || 0;
return { ...row, unit_price: up, amount: (qty * price).toString() };
}));
} catch { /* skip */ }
} else if (isCustomer && partnerId) {
// 거래처별 단가 조회
const itemCodes = detailRows.map((r) => r.part_code).filter(Boolean);
if (itemCodes.length === 0) return;
try {
const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: partnerId },
{ columnName: "item_id", operator: "in", value: itemCodes },
]},
autoFilter: true,
});
const prices = res.data?.data?.data || res.data?.data?.rows || [];
const _n = new Date();
const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`;
const priceMap: Record<string, string> = {};
for (const p of prices) {
if (p.start_date && p.start_date > today) continue;
if (p.end_date && p.end_date < today) continue;
const price = p.calculated_price || p.base_price || p.unit_price || "";
if (price && Number(price) > 0) priceMap[p.item_id] = String(price);
}
setDetailRows((prev) => prev.map((row) => {
const up = priceMap[row.part_code] || "";
const qty = parseFloat(row.qty) || 0;
const price = parseFloat(up) || 0;
return { ...row, unit_price: up, amount: (qty * price).toString() };
}));
} catch { /* skip */ }
}
}, [detailRows]);
const updateDetailRow = (idx: number, field: string, value: string) => {
setDetailRows((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: value };
if (field === "qty" || field === "unit_price") {
const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0;
const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0;
next[idx].amount = (qty * price).toString();
}
return next;
});
};
const removeDetailRow = (idx: number) => {
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
};
// 조건부 레이어 판단
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
const handleExcelDownload = async () => {
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
const cols = ["order_no","part_code","part_name","spec","unit","qty","ship_qty","balance_qty","unit_price","amount","currency_code","due_date","memo"];
const labels: Record<string, string> = {
order_no:"수주번호",part_code:"품번",part_name:"품명",spec:"규격",unit:"단위",
qty:"수량",ship_qty:"출하수량",balance_qty:"잔량",unit_price:"단가",
amount:"금액",currency_code:"통화",due_date:"납기일",memo:"메모",
};
const data = orders.map((o) => {
const row: Record<string, any> = {};
for (const col of cols) row[labels[col]] = o[col] || "";
return row;
});
await exportToExcel(data, "수주관리.xlsx", "수주목록");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 브레드크럼 */}
<nav className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span></span>
<span className="opacity-40">/</span>
<span className="font-medium text-foreground"></span>
</nav>
{/* 검색 필터 (DynamicSearchFilter) */}
<DynamicSearchFilter
tableName={DETAIL_TABLE}
filterId="c16-sales-order"
onFilterChange={setSearchFilters}
dataCount={orders.length}
externalFilterConfig={ts.filterConfig}
/>
{/* 액션 바 */}
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3">
<h2 className="text-lg font-bold"> </h2>
<span className="inline-flex items-center rounded-full border border-primary/15 bg-primary/5 px-2.5 py-0.5 font-mono text-[11px] font-bold text-primary">
{totalCount}
</span>
</div>
<div className="flex items-center gap-2 overflow-x-auto">
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="outline" size="sm"
disabled={checkedIds.length !== 1}
onClick={() => {
const item = orders.find((o) => o.id === checkedIds[0]);
if (item) openEditModal(item.order_no);
}}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="outline" size="sm"
className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10 hover:text-destructive"
disabled={checkedIds.length === 0}
onClick={handleDelete}
>
<Trash2 className="w-4 h-4" /> {checkedIds.length > 0 && ` (${checkedIds.length})`}
</Button>
<div className="h-5 w-px bg-border mx-0.5" />
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" disabled={checkedIds.length === 0} onClick={() => setShippingPlanOpen(true)}>
<Truck className="w-4 h-4" /> {checkedIds.length > 0 && ` (${checkedIds.length})`}
</Button>
<div className="h-5 w-px bg-border mx-0.5" />
<Button variant="outline" size="sm" onClick={() => ts.setOpen(true)}>
<Settings2 className="w-4 h-4" />
</Button>
</div>
</div>
{/* 데이터 테이블 (플랫 리스트) */}
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
<div className="h-full overflow-auto">
<Table noWrapper style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "140px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "70px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = filteredFlatRows.map((r) => r.id);
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={(() => {
const allFilteredIds = filteredFlatRows.map((r) => r.id);
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
{FLAT_COLUMNS.map((col) => {
const isRight = ["qty", "ship_qty", "balance_qty", "unit_price", "amount"].includes(col.key);
return (
<TableHead key={col.key} className={cn("text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none", isRight && "text-right")}>
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
<span className="truncate">{col.label}</span>
{sortState?.key === col.key && (
sortState.direction === "asc"
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
)}
</div>
{(columnUniqueValues[col.key] || []).length > 0 && (
<HeaderFilterPopover
colKey={col.key} colLabel={col.label}
uniqueValues={columnUniqueValues[col.key] || []}
filterValues={headerFilters[col.key] || new Set<string>()}
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
/>
)}
</div>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : filteredFlatRows.length === 0 ? (
<TableRow>
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<ClipboardList className="h-8 w-8 opacity-30" />
<span className="text-sm"> </span>
</div>
</TableCell>
</TableRow>
) : (
filteredFlatRows.map((row) => {
const isChecked = checkedIds.includes(row.id);
return (
<TableRow
key={row.id}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
)}
onClick={() => {
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
onDoubleClick={() => openEditModal(row.order_no)}
>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="font-mono whitespace-nowrap text-[13px]">{row.order_no}</TableCell>
<TableCell className="text-[13px] truncate max-w-[140px]"><span className="block truncate">{row.partner_id || ""}</span></TableCell>
<TableCell className="whitespace-nowrap text-[13px]">{row.order_date || ""}</TableCell>
<TableCell className="font-mono text-[13px]">{row.part_code}</TableCell>
<TableCell className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px]">{row.unit}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.qty ? Number(row.qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{row.ship_qty ? Number(row.ship_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.memo || ""}</span></TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
{/* 수주 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="flex flex-col gap-0 p-0 max-w-5xl w-full" style={{ maxHeight: "85vh" }}>
<DialogHeader className="shrink-0 border-b border-border px-6 py-5">
<DialogTitle className="text-[17px] font-bold">
{isEditMode ? "수주 수정" : "수주 등록"}
</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{isEditMode ? "수주 정보를 수정합니다." : "새로운 수주를 등록합니다."}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-6">
{/* 기본 정보 섹션 */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-[13px] font-bold text-muted-foreground">
<div className="flex-1 h-px bg-border" />
</div>
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Input
value={masterForm.order_no || ""}
onChange={(e) => setMasterForm((p) => ({ ...p, order_no: e.target.value }))}
placeholder="수주번호" className="h-9" disabled={isEditMode}
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Input
type="date"
value={masterForm.order_date || ""}
onChange={(e) => setMasterForm((p) => ({ ...p, order_date: e.target.value }))}
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Select value={masterForm.sell_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, sell_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["sell_mode"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={masterForm.input_mode || ""} onValueChange={(v) => {
setMasterForm((p) => {
const next = { ...p, input_mode: v };
// 입력방식 변경 시 거래처 관련 값 초기화
delete next.partner_id;
delete next.delivery_partner_id;
delete next.delivery_address;
return next;
});
setDeliveryOptions([]);
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["input_mode"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={masterForm.price_mode || ""} onValueChange={(v) => { setMasterForm((p) => ({ ...p, price_mode: v })); recalcPrices(v, masterForm.partner_id || ""); }}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["price_mode"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox checked={allowPriceEdit} onCheckedChange={(v) => setAllowPriceEdit(!!v)} />
<span className="text-sm font-medium"> </span>
</label>
</div>
</div>
</div>
{/* 거래처 우선 조건부 레이어 */}
{isSupplierFirst && (
<div className="rounded-lg border border-dashed border-border bg-muted/50 p-4 space-y-3">
<div className="flex items-center gap-1.5 text-xs font-semibold text-primary">
<ClipboardList className="w-3.5 h-3.5" />
</div>
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select
value={masterForm.partner_id || ""}
onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); recalcPrices(masterForm.price_mode || "", v); }}
>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={masterForm.manager_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, manager_id: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="담당자 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["manager_id"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
{deliveryOptions.length > 0 ? (
<Select
value={masterForm.delivery_partner_id || ""}
onValueChange={(v) => {
setMasterForm((p) => ({ ...p, delivery_partner_id: v }));
const found = deliveryOptions.find((o) => o.code === v);
if (found) {
const addr = found.label.match(/\((.+)\)$/)?.[1] || "";
if (addr) setMasterForm((p) => ({ ...p, delivery_partner_id: v, delivery_address: addr }));
}
}}
>
<SelectTrigger className="h-9"><SelectValue placeholder="납품처 선택" /></SelectTrigger>
<SelectContent>
{deliveryOptions.map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={masterForm.delivery_partner_id || ""}
onChange={(e) => setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))}
placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택해주세요"}
className="h-9" disabled={!masterForm.partner_id}
/>
)}
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input
value={masterForm.delivery_address || ""}
onChange={(e) => setMasterForm((p) => ({ ...p, delivery_address: e.target.value }))}
placeholder="납품장소" className="h-9"
/>
</div>
</div>
</div>
)}
{/* 해외판매 조건부 레이어 */}
{isOverseas && (
<div className="rounded-lg border border-dashed border-border bg-muted/50 p-4 space-y-3">
<div className="flex items-center gap-1.5 text-xs font-semibold text-primary">
</div>
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={masterForm.incoterms || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, incoterms: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["incoterms"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={masterForm.payment_term || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, payment_term: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["payment_term"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={masterForm.currency || "KRW"} onValueChange={(v) => setMasterForm((p) => ({ ...p, currency: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="통화 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="KRW">KRW ()</SelectItem>
<SelectItem value="USD">USD ()</SelectItem>
<SelectItem value="EUR">EUR ()</SelectItem>
<SelectItem value="JPY">JPY ()</SelectItem>
<SelectItem value="CNY">CNY ()</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={masterForm.port_of_loading || ""} onChange={(e) => setMasterForm((p) => ({ ...p, port_of_loading: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Input value={masterForm.port_of_discharge || ""} onChange={(e) => setMasterForm((p) => ({ ...p, port_of_discharge: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">HS Code</Label>
<Input value={masterForm.hs_code || ""} onChange={(e) => setMasterForm((p) => ({ ...p, hs_code: e.target.value }))} className="h-9 font-mono text-xs" />
</div>
</div>
</div>
)}
{/* 품목 내역 리피터 */}
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-[13px] font-bold text-muted-foreground">
<Package className="w-4 h-4" />
<span className="inline-flex items-center rounded-full border border-primary/15 bg-primary/5 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
{detailRows.length}
</span>
</div>
<Button size="sm" onClick={() => {
setItemSelectedMap(new Map());
setItemPage(1);
setItemPageInput("1");
setItemSearchKeyword("");
setItemSelectOpen(true);
searchItems(1);
}}>
<Plus className="w-4 h-4" />
</Button>
</div>
{detailRows.length === 0 ? (
<div className="flex flex-col items-center gap-2 rounded-lg border border-dashed border-border py-8 text-muted-foreground">
<Package className="h-8 w-8 opacity-40" />
<span className="text-sm"> . .</span>
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-border">
<Table noWrapper className="min-w-max w-full">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
<TableHead className="w-28 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">/</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
<TableCell className="text-center font-mono text-[13px] text-muted-foreground/50">{idx + 1}</TableCell>
<TableCell className="max-w-[112px]">
<span className="block truncate font-mono text-[13px]" title={row.part_code}>{row.part_code}</span>
</TableCell>
<TableCell className="max-w-[128px]">
<span className="block truncate text-[13px]" title={row.part_name}>{row.part_name}</span>
</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{row.material}</TableCell>
<TableCell>
<Input
value={row.packing_material || ""}
onChange={(e) => updateDetailRow(idx, "packing_material", e.target.value)}
placeholder="포장재"
className="h-8 text-xs"
/>
</TableCell>
<TableCell>
<Select value={row.unit || ""} onValueChange={(v) => updateDetailRow(idx, "unit", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="단위" /></SelectTrigger>
<SelectContent>
{(categoryOptions["item_unit"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input
type="number"
min="1"
value={row.qty || "1"}
onChange={(e) => updateDetailRow(idx, "qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-16"
/>
</TableCell>
<TableCell>
<Input
type="number"
min="0"
value={row.pack_qty || "0"}
onChange={(e) => updateDetailRow(idx, "pack_qty", e.target.value)}
className="h-8 text-xs text-right font-mono w-16"
/>
</TableCell>
<TableCell>
<Input
value={formatNumber(row.unit_price || "")}
onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))}
readOnly={!allowPriceEdit}
className={cn("h-8 text-xs text-right font-mono w-20", !allowPriceEdit && "bg-muted cursor-not-allowed")}
/>
</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">
{row.amount ? Number(row.amount).toLocaleString() : "0"}
</TableCell>
<TableCell>
<Input
type="date"
value={row.due_date || ""}
onChange={(e) => updateDetailRow(idx, "due_date", e.target.value)}
className="h-8 text-xs"
/>
</TableCell>
<TableCell className="text-center">
<div className="flex items-center gap-1 justify-center">
<Button
variant="default" size="sm"
className="h-7 px-2 text-[11px] bg-emerald-500 hover:bg-emerald-600 text-white"
onClick={() => {
setDetailRows((prev) => {
const next = [...prev];
const newRow = { ...next[idx], _id: `split_${Date.now()}_${Math.random()}` };
next.splice(idx + 1, 0, newRow);
return next;
});
toast.success("행이 분할되었어요");
}}
>
</Button>
<Button
variant="ghost" size="sm"
className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5"
onClick={() => removeDetailRow(idx)}
>
<X className="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
{/* 비고 */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-[13px] font-bold text-muted-foreground">
<div className="flex-1 h-px bg-border" />
</div>
<textarea
value={masterForm.memo || ""}
onChange={(e) => setMasterForm((p) => ({ ...p, memo: e.target.value }))}
placeholder="특이사항이나 메모를 입력해주세요"
rows={3}
className="w-full rounded-md border border-input bg-muted px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-primary focus:outline-none focus:ring-[3px] focus:ring-ring/25 resize-vertical min-h-[80px]"
/>
</div>
</div>
<DialogFooter className="shrink-0 border-t border-border px-6 py-4">
<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>
</DialogFooter>
{/* 품목 선택 중첩 모달 */}
<Dialog open={itemSelectOpen} onOpenChange={setItemSelectOpen}>
<DialogContent className="max-w-3xl" style={{ maxHeight: "70vh" }} onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input
placeholder="품명/품목코드 검색"
value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()}
className="h-9 flex-1"
/>
<Select value={itemSearchDivision} onValueChange={setItemSearchDivision}>
<SelectTrigger className="h-9 w-[130px]"><SelectValue placeholder="관리품목" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["item_division"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" onClick={triggerNewSearch} disabled={itemSearchLoading} className="h-9">
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
</div>
<div className="overflow-auto rounded-lg border border-border" style={{ maxHeight: "320px" }}>
<Table noWrapper>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-10 text-center">
<input
type="checkbox"
checked={itemSearchResults.length > 0 && itemSearchResults.every((i) => itemSelectedMap.has(i.id))}
onChange={(e) => {
setItemSelectedMap((prev) => {
const next = new Map(prev);
if (e.target.checked) itemSearchResults.forEach((i) => next.set(i.id, i));
else itemSearchResults.forEach((i) => next.delete(i.id));
return next;
});
}}
/>
</TableHead>
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-24 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-16 text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{itemSearchResults.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground"> </TableCell>
</TableRow>
) : itemSearchResults.map((item) => (
<TableRow
key={item.id}
className={cn("cursor-pointer", itemSelectedMap.has(item.id) && "bg-primary/5")}
onClick={() => setItemSelectedMap((prev) => {
const next = new Map(prev);
if (next.has(item.id)) next.delete(item.id); else next.set(item.id, item);
return next;
})}
>
<TableCell className="text-center">
<input type="checkbox" checked={itemSelectedMap.has(item.id)} readOnly />
</TableCell>
<TableCell className="max-w-[128px]">
<span className="block truncate text-[13px]" title={item.item_number}>{item.item_number}</span>
</TableCell>
<TableCell className="max-w-[150px]">
<span className="block truncate text-sm" title={item.item_name}>{item.item_name}</span>
</TableCell>
<TableCell className="text-[13px]">{item.size}</TableCell>
<TableCell className="text-[13px]">
{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}
</TableCell>
<TableCell className="text-[13px]">
{categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between w-full border-t pt-2">
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">:</span>
<input
type="number" min={1} max={200} value={itemPageSize}
onChange={(e) => {
const v = Math.min(200, Math.max(1, Number(e.target.value) || 20));
setItemPageSize(v);
setItemPage(1);
setItemPageInput("1");
searchItems(1, v);
}}
className="h-7 w-14 rounded-md border px-1 text-center text-xs"
/>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" className="h-7 w-7 p-0"
onClick={() => handleItemPageChange(1)} disabled={itemPage === 1 || itemSearchLoading}>
<ChevronsLeft className="h-3 w-3" />
</Button>
<Button variant="outline" size="sm" className="h-7 w-7 p-0"
onClick={() => handleItemPageChange(itemPage - 1)} disabled={itemPage === 1 || itemSearchLoading}>
<ChevronLeft className="h-3 w-3" />
</Button>
<input
type="text" inputMode="numeric" value={itemPageInput}
onChange={(e) => setItemPageInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { commitItemPageInput(); (e.target as HTMLInputElement).blur(); } }}
onBlur={commitItemPageInput}
onFocus={(e) => e.target.select()}
className="h-7 w-10 rounded-md border px-1 text-center text-xs"
/>
<span className="text-xs text-muted-foreground">/ {itemTotalPages || 1}</span>
<Button variant="outline" size="sm" className="h-7 w-7 p-0"
onClick={() => handleItemPageChange(itemPage + 1)} disabled={itemPage >= itemTotalPages || itemSearchLoading}>
<ChevronRight className="h-3 w-3" />
</Button>
<Button variant="outline" size="sm" className="h-7 w-7 p-0"
onClick={() => handleItemPageChange(itemTotalPages)} disabled={itemPage >= itemTotalPages || itemSearchLoading}>
<ChevronsRight className="h-3 w-3" />
</Button>
</div>
<span className="text-xs text-muted-foreground"> {itemTotal}</span>
</div>
<DialogFooter>
<div className="flex w-full items-center justify-between">
<span className="text-sm text-muted-foreground">{itemSelectedMap.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => { setItemSelectedMap(new Map()); setItemSelectOpen(false); }}></Button>
<Button onClick={addSelectedItemsToDetail} disabled={itemSelectedMap.size === 0}>
<Plus className="w-4 h-4 mr-1" />{itemSelectedMap.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
{/* 출하계획 동시 등록 모달 */}
<ShippingPlanBatchModal
open={shippingPlanOpen}
onOpenChange={setShippingPlanOpen}
selectedDetailIds={checkedIds}
onSuccess={fetchOrders}
/>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={DETAIL_TABLE}
userId={user?.userId}
onSuccess={() => fetchOrders()}
/>
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
{/* 공통 확인 다이얼로그 */}
{ConfirmDialogComponent}
</div>
);
}