Files
pipeline/frontend/app/(main)/COMPANY_7/outsourcing/subcontractor/page.tsx
T
kjs 972a0143ad refactor: Integrate ImageUpload component for mold image handling
- Replaced manual image upload logic with the ImageUpload component for better management of mold images.
- Updated image source handling to ensure proper display of images based on their URL format.
- Enhanced error handling for image display to improve user experience.

These changes aim to streamline the image upload process and enhance the overall functionality of the mold information page across multiple companies.
2026-04-11 14:50:18 +09:00

1419 lines
70 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 { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Wrench, Package, Search, X, RefreshCw, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
import { exportToExcel } from "@/lib/utils/excelExport";
import { validateField, validateForm, formatField } from "@/lib/utils/validation";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
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 SUBCONTRACTOR_TABLE = "subcontractor_mng";
const MAPPING_TABLE = "subcontractor_item_mapping";
const PRICE_TABLE = "subcontractor_item_prices";
const GRID_COLUMNS_CONFIG = [
{ key: "subcontractor_code", label: "외주사코드" },
{ key: "subcontractor_name", label: "외주사명" },
{ key: "division", label: "업체 유형" },
{ key: "status", label: "상태" },
{ key: "contact_person", label: "담당자" },
{ key: "contact_phone", label: "담당자 전화번호" },
{ key: "contact_email", label: "담당자 이메일" },
{ key: "email", label: "이메일" },
{ key: "business_number", label: "사업자번호" },
{ key: "address", label: "주소" },
{ key: "phone", label: "전화번호" },
{ key: "fax", label: "팩스" },
{ key: "representative", label: "대표자명" },
{ key: "grade", label: "등급" },
{ key: "process_type", label: "공정 유형" },
{ key: "payment_terms", label: "결제 조건" },
{ key: "remarks", label: "비고" },
{ key: "writer", label: "작성자" },
{ key: "created_date", label: "생성일시" },
{ key: "updated_date", label: "수정일시" },
];
export default function SubcontractorManagementPage() {
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 검색 필터 (DynamicSearchFilter)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 좌측: 외주업체 목록
const [subcontractors, setSubcontractors] = useState<any[]>([]);
const [subcontractorLoading, setSubcontractorLoading] = useState(false);
const [subcontractorCount, setSubcontractorCount] = useState(0);
const [selectedSubcontractorId, setSelectedSubcontractorId] = useState<string | null>(null);
// 우측: 품목 단가
const [priceItems, setPriceItems] = useState<any[]>([]);
const [priceLoading, setPriceLoading] = useState(false);
// 품목 편집 데이터
const [editItemData, setEditItemData] = useState<any>(null);
// 외주업체 등록/수정 모달
const [subcontractorModalOpen, setSubcontractorModalOpen] = useState(false);
const [subcontractorEditMode, setSubcontractorEditMode] = useState(false);
const [subcontractorForm, setSubcontractorForm] = useState<Record<string, any>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
// 채번 시스템
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
// 품목 추가 모달 (1단계: 검색/선택)
const [itemSelectOpen, setItemSelectOpen] = useState(false);
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
const [itemSearchLoading, setItemSearchLoading] = useState(false);
const [itemCheckedIds, setItemCheckedIds] = useState<Set<string>>(new Set());
// 품목 상세 입력 모달 (2단계)
const [itemDetailOpen, setItemDetailOpen] = useState(false);
const [selectedItemsForDetail, setSelectedItemsForDetail] = useState<any[]>([]);
const [itemMappings, setItemMappings] = useState<Record<string, Array<{
_id: string; subcontractor_item_code: string; subcontractor_item_name: string;
}>>>({});
const [itemPrices, setItemPrices] = useState<Record<string, Array<{
_id: string; start_date: string; end_date: string; currency_code: string;
base_price_type: string; base_price: string; discount_type: string;
discount_value: string; rounding_type: string; rounding_unit_value: string;
calculated_price: string;
}>>>({});
const [priceCategoryOptions, setPriceCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
const [excelDetecting, setExcelDetecting] = useState(false);
// 테이블 설정
const DEFAULT_VISIBLE_KEYS = ["subcontractor_code", "subcontractor_name", "division", "status", "contact_person", "contact_phone"];
const ts = useTableSettings("c16-subcontractor-v2", SUBCONTRACTOR_TABLE, GRID_COLUMNS_CONFIG, DEFAULT_VISIBLE_KEYS);
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 카테고리 로드
useEffect(() => {
const load = async () => {
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;
};
for (const col of ["division", "status"]) {
try {
const res = await apiClient.get(`/table-categories/${SUBCONTRACTOR_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
// item_info의 division/unit/material 카테고리도 로드 (품목 검색 시 외주관리 코드 조회용)
for (const col of ["division", "unit", "material"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
const priceOpts: Record<string, { code: string; label: string }[]> = {};
for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) {
try {
const res = await apiClient.get(`/table-categories/${PRICE_TABLE}/${col}/values`);
if (res.data?.success) priceOpts[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setPriceCategoryOptions(priceOpts);
};
load();
}, []);
const getCategoryLabel = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 셀 렌더링 헬퍼
const renderCellValue = (row: any, key: string) => {
const val = key === "division" ? (row.division_label || row.division)
: key === "status" ? (row.status_label || row.status)
: row[key];
if (!val) return "-";
if (key === "subcontractor_code") return <span className="text-primary">{val}</span>;
if (key === "division") return <Badge variant="secondary" className="text-[10px] px-1.5 py-0">{val}</Badge>;
if (key === "status") return <Badge variant="outline" className="text-[10px] px-1.5 py-0">{val}</Badge>;
if (key === "subcontractor_name") return <span className="font-medium">{val}</span>;
return val;
};
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
return ts.visibleColumns.map((col) => ({
key: col.key,
label: col.label,
render: (value: any, row: any) => renderCellValue(row, col.key),
}));
}, [ts.visibleColumns, categoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps
// 외주업체 목록 조회
const fetchSubcontractors = useCallback(async () => {
setSubcontractorLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
const data = raw.map((r: any) => ({
...r,
division_label: resolve("division", r.division),
status_label: resolve("status", r.status),
}));
setSubcontractors(data);
setSubcontractorCount(res.data?.data?.total || raw.length);
} catch (err) {
toast.error("외주업체 목록을 불러오지 못했어요");
} finally {
setSubcontractorLoading(false);
}
}, [searchFilters, categoryOptions]);
useEffect(() => { fetchSubcontractors(); }, [fetchSubcontractors]);
// 선택된 외주업체
const selectedSubcontractor = subcontractors.find((c) => c.id === selectedSubcontractorId);
// 선택된 외주업체의 품목 단가 조회
useEffect(() => {
if (!selectedSubcontractor?.subcontractor_code) { setPriceItems([]); return; }
const fetchItems = async () => {
setPriceLoading(true);
try {
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code },
]},
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
const itemIds = [...new Set(mappings.map((r: any) => r.item_id).filter(Boolean))];
let itemMap: Record<string, any> = {};
if (itemIds.length > 0) {
try {
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: itemIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemIds }] },
autoFilter: true,
});
for (const item of (itemRes.data?.data?.data || itemRes.data?.data?.rows || [])) {
itemMap[item.item_number] = item;
}
} catch { /* skip */ }
}
let priceMap: Record<string, any> = {};
if (mappings.length > 0) {
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code },
]},
autoFilter: true,
});
const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
for (const p of prices) {
const key = p.item_id;
if (!priceMap[key] || (p.start_date && (!priceMap[key].start_date || p.start_date > priceMap[key].start_date))) {
priceMap[key] = p;
}
}
} catch { /* skip */ }
}
const priceResolve = (col: string, code: string) => {
if (!code) return "";
return priceCategoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
setPriceItems(mappings.map((m: any) => {
const itemInfo = itemMap[m.item_id] || {};
const price = priceMap[m.item_id] || {};
return {
...m,
item_number: m.item_id,
item_name: itemInfo.item_name || "",
base_price_type: priceResolve("base_price_type", price.base_price_type || m.base_price_type || ""),
base_price: price.base_price || m.base_price || "",
discount_type: priceResolve("discount_type", price.discount_type || m.discount_type || ""),
discount_value: price.discount_value || m.discount_value || "",
rounding_type: priceResolve("rounding_unit_value", price.rounding_type || m.rounding_type || ""),
calculated_price: price.calculated_price || m.calculated_price || "",
currency_code: priceResolve("currency_code", price.currency_code || m.currency_code || ""),
};
}));
} catch (err) {
// 품목 조회 실패 시 무시
} finally {
setPriceLoading(false);
}
};
fetchItems();
}, [selectedSubcontractor?.subcontractor_code, priceCategoryOptions]);
// 외주업체 등록 모달 열기
const openSubcontractorRegister = async () => {
setSubcontractorForm({});
setFormErrors({});
setSubcontractorEditMode(false);
setNumberingRuleId(null);
setPreviewCode(null);
setSubcontractorModalOpen(true);
// 채번 규칙 조회 (subcontractor_mng.subcontractor_code)
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/subcontractor_mng/subcontractor_code`);
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
setNumberingRuleId(ruleId);
const previewRes = await previewNumberingCode(ruleId);
if (previewRes.success && previewRes.data?.generatedCode) {
setPreviewCode(previewRes.data.generatedCode);
}
}
} catch {
// 채번 규칙 없으면 무시
}
};
const openSubcontractorEdit = () => {
if (!selectedSubcontractor) return;
setSubcontractorForm({ ...selectedSubcontractor });
setFormErrors({});
setSubcontractorEditMode(true);
setSubcontractorModalOpen(true);
};
const handleFormChange = (field: string, value: string) => {
const formatted = formatField(field, value);
setSubcontractorForm((prev) => ({ ...prev, [field]: formatted }));
const error = validateField(field, formatted);
setFormErrors((prev) => {
const next = { ...prev };
if (error) next[field] = error; else delete next[field];
return next;
});
};
const handleSubcontractorSave = async () => {
if (!subcontractorForm.subcontractor_name) { toast.error("외주업체명은 필수예요"); return; }
if (!subcontractorForm.status) { toast.error("상태는 필수예요"); return; }
const errors = validateForm(subcontractorForm, ["contact_phone", "email", "business_number"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) {
toast.error("입력 형식을 확인해주세요.");
return;
}
setSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, division_label, status_label, ...fields } = subcontractorForm;
if (subcontractorEditMode && id) {
await apiClient.put(`/table-management/tables/${SUBCONTRACTOR_TABLE}/edit`, {
originalData: { id }, updatedData: fields,
});
toast.success("수정되었어요");
} else {
// 채번 규칙이 있으면 allocate로 실제 코드 할당
let allocatedCode: string | undefined;
if (numberingRuleId) {
const allocRes = await allocateNumberingCode(numberingRuleId);
if (allocRes.success && allocRes.data?.generatedCode) {
allocatedCode = allocRes.data.generatedCode;
} else {
toast.error("채번 코드 할당에 실패했습니다.");
return;
}
}
const saveFields = allocatedCode ? { ...fields, subcontractor_code: allocatedCode } : fields;
await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/add`, { id: crypto.randomUUID(), ...saveFields });
toast.success("등록되었어요");
}
setSubcontractorModalOpen(false);
fetchSubcontractors();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했어요");
} finally {
setSaving(false);
}
};
// 외주업체 삭제
const handleSubcontractorDelete = async () => {
if (!selectedSubcontractorId) return;
const ok = await confirm("외주업체를 삭제할까요?", {
description: "관련된 품목 매핑, 단가 정보도 함께 삭제돼요",
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${SUBCONTRACTOR_TABLE}/delete`, {
data: [{ id: selectedSubcontractorId }],
});
toast.success("삭제되었어요");
setSelectedSubcontractorId(null);
fetchSubcontractors();
} catch { toast.error("삭제에 실패했어요"); }
};
// 품목 검색
const searchItems = async () => {
setItemSearchLoading(true);
try {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
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 allItems = res.data?.data?.data || res.data?.data?.rows || [];
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
const outsourcingCode = categoryOptions["item_division"]?.find((o) => o.label === "외주관리")?.code;
setItemSearchResults(allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
if (!outsourcingCode) return true; // 카테고리 미로드 시 전체 표시
const div = item.division || "";
return div.includes(outsourcingCode);
}));
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
// 품목 선택 완료 → 상세 입력 모달로 전환
const goToItemDetail = () => {
const selected = itemSearchResults.filter((i) => itemCheckedIds.has(i.id));
if (selected.length === 0) { toast.error("품목을 선택해주세요"); return; }
setSelectedItemsForDetail(selected);
const mappings: typeof itemMappings = {};
const prices: typeof itemPrices = {};
for (const item of selected) {
const key = item.item_number || item.id;
mappings[key] = [];
prices[key] = [{
_id: `p_${Date.now()}_${Math.random()}`,
start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: item.standard_price || item.selling_price || "",
discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "",
calculated_price: item.standard_price || item.selling_price || "",
}];
}
setItemMappings(mappings);
setItemPrices(prices);
setEditItemData(null);
setItemSelectOpen(false);
setItemDetailOpen(true);
};
const addMappingRow = (itemKey: string) => {
setItemMappings((prev) => ({
...prev,
[itemKey]: [...(prev[itemKey] || []), { _id: `m_${Date.now()}_${Math.random()}`, subcontractor_item_code: "", subcontractor_item_name: "" }],
}));
};
const removeMappingRow = (itemKey: string, rowId: string) => {
setItemMappings((prev) => ({
...prev,
[itemKey]: (prev[itemKey] || []).filter((r) => r._id !== rowId),
}));
};
const updateMappingRow = (itemKey: string, rowId: string, field: string, value: string) => {
setItemMappings((prev) => ({
...prev,
[itemKey]: (prev[itemKey] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r),
}));
};
const addPriceRow = (itemKey: string) => {
setItemPrices((prev) => ({
...prev,
[itemKey]: [...(prev[itemKey] || []), {
_id: `p_${Date.now()}_${Math.random()}`,
start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "",
discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "",
calculated_price: "",
}],
}));
};
const removePriceRow = (itemKey: string, rowId: string) => {
setItemPrices((prev) => ({
...prev,
[itemKey]: (prev[itemKey] || []).filter((r) => r._id !== rowId),
}));
};
const updatePriceRow = (itemKey: string, rowId: string, field: string, value: string) => {
setItemPrices((prev) => ({
...prev,
[itemKey]: (prev[itemKey] || []).map((r) => {
if (r._id !== rowId) return r;
const updated = { ...r, [field]: value };
if (["base_price", "discount_type", "discount_value"].includes(field)) {
const bp = Number(updated.base_price) || 0;
const dv = Number(updated.discount_value) || 0;
const dt = updated.discount_type;
let calc = bp;
if (dt === "CAT_MLAMBEC8_URQA") calc = bp * (1 - dv / 100);
else if (dt === "CAT_MLAMBLFM_JTLO") calc = bp - dv;
updated.calculated_price = String(Math.round(calc));
}
return updated;
}),
}));
};
// 우측 품목 편집 열기 — 해당 item_number의 모든 매핑+단가를 로드
const openEditItem = async (row: any) => {
const itemKey = row.item_number || row.item_id;
let itemInfo: any = { item_number: itemKey, item_name: row.item_name || "", size: "", unit: "" };
try {
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "equals", value: itemKey }] },
autoFilter: true,
});
const found = (res.data?.data?.data || res.data?.data?.rows || [])[0];
if (found) itemInfo = found;
} catch { /* skip */ }
// 같은 item_number를 가진 모든 priceItems 행에서 매핑 정보 추출
const allRowsForItem = priceItems.filter((p: any) => (p.item_number || p.item_id) === itemKey);
const allMappingIds = allRowsForItem.map((r: any) => r.id).filter(Boolean);
const mappingRows = allRowsForItem
.filter((r: any) => r.subcontractor_item_code || r.subcontractor_item_name)
.map((r: any) => ({
_id: `m_existing_${r.id}`,
subcontractor_item_code: r.subcontractor_item_code || "",
subcontractor_item_name: r.subcontractor_item_name || "",
}));
// 서버에서 이 item+subcontractor의 모든 단가를 raw 코드로 가져오기
let priceRows: Array<{
_id: string; start_date: string; end_date: string; currency_code: string;
base_price_type: string; base_price: string; discount_type: string;
discount_value: string; rounding_type: string; rounding_unit_value: string;
calculated_price: string;
}> = [];
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor!.subcontractor_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]},
autoFilter: true,
});
const rawPrices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
priceRows = rawPrices.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date || "",
end_date: p.end_date || "",
currency_code: p.currency_code || "CAT_MLAMDKVN_PZJI",
base_price_type: p.base_price_type || "CAT_MLAMFGFT_4RZW",
base_price: p.base_price ? String(p.base_price) : "",
discount_type: p.discount_type || "",
discount_value: p.discount_value ? String(p.discount_value) : "",
rounding_type: p.rounding_type || "",
rounding_unit_value: p.rounding_unit_value || "",
calculated_price: p.calculated_price ? String(p.calculated_price) : "",
}));
} catch { /* skip */ }
if (priceRows.length === 0) {
priceRows.push({
_id: `p_${Date.now()}`, start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", discount_type: "", discount_value: "",
rounding_type: "", rounding_unit_value: "", calculated_price: "",
});
}
setSelectedItemsForDetail([itemInfo]);
setItemMappings({ [itemKey]: mappingRows });
setItemPrices({ [itemKey]: priceRows });
// editItemData에 원본 매핑 ID 목록 저장 (삭제 시 사용)
setEditItemData({ ...row, _allMappingIds: allMappingIds });
setItemDetailOpen(true);
};
const handleItemDetailSave = async () => {
if (!selectedSubcontractor) return;
const isEditingExisting = !!editItemData;
setSaving(true);
try {
for (const item of selectedItemsForDetail) {
const itemKey = item.item_number || item.id;
const mappingRows = itemMappings[itemKey] || [];
if (isEditingExisting && editItemData?.id) {
// 1) 기존 매핑 모두 삭제
const allMappingIds: string[] = editItemData._allMappingIds || [editItemData.id];
if (allMappingIds.length > 0) {
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: allMappingIds.map((mid: string) => ({ id: mid })),
});
}
// 2) 기존 단가 모두 삭제 (subcontractor_id + item_id 기준)
try {
const existingPrices = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${PRICE_TABLE}/delete`, {
data: existing.map((p: any) => ({ id: p.id })),
});
}
} catch { /* skip */ }
// 3) 모든 매핑 재삽입
let firstMappingId: string | null = null;
for (let mi = 0; mi < mappingRows.length; mi++) {
const newId = crypto.randomUUID();
if (mi === 0) firstMappingId = newId;
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
id: newId,
subcontractor_id: selectedSubcontractor.subcontractor_code,
item_id: itemKey,
subcontractor_item_code: mappingRows[mi].subcontractor_item_code || "",
subcontractor_item_name: mappingRows[mi].subcontractor_item_name || "",
});
}
// 매핑이 비어있으면 빈 매핑 1개 생성 (item_id 연결 유지)
if (mappingRows.length === 0) {
firstMappingId = crypto.randomUUID();
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
id: firstMappingId,
subcontractor_id: selectedSubcontractor.subcontractor_code,
item_id: itemKey,
subcontractor_item_code: "",
subcontractor_item_name: "",
});
}
// 4) 모든 단가 재삽입
const filteredPriceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
);
for (const price of filteredPriceRows) {
await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, {
id: crypto.randomUUID(),
mapping_id: firstMappingId || "",
subcontractor_id: selectedSubcontractor.subcontractor_code,
item_id: itemKey,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
}
} else {
let mappingId: string | null = null;
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
id: crypto.randomUUID(),
subcontractor_id: selectedSubcontractor.subcontractor_code, item_id: itemKey,
subcontractor_item_code: mappingRows[0]?.subcontractor_item_code || "",
subcontractor_item_name: mappingRows[0]?.subcontractor_item_name || "",
});
mappingId = mappingRes.data?.data?.id || null;
for (let mi = 1; mi < mappingRows.length; mi++) {
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
id: crypto.randomUUID(),
subcontractor_id: selectedSubcontractor.subcontractor_code, item_id: itemKey,
subcontractor_item_code: mappingRows[mi].subcontractor_item_code || "",
subcontractor_item_name: mappingRows[mi].subcontractor_item_name || "",
});
}
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, {
id: crypto.randomUUID(),
mapping_id: mappingId || "", subcontractor_id: selectedSubcontractor.subcontractor_code, item_id: itemKey,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
}
}
}
toast.success(isEditingExisting ? "수정되었어요" : `${selectedItemsForDetail.length}개 품목이 추가되었어요`);
setItemDetailOpen(false);
setEditItemData(null);
setItemCheckedIds(new Set());
const cid = selectedSubcontractorId;
setSelectedSubcontractorId(null);
setTimeout(() => setSelectedSubcontractorId(cid), 50);
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했어요");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (subcontractors.length === 0) return;
toast.loading("엑셀 데이터 준비 중...", { id: "excel-dl" });
try {
const allMappings: any[] = [];
const subCodes = subcontractors.map((c) => c.subcontractor_code).filter(Boolean);
if (subCodes.length > 0) {
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 5000, autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
const itemIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))];
let itemMap: Record<string, any> = {};
if (itemIds.length > 0) {
try {
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: itemIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemIds }] },
autoFilter: true,
});
for (const item of (itemRes.data?.data?.data || itemRes.data?.data?.rows || [])) {
itemMap[item.item_number] = item;
}
} catch { /* skip */ }
}
for (const m of mappings) {
const itemInfo = itemMap[m.item_id] || {};
allMappings.push({ ...m, item_name: itemInfo.item_name || "", item_spec: itemInfo.size || "" });
}
}
const rows: Record<string, any>[] = [];
for (const c of subcontractors) {
const subMappings = allMappings.filter((m) => m.subcontractor_id === c.subcontractor_code);
if (subMappings.length === 0) {
rows.push({
외주업체코드: c.subcontractor_code, 외주업체명: c.subcontractor_name,
거래유형: getCategoryLabel("division", c.division),
담당자: c.contact_person, 전화번호: c.contact_phone,
사업자번호: c.business_number, 이메일: c.email,
상태: getCategoryLabel("status", c.status),
: "", : "", : "",
: "", : "",
: "", : "", : "", : "", : "",
});
} else {
for (const m of subMappings) {
rows.push({
외주업체코드: c.subcontractor_code, 외주업체명: c.subcontractor_name,
거래유형: getCategoryLabel("division", c.division),
담당자: c.contact_person, 전화번호: c.contact_phone,
사업자번호: c.business_number, 이메일: c.email,
상태: getCategoryLabel("status", c.status),
품목코드: m.item_id || "", 품명: m.item_name || "", 규격: m.item_spec || "",
외주품번: m.subcontractor_item_code || "", 외주품명: m.subcontractor_item_name || "",
기준가: m.base_price || "", 할인유형: m.discount_type || "", 할인값: m.discount_value || "",
단가: m.calculated_price || "", 통화: m.currency_code || "",
});
}
}
}
await exportToExcel(rows, "외주업체관리.xlsx", "외주업체+품목");
toast.dismiss("excel-dl");
toast.success(`${rows.length}행 다운로드 완료`);
} catch (err) {
toast.dismiss("excel-dl");
toast.error("다운로드에 실패했어요");
}
};
// 셀렉트 렌더링
const renderSelect = (field: string, value: string, onChange: (v: string) => void, placeholder: string) => (
<Select value={value || ""} onValueChange={onChange}>
<SelectTrigger className="h-9 text-sm"><SelectValue placeholder={placeholder} /></SelectTrigger>
<SelectContent>
{(categoryOptions[field] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
);
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 shrink-0">
<span></span>
<span className="text-muted-foreground/50">/</span>
<span className="font-medium text-foreground"></span>
</nav>
{/* 검색 필터 바 */}
<DynamicSearchFilter
tableName={SUBCONTRACTOR_TABLE}
filterId="c16-subcontractor"
onFilterChange={setSearchFilters}
dataCount={subcontractorCount}
externalFilterConfig={ts.filterConfig}
extraActions={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={excelDetecting}
onClick={async () => {
setExcelDetecting(true);
try {
const result = await autoDetectMultiTableConfig(SUBCONTRACTOR_TABLE);
if (result.success && result.data) {
setExcelChainConfig(result.data);
setExcelUploadOpen(true);
} else {
toast.error(result.message || "테이블 구조 분석 실패");
}
} catch { toast.error("테이블 구조 분석 중 오류"); } finally { setExcelDetecting(false); }
}}
>
{excelDetecting
? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
: <FileSpreadsheet className="mr-1.5 h-4 w-4" />}
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="mr-1.5 h-4 w-4" />
</Button>
</div>
}
/>
{/* 마스터-디테일 분할 패널 */}
<ResizablePanelGroup direction="horizontal" className="flex-1 overflow-hidden rounded-lg">
{/* 좌측: 외주업체 목록 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex h-full flex-col rounded-lg border bg-card">
<div className="flex items-center justify-between border-b bg-muted/30 px-4 py-3 shrink-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold"> </span>
<Badge variant="outline" className="text-xs px-1.5 py-0">{subcontractorCount}</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" onClick={openSubcontractorRegister}>
<Plus className="h-3.5 w-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedSubcontractorId} onClick={openSubcontractorEdit}>
<Pencil className="h-3.5 w-3.5 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
disabled={!selectedSubcontractorId}
onClick={handleSubcontractorDelete}
className="text-destructive border-destructive/20 hover:bg-destructive/10"
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
</Button>
<Button variant="ghost" size="sm" onClick={fetchSubcontractors} disabled={subcontractorLoading}>
{subcontractorLoading
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <RefreshCw className="h-3.5 w-3.5" />}
</Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)}>
<Settings2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<EDataTable
columns={mainTableColumns}
data={ts.groupData(subcontractors)}
loading={subcontractorLoading}
emptyMessage="등록된 외주업체가 없어요"
selectedId={selectedSubcontractorId}
onSelect={(id) => setSelectedSubcontractorId(id)}
onRowDoubleClick={() => openSubcontractorEdit()}
showPagination={true}
draggableColumns={false}
columnOrderKey="c16-subcontractor-main"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 품목 정보 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex h-full flex-col rounded-lg border bg-card">
<div className="flex items-center justify-between border-b bg-muted/30 px-4 py-3 shrink-0">
<div className="flex items-center gap-2">
{selectedSubcontractor ? (
<>
<span className="text-sm font-semibold">{selectedSubcontractor.subcontractor_name}</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">{selectedSubcontractor.subcontractor_code}</Badge>
</>
) : (
<>
<Package className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
</>
)}
</div>
<Button
variant="outline"
size="sm"
disabled={!selectedSubcontractorId}
onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}
>
<Plus className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
<div className="flex-1 overflow-auto">
{!selectedSubcontractorId ? (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-3 rounded-lg border-2 border-dashed border-border/60 px-12 py-10 text-center">
<Wrench className="h-10 w-10 text-muted-foreground opacity-30" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
) : priceLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : priceItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Package className="h-12 w-12 mb-3 opacity-30" />
<p className="text-sm font-medium"> </p>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs font-bold text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs font-bold text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs font-bold text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(() => {
// item_number 기준 그룹화 (순서 유지)
const grouped: { itemNumber: string; rows: any[] }[] = [];
const groupMap = new Map<string, any[]>();
for (const item of priceItems) {
const key = item.item_number || item.item_id || item.id;
if (!groupMap.has(key)) {
const rows: any[] = [];
groupMap.set(key, rows);
grouped.push({ itemNumber: key, rows });
}
groupMap.get(key)!.push(item);
}
return grouped.map((group, gIdx) =>
group.rows.map((item, rowIdx) => (
<TableRow
key={item.id}
className={cn(
"cursor-pointer hover:bg-muted/50",
rowIdx === 0 && gIdx > 0 && "border-t-2 border-t-border/60"
)}
onDoubleClick={() => openEditItem(item)}
>
<TableCell className="text-[13px] text-primary">
{rowIdx === 0 ? item.item_number : ""}
</TableCell>
<TableCell className="text-[13px]">
{rowIdx === 0 ? item.item_name : ""}
</TableCell>
<TableCell className="text-[13px]">{item.subcontractor_item_code || "-"}</TableCell>
<TableCell className="text-[13px]">{item.subcontractor_item_name || "-"}</TableCell>
<TableCell className="text-[13px]">{item.base_price_type || "-"}</TableCell>
<TableCell className="text-[13px] text-right">{item.base_price ? Number(item.base_price).toLocaleString() : "-"}</TableCell>
<TableCell className="text-[13px]">{item.discount_type || "-"}</TableCell>
<TableCell className="text-[13px] text-right">{item.discount_value || "-"}</TableCell>
<TableCell className="text-[13px] text-right font-semibold">{item.calculated_price ? Number(item.calculated_price).toLocaleString() : "-"}</TableCell>
<TableCell className="text-[13px]">{item.currency_code || "-"}</TableCell>
</TableRow>
))
);
})()}
</TableBody>
</Table>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
{/* ========== 모달들 ========== */}
{/* 외주업체 등록/수정 모달 */}
<Dialog open={subcontractorModalOpen} onOpenChange={setSubcontractorModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[680px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{subcontractorEditMode ? "외주업체 수정" : "외주업체 등록"}</DialogTitle>
<DialogDescription>
{subcontractorEditMode ? "외주업체 정보를 수정해요." : "새로운 외주업체를 등록해요."}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Input
value={subcontractorEditMode ? (subcontractorForm.subcontractor_code || "") : (previewCode || subcontractorForm.subcontractor_code || "")}
onChange={(e) => {
if (!subcontractorEditMode && !numberingRuleId) {
setSubcontractorForm((p) => ({ ...p, subcontractor_code: e.target.value }));
}
}}
placeholder={numberingRuleId && !subcontractorEditMode ? "자동 생성돼요" : "외주업체 코드"}
className="h-9 text-sm"
readOnly={!!numberingRuleId && !subcontractorEditMode}
disabled={subcontractorEditMode}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Input
value={subcontractorForm.subcontractor_name || ""}
onChange={(e) => setSubcontractorForm((p) => ({ ...p, subcontractor_name: e.target.value }))}
placeholder="외주업체명"
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
{renderSelect("division", subcontractorForm.division, (v) => setSubcontractorForm((p) => ({ ...p, division: v })), "거래 유형")}
</div>
<div className="space-y-1.5">
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
{renderSelect("status", subcontractorForm.status, (v) => setSubcontractorForm((p) => ({ ...p, status: v })), "상태")}
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={subcontractorForm.contact_person || ""}
onChange={(e) => setSubcontractorForm((p) => ({ ...p, contact_person: e.target.value }))}
placeholder="담당자"
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={subcontractorForm.contact_phone || ""}
onChange={(e) => handleFormChange("contact_phone", e.target.value)}
placeholder="010-0000-0000"
className={cn("h-9 text-sm", formErrors.contact_phone && "border-destructive")}
/>
{formErrors.contact_phone && <p className="text-xs text-destructive">{formErrors.contact_phone}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={subcontractorForm.email || ""}
onChange={(e) => handleFormChange("email", e.target.value)}
placeholder="example@email.com"
className={cn("h-9 text-sm", formErrors.email && "border-destructive")}
/>
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={subcontractorForm.business_number || ""}
onChange={(e) => handleFormChange("business_number", e.target.value)}
placeholder="000-00-00000"
className={cn("h-9 text-sm", formErrors.business_number && "border-destructive")}
/>
{formErrors.business_number && <p className="text-xs text-destructive">{formErrors.business_number}</p>}
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-xs"></Label>
<Input
value={subcontractorForm.address || ""}
onChange={(e) => setSubcontractorForm((p) => ({ ...p, address: e.target.value }))}
placeholder="주소"
className="h-9 text-sm"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSubcontractorModalOpen(false)}></Button>
<Button onClick={handleSubcontractorSave} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <Save className="h-4 w-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 품목 추가 모달 (1단계: 검색/선택) */}
<Dialog open={itemSelectOpen} onOpenChange={setItemSelectOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[780px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="flex gap-2">
<Input
placeholder="품명/품목코드 검색"
value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchItems()}
className="h-9 flex-1 text-sm"
/>
<Button size="sm" onClick={searchItems} disabled={itemSearchLoading} className="h-9">
{itemSearchLoading
? <Loader2 className="h-4 w-4 animate-spin" />
: <><Search className="h-4 w-4 mr-1" /></>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] rounded-md border">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center">
<input
type="checkbox"
checked={itemSearchResults.length > 0 && itemCheckedIds.size === itemSearchResults.length}
onChange={(e) => {
if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id)));
else setItemCheckedIds(new Set());
}}
className="h-4 w-4"
/>
</TableHead>
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{itemSearchResults.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8 text-sm">
</TableCell>
</TableRow>
) : itemSearchResults.map((item) => (
<TableRow
key={item.id}
className={cn("cursor-pointer", itemCheckedIds.has(item.id) && "bg-primary/5")}
onClick={() => setItemCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
})}
>
<TableCell className="text-center">
<input type="checkbox" checked={itemCheckedIds.has(item.id)} readOnly className="h-4 w-4" />
</TableCell>
<TableCell className="text-[13px]">{item.item_number}</TableCell>
<TableCell className="text-[13px]">{item.item_name}</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>
<DialogFooter>
<div className="flex items-center justify-between w-full">
<span className="text-sm text-muted-foreground">{itemCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setItemSelectOpen(false)}></Button>
<Button onClick={goToItemDetail} disabled={itemCheckedIds.size === 0}>
<Plus className="h-4 w-4 mr-1" />
{itemCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 품목 상세정보 입력 모달 (2단계) */}
<Dialog open={itemDetailOpen} onOpenChange={setItemDetailOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editItemData ? "수정" : "입력"} {selectedSubcontractor?.subcontractor_name || ""}
</DialogTitle>
<DialogDescription>
{editItemData ? "외주 품번/품명과 기간별 단가를 수정해요" : "선택한 품목의 외주 품번/품명과 기간별 단가를 설정해요"}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-2">
{selectedItemsForDetail.map((item, idx) => {
const itemKey = item.item_number || item.id;
const mappingRows = itemMappings[itemKey] || [];
const prices = itemPrices[itemKey] || [];
return (
<div key={itemKey} className="border rounded-xl overflow-hidden bg-card">
{/* 품목 헤더 */}
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-semibold text-sm">{idx + 1}. {item.item_name || itemKey}</div>
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit || ""}</div>
</div>
<div className="flex gap-4 p-4">
{/* 좌: 외주 품번/품명 */}
<div className="flex-1 border rounded-lg p-4 bg-muted/20">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold"> / </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addMappingRow(itemKey)}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{mappingRows.length === 0 ? (
<div className="text-xs text-muted-foreground py-2"> </div>
) : mappingRows.map((mRow, mIdx) => (
<div key={mRow._id} className="flex gap-2 items-center">
<span className="text-xs text-muted-foreground w-4 shrink-0">{mIdx + 1}</span>
<Input
value={mRow.subcontractor_item_code}
onChange={(e) => updateMappingRow(itemKey, mRow._id, "subcontractor_item_code", e.target.value)}
placeholder="외주 품번"
className="h-8 text-sm flex-1"
/>
<Input
value={mRow.subcontractor_item_name}
onChange={(e) => updateMappingRow(itemKey, mRow._id, "subcontractor_item_name", e.target.value)}
placeholder="외주 품명"
className="h-8 text-sm flex-1"
/>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive shrink-0" onClick={() => removeMappingRow(itemKey, mRow._id)}>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
{/* 우: 기간별 단가 */}
<div className="flex-1 border rounded-lg p-4 bg-muted/20">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold"> </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addPriceRow(itemKey)}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-3">
{prices.map((price, pIdx) => (
<div key={price._id} className="border rounded-lg p-3 bg-background space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground"> {pIdx + 1}</span>
{prices.length > 1 && (
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-destructive" onClick={() => removePriceRow(itemKey, price._id)}>
<X className="h-3 w-3" />
</Button>
)}
</div>
{/* 기간 */}
<div className="flex gap-2 items-center">
<Input
type="date"
value={price.start_date}
onChange={(e) => updatePriceRow(itemKey, price._id, "start_date", e.target.value)}
className="h-8 text-xs flex-1"
/>
<span className="text-xs text-muted-foreground">~</span>
<Input
type="date"
value={price.end_date}
onChange={(e) => updatePriceRow(itemKey, price._id, "end_date", e.target.value)}
className="h-8 text-xs flex-1"
/>
<div className="w-[80px]">
<Select value={price.currency_code || undefined} onValueChange={(v) => updatePriceRow(itemKey, price._id, "currency_code", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{/* 기준가/할인/반올림 */}
<div className="flex gap-2 items-center">
<div className="w-[90px]">
<Select value={price.base_price_type || undefined} onValueChange={(v) => updatePriceRow(itemKey, price._id, "base_price_type", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="기준유형" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["base_price_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Input
value={price.base_price}
onChange={(e) => updatePriceRow(itemKey, price._id, "base_price", e.target.value)}
className="h-8 text-xs text-right flex-1"
placeholder="기준가"
/>
<div className="w-[90px]">
<Select value={price.discount_type || undefined} onValueChange={(v) => updatePriceRow(itemKey, price._id, "discount_type", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="할인유형" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{(priceCategoryOptions["discount_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Input
value={price.discount_value}
onChange={(e) => updatePriceRow(itemKey, price._id, "discount_value", e.target.value)}
className="h-8 text-xs text-right w-[60px]"
placeholder="0"
/>
<div className="w-[90px]">
<Select value={price.rounding_unit_value || undefined} onValueChange={(v) => updatePriceRow(itemKey, price._id, "rounding_unit_value", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="반올림" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["rounding_unit_value"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{/* 계산 단가 표시 */}
<div className="flex items-center justify-end gap-2 pt-1 border-t">
<span className="text-xs text-muted-foreground"> :</span>
<span className="font-bold text-sm">{price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"}</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setItemDetailOpen(false);
if (!editItemData) setItemSelectOpen(true);
setEditItemData(null);
}}
>
{editItemData ? "취소" : "← 이전"}
</Button>
<Button onClick={handleItemDetailSave} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <Save className="h-4 w-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 (멀티테이블) */}
{excelChainConfig && (
<MultiTableExcelUploadModal
open={excelUploadOpen}
onOpenChange={(open) => {
setExcelUploadOpen(open);
if (!open) setExcelChainConfig(null);
}}
config={excelChainConfig}
onSuccess={() => {
fetchSubcontractors();
const cid = selectedSubcontractorId;
setSelectedSubcontractorId(null);
setTimeout(() => setSelectedSubcontractorId(cid), 50);
}}
/>
)}
{/* 테이블 설정 */}
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
includeAutoColumns={["created_date", "updated_date", "writer"]}
onSave={ts.applySettings}
/>
{ConfirmDialogComponent}
</div>
);
}