Files
wace_rps/frontend/app/(main)/COMPANY_16/logistics/info/page.tsx
T

904 lines
36 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
Truck,
DollarSign,
FileText,
MapPin,
Car,
Plus,
Trash2,
Download,
Pencil,
RefreshCw,
Inbox,
Loader2,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
// ========== 타입 & 상수 ==========
type TabKey = "carrier" | "cost" | "contract" | "route" | "vehicle";
interface TabColumnDef {
key: string;
label: string;
width?: string;
align?: "left" | "center" | "right";
formatNumber?: boolean;
}
interface FormFieldDef {
key: string;
label: string;
type: "text" | "number" | "select" | "smartselect" | "date";
required?: boolean;
referenceKey?: "carrier" | "route";
categoryKey?: string;
options?: { value: string; label: string }[];
placeholder?: string;
}
interface TabConfig {
key: TabKey;
label: string;
icon: React.ReactNode;
tableName: string;
columns: TabColumnDef[];
formFields: FormFieldDef[];
defaultSortColumn: string;
}
const TAB_CONFIGS: TabConfig[] = [
{
key: "carrier",
label: "운송업체",
icon: <Truck className="h-3.5 w-3.5" />,
tableName: "carrier_mng",
defaultSortColumn: "carrier_code",
columns: [
{ key: "carrier_code", label: "업체코드", width: "120px" },
{ key: "carrier_name", label: "업체명", width: "160px" },
{ key: "carrier_type", label: "유형", width: "100px" },
{ key: "contact_person", label: "담당자", width: "100px" },
{ key: "contact_phone", label: "연락처", width: "130px" },
{ key: "email", label: "이메일", width: "180px" },
{ key: "address", label: "주소", width: "220px" },
{ key: "rating", label: "등급", width: "70px", align: "center" },
{ key: "status", label: "상태", width: "80px", align: "center" },
],
formFields: [
{ key: "carrier_code", label: "업체코드", type: "text", required: true, placeholder: "업체코드를 입력해주세요" },
{ key: "carrier_name", label: "업체명", type: "text", required: true, placeholder: "업체명을 입력해주세요" },
{ key: "carrier_type", label: "유형", type: "select", categoryKey: "carrier_mng:carrier_type", placeholder: "유형을 선택해주세요" },
{ key: "contact_person", label: "담당자", type: "text", placeholder: "담당자명" },
{ key: "contact_phone", label: "연락처", type: "text", placeholder: "010-0000-0000" },
{ key: "email", label: "이메일", type: "text", placeholder: "email@example.com" },
{ key: "address", label: "주소", type: "text", placeholder: "주소를 입력해주세요" },
{ key: "rating", label: "등급", type: "select", options: [1, 2, 3, 4, 5].map((v) => ({ value: String(v), label: `${v}등급` })) },
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_mng:status", placeholder: "상태를 선택해주세요" },
],
},
{
key: "cost",
label: "물류비",
icon: <DollarSign className="h-3.5 w-3.5" />,
tableName: "logistics_cost_mng",
defaultSortColumn: "carrier_code",
columns: [
{ key: "carrier_code", label: "운송업체", width: "120px" },
{ key: "route_code", label: "구간코드", width: "120px" },
{ key: "base_fee", label: "기본요금", width: "110px", align: "right", formatNumber: true },
{ key: "unit", label: "단위", width: "70px", align: "center" },
{ key: "unit_fee", label: "단가", width: "110px", align: "right", formatNumber: true },
{ key: "min_weight", label: "최소중량", width: "100px", align: "right", formatNumber: true },
{ key: "max_weight", label: "최대중량", width: "100px", align: "right", formatNumber: true },
{ key: "delivery_days", label: "배송일수", width: "80px", align: "center" },
],
formFields: [
{ key: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" },
{ key: "route_code", label: "배송구간", type: "smartselect", required: true, referenceKey: "route" },
{ key: "base_fee", label: "기본요금", type: "number", placeholder: "0" },
{ key: "unit", label: "단위", type: "text", placeholder: "kg, 건 등" },
{ key: "unit_fee", label: "단가", type: "number", placeholder: "0" },
{ key: "min_weight", label: "최소중량", type: "number", placeholder: "0" },
{ key: "max_weight", label: "최대중량", type: "number", placeholder: "0" },
{ key: "delivery_days", label: "배송일수", type: "number", placeholder: "0" },
],
},
{
key: "contract",
label: "계약서",
icon: <FileText className="h-3.5 w-3.5" />,
tableName: "carrier_contract_mng",
defaultSortColumn: "contract_no",
columns: [
{ key: "contract_no", label: "계약번호", width: "130px" },
{ key: "carrier_code", label: "운송업체", width: "120px" },
{ key: "contract_start_date", label: "시작일", width: "110px" },
{ key: "contract_end_date", label: "종료일", width: "110px" },
{ key: "contract_amount", label: "계약금액", width: "130px", align: "right", formatNumber: true },
{ key: "status", label: "상태", width: "80px", align: "center" },
],
formFields: [
{ key: "contract_no", label: "계약번호", type: "text", required: true, placeholder: "계약번호를 입력해주세요" },
{ key: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" },
{ key: "contract_start_date", label: "시작일", type: "date", required: true },
{ key: "contract_end_date", label: "종료일", type: "date", required: true },
{ key: "contract_amount", label: "계약금액", type: "number", placeholder: "0" },
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_contract_mng:status", placeholder: "상태를 선택해주세요" },
],
},
{
key: "route",
label: "배송구간",
icon: <MapPin className="h-3.5 w-3.5" />,
tableName: "delivery_route_mng",
defaultSortColumn: "route_code",
columns: [
{ key: "route_code", label: "구간코드", width: "120px" },
{ key: "route_name", label: "구간명", width: "160px" },
{ key: "departure", label: "출발지", width: "120px" },
{ key: "destination", label: "도착지", width: "120px" },
{ key: "distance_km", label: "거리(km)", width: "100px", align: "right", formatNumber: true },
{ key: "avg_time_hours", label: "평균시간(h)", width: "100px", align: "right" },
{ key: "route_type", label: "구간유형", width: "100px" },
{ key: "status", label: "상태", width: "80px", align: "center" },
],
formFields: [
{ key: "route_code", label: "구간코드", type: "text", required: true, placeholder: "구간코드를 입력해주세요" },
{ key: "route_name", label: "구간명", type: "text", required: true, placeholder: "구간명을 입력해주세요" },
{ key: "departure", label: "출발지", type: "text", placeholder: "출발지" },
{ key: "destination", label: "도착지", type: "text", placeholder: "도착지" },
{ key: "distance_km", label: "거리(km)", type: "number", placeholder: "0" },
{ key: "avg_time_hours", label: "평균시간(h)", type: "number", placeholder: "0" },
{ key: "route_type", label: "구간유형", type: "select", categoryKey: "delivery_route_mng:route_type", placeholder: "유형을 선택해주세요" },
{ key: "status", label: "상태", type: "select", categoryKey: "delivery_route_mng:status", placeholder: "상태를 선택해주세요" },
],
},
{
key: "vehicle",
label: "차량",
icon: <Car className="h-3.5 w-3.5" />,
tableName: "carrier_vehicle_mng",
defaultSortColumn: "vehicle_code",
columns: [
{ key: "vehicle_code", label: "차량코드", width: "120px" },
{ key: "vehicle_number", label: "차량번호", width: "120px" },
{ key: "vehicle_type", label: "차종", width: "100px" },
{ key: "carrier_code", label: "운송업체", width: "120px" },
{ key: "load_capacity_kg", label: "적재용량(kg)", width: "120px", align: "right", formatNumber: true },
{ key: "driver_name", label: "운전자", width: "100px" },
{ key: "status", label: "상태", width: "80px", align: "center" },
],
formFields: [
{ key: "vehicle_code", label: "차량코드", type: "text", required: true, placeholder: "차량코드를 입력해주세요" },
{ key: "vehicle_number", label: "차량번호", type: "text", required: true, placeholder: "12가 3456" },
{ key: "vehicle_type", label: "차종", type: "select", categoryKey: "carrier_vehicle_mng:vehicle_type", placeholder: "차종을 선택해주세요" },
{ key: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" },
{ key: "load_capacity_kg", label: "적재용량(kg)", type: "number", placeholder: "0" },
{ key: "driver_name", label: "운전자", type: "text", placeholder: "운전자명" },
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_vehicle_mng:status", placeholder: "상태를 선택해주세요" },
],
},
];
// 카테고리 계층 평탄화
function flattenCategories(items: any[]): { value: string; label: string }[] {
const result: { value: string; label: string }[] = [];
function walk(arr: any[]) {
for (const item of arr) {
if (item.value || item.name) {
result.push({
value: item.value || item.name,
label: item.label || item.name || item.value,
});
}
if (item.children?.length) walk(item.children);
}
}
walk(items);
return result;
}
// ========== 메인 컴포넌트 ==========
export default function LogisticsInfoPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 탭 상태
const [activeTab, setActiveTab] = useState<TabKey>("carrier");
// 검색 필터
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 탭별 독립 상태
const [tabData, setTabData] = useState<Record<TabKey, any[]>>({
carrier: [], cost: [], contract: [], route: [], vehicle: [],
});
const [tabLoading, setTabLoading] = useState<Record<TabKey, boolean>>({
carrier: false, cost: false, contract: false, route: false, vehicle: false,
});
const [tabChecked, setTabChecked] = useState<Record<TabKey, string[]>>({
carrier: [], cost: [], contract: [], route: [], vehicle: [],
});
// FK 참조 데이터 (캐싱)
const [carrierOptions, setCarrierOptions] = useState<{ code: string; label: string }[]>([]);
const [routeOptions, setRouteOptions] = useState<{ code: string; label: string }[]>([]);
// 카테고리 옵션 캐시
const [categoryOptions, setCategoryOptions] = useState<Record<string, { value: string; label: string }[]>>({});
const loadedCategories = useRef(new Set<string>());
// 모달 상태
const [formOpen, setFormOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [formData, setFormData] = useState<Record<string, any>>({});
// 테이블 설정 (탭별)
const tsCarrier = useTableSettings("c16-logistics-carrier", TAB_CONFIGS[0].tableName, TAB_CONFIGS[0].columns);
const tsCost = useTableSettings("c16-logistics-cost", TAB_CONFIGS[1].tableName, TAB_CONFIGS[1].columns);
const tsContract = useTableSettings("c16-logistics-contract", TAB_CONFIGS[2].tableName, TAB_CONFIGS[2].columns);
const tsRoute = useTableSettings("c16-logistics-route", TAB_CONFIGS[3].tableName, TAB_CONFIGS[3].columns);
const tsVehicle = useTableSettings("c16-logistics-vehicle", TAB_CONFIGS[4].tableName, TAB_CONFIGS[4].columns);
const tsMap: Record<TabKey, typeof tsCarrier> = { carrier: tsCarrier, cost: tsCost, contract: tsContract, route: tsRoute, vehicle: tsVehicle };
const activeTs = tsMap[activeTab];
const activeConfig = useMemo(
() => TAB_CONFIGS.find((c) => c.key === activeTab)!,
[activeTab]
);
// 컬럼 가시성 헬퍼
const getVisibleColumns = (tabKey: TabKey) => tsMap[tabKey].visibleColumns;
// 클라이언트 사이드 필터링
const filteredData = useMemo(() => {
const data = tabData[activeTab];
if (searchFilters.length === 0) return data;
return data.filter((row) =>
searchFilters.every((f) => {
if (!f.value) return true;
const kw = f.value.toLowerCase();
if (f.columnName) {
return String(row[f.columnName] ?? "").toLowerCase().includes(kw);
}
return Object.values(row).some((v) => String(v ?? "").toLowerCase().includes(kw));
})
);
}, [tabData, activeTab, searchFilters]);
// FK 참조 데이터 로드
const loadReferences = useCallback(async () => {
try {
const [carrierRes, routeRes] = await Promise.all([
apiClient.post("/table-management/tables/carrier_mng/data", {
page: 1, size: 500, autoFilter: true,
sort: { columnName: "carrier_code", order: "asc" },
}),
apiClient.post("/table-management/tables/delivery_route_mng/data", {
page: 1, size: 500, autoFilter: true,
sort: { columnName: "route_code", order: "asc" },
}),
]);
const carriers = carrierRes.data?.data?.data || carrierRes.data?.data?.rows || [];
setCarrierOptions(
carriers.map((r: any) => ({
code: r.carrier_code || "",
label: `${r.carrier_code} - ${r.carrier_name || ""}`,
}))
);
const routes = routeRes.data?.data?.data || routeRes.data?.data?.rows || [];
setRouteOptions(
routes.map((r: any) => ({
code: r.route_code || "",
label: `${r.route_code} - ${r.route_name || ""}`,
}))
);
} catch {
// FK 참조 로드 실패 시 무시
}
}, []);
useEffect(() => {
loadReferences();
}, [loadReferences]);
// 카테고리 옵션 로드
const loadCategoryOptions = useCallback(async (tableColumn: string) => {
if (loadedCategories.current.has(tableColumn)) return;
loadedCategories.current.add(tableColumn);
const [tableName, columnName] = tableColumn.split(":");
try {
const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
const data = res.data?.data || [];
setCategoryOptions((prev) => ({
...prev,
[tableColumn]: data.length > 0 ? flattenCategories(data) : [],
}));
} catch {
setCategoryOptions((prev) => ({ ...prev, [tableColumn]: [] }));
}
}, []);
// 활성 탭의 카테고리 로드
useEffect(() => {
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
if (!config) return;
config.formFields.forEach((f) => {
if (f.categoryKey) loadCategoryOptions(f.categoryKey);
});
}, [activeTab, loadCategoryOptions]);
// 데이터 조회
const fetchTabData = useCallback(async (tab: TabKey) => {
const config = TAB_CONFIGS.find((c) => c.key === tab);
if (!config) return;
setTabLoading((prev) => ({ ...prev, [tab]: true }));
try {
const res = await apiClient.post(
`/table-management/tables/${config.tableName}/data`,
{
page: 1, size: 500, autoFilter: true,
sort: { columnName: config.defaultSortColumn, order: "asc" },
}
);
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setTabData((prev) => ({ ...prev, [tab]: rows }));
} catch {
toast.error("데이터를 불러오는 데 실패했어요.");
setTabData((prev) => ({ ...prev, [tab]: [] }));
} finally {
setTabLoading((prev) => ({ ...prev, [tab]: false }));
}
}, []);
// 초기 데이터 로드 (탭 전환 시)
useEffect(() => {
fetchTabData(activeTab);
}, [activeTab, fetchTabData]);
// 탭 변경
const handleTabChange = useCallback((tab: string) => {
setActiveTab(tab as TabKey);
setSearchFilters([]);
}, []);
// 등록 모달 열기
const handleOpenAdd = useCallback(() => {
setEditMode(false);
setEditId(null);
setFormData({});
setFormOpen(true);
}, []);
// 수정 모달 열기
const handleOpenEdit = useCallback((row: any) => {
setEditMode(true);
setEditId(row.id ? String(row.id) : null);
setFormData({ ...row });
setFormOpen(true);
}, []);
// 저장
const handleSave = useCallback(async () => {
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
if (!config) return;
// 필수값 검증
for (const field of config.formFields) {
if (field.required && !formData[field.key]?.toString().trim()) {
toast.error(`${field.label}은(는) 필수 입력이에요.`);
return;
}
}
try {
if (editMode && editId) {
await apiClient.put(`/table-management/tables/${config.tableName}/edit`, {
originalData: { id: editId },
updatedData: formData,
});
toast.success("수정이 완료되었어요.");
} else {
await apiClient.post(
`/table-management/tables/${config.tableName}/add`,
{ id: crypto.randomUUID(), ...formData }
);
toast.success("등록이 완료되었어요.");
}
setFormOpen(false);
fetchTabData(activeTab);
// FK 참조 테이블 변경 시 캐시 갱신
if (activeTab === "carrier" || activeTab === "route") {
loadReferences();
}
} catch (err: any) {
toast.error(err?.response?.data?.message || "저장에 실패했어요.");
}
}, [activeTab, editMode, editId, formData, fetchTabData, loadReferences]);
// 삭제
const handleDelete = useCallback(async () => {
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
if (!config) return;
const ids = tabChecked[activeTab];
if (ids.length === 0) {
toast.error("삭제할 항목을 선택해주세요.");
return;
}
const ok = await confirm(`선택한 ${ids.length}건을 삭제할까요?`, {
description: "삭제된 데이터는 복구할 수 없어요.",
variant: "destructive",
});
if (!ok) return;
try {
await apiClient.delete(
`/table-management/tables/${config.tableName}/delete`,
{ data: ids.map((id) => ({ id })) }
);
toast.success(`${ids.length}건이 삭제되었어요.`);
setTabChecked((prev) => ({ ...prev, [activeTab]: [] }));
fetchTabData(activeTab);
if (activeTab === "carrier" || activeTab === "route") {
loadReferences();
}
} catch {
toast.error("삭제에 실패했어요.");
}
}, [activeTab, tabChecked, confirm, fetchTabData, loadReferences]);
// 엑셀 다운로드 (필터된 데이터 기준)
const handleExcelDownload = useCallback(async () => {
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
if (!config) return;
if (filteredData.length === 0) {
toast.error("다운로드할 데이터가 없어요.");
return;
}
const exportData = filteredData.map((row) => {
const obj: Record<string, any> = {};
config.columns.forEach((col) => {
obj[col.label] = row[col.key] ?? "";
});
return obj;
});
await exportToExcel(exportData, `${config.label}.xlsx`, config.label);
toast.success("엑셀 다운로드가 완료되었어요.");
}, [activeTab, filteredData]);
// 폼 필드 변경
const updateFormField = useCallback((key: string, value: any) => {
setFormData((prev) => ({ ...prev, [key]: value }));
}, []);
// 행 체크 토글
const toggleRowCheck = useCallback((tabKey: TabKey, rowId: string) => {
setTabChecked((prev) => {
const ids = prev[tabKey];
return {
...prev,
[tabKey]: ids.includes(rowId) ? ids.filter((x) => x !== rowId) : [...ids, rowId],
};
});
}, []);
// 전체 체크 토글
const toggleAllCheck = useCallback((tabKey: TabKey, checked: boolean) => {
setTabChecked((prev) => ({
...prev,
[tabKey]: checked ? tabData[tabKey].map((r: any) => String(r.id)) : [],
}));
}, [tabData]);
// 폼 필드 렌더
const renderFormField = useCallback(
(field: FormFieldDef) => {
const value = formData[field.key] ?? "";
// 수정 모드에서 코드/번호 필드는 읽기전용
const isCodeField =
editMode &&
field.type === "text" &&
(field.key.endsWith("_code") || field.key.endsWith("_no"));
switch (field.type) {
case "text":
return (
<Input
value={value}
onChange={(e) => updateFormField(field.key, e.target.value)}
placeholder={field.placeholder}
readOnly={isCodeField}
className={cn(
"h-9 text-sm",
isCodeField && "bg-muted text-muted-foreground"
)}
/>
);
case "number":
return (
<Input
type="number"
value={value}
onChange={(e) => updateFormField(field.key, e.target.value)}
placeholder={field.placeholder}
className="h-9 text-sm"
/>
);
case "select": {
const opts =
field.options ||
(field.categoryKey ? categoryOptions[field.categoryKey] : []) ||
[];
return (
<Select
value={String(value)}
onValueChange={(v) => updateFormField(field.key, v)}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder={field.placeholder || "선택해주세요"} />
</SelectTrigger>
<SelectContent>
{opts.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
case "smartselect": {
// SmartSelect 대신 Select로 직접 구현
const opts =
field.referenceKey === "carrier" ? carrierOptions : routeOptions;
return (
<Select
value={String(value)}
onValueChange={(v) => updateFormField(field.key, v)}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder={field.placeholder || "선택해주세요"} />
</SelectTrigger>
<SelectContent>
{opts.map((o) => (
<SelectItem key={o.code} value={o.code}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
case "date":
return (
<Input
type="date"
value={value ? String(value).split("T")[0] : ""}
onChange={(e) => updateFormField(field.key, e.target.value)}
className="h-9 text-sm"
/>
);
default:
return null;
}
},
[formData, editMode, carrierOptions, routeOptions, categoryOptions, updateFormField]
);
// ========== 렌더링 ==========
return (
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3">
{/* 검색 필터 바 */}
<DynamicSearchFilter
tableName={activeConfig.tableName}
filterId="c16-logistics-info"
onFilterChange={setSearchFilters}
externalFilterConfig={activeTs.filterConfig}
dataCount={filteredData.length}
/>
{/* 탭 + 콘텐츠 영역 */}
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border bg-card"
>
<TabsList className="h-auto w-full shrink-0 justify-start gap-0 rounded-none border-b bg-muted/30 p-0">
{TAB_CONFIGS.map((tab) => (
<TabsTrigger
key={tab.key}
value={tab.key}
className="flex items-center gap-1.5 rounded-none border-b-2 border-transparent px-4 py-2.5 text-sm font-medium text-muted-foreground data-[state=active]:border-primary data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-none"
>
{tab.icon}
{tab.label}
<Badge
variant="outline"
className="ml-1 h-5 min-w-[22px] justify-center px-1.5 font-mono text-[10px]"
>
{tabData[tab.key]?.length || 0}
</Badge>
</TabsTrigger>
))}
</TabsList>
{TAB_CONFIGS.map((tab) => {
const displayData = tab.key === activeTab ? filteredData : tabData[tab.key];
const isAllChecked =
tabData[tab.key].length > 0 &&
tabData[tab.key].every((r: any) => tabChecked[tab.key].includes(String(r.id)));
return (
<TabsContent
key={tab.key}
value={tab.key}
className="m-0 flex flex-1 flex-col overflow-hidden data-[state=inactive]:hidden"
>
{/* 액션 바 */}
<div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
<div className="flex items-center gap-2">
<h2 className="text-sm font-bold">{tab.label} </h2>
<Badge className="bg-primary/10 font-mono text-[11px] text-primary">
{tab.key === activeTab ? displayData.length : tabData[tab.key]?.length || 0}
</Badge>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" className="h-8 text-xs" onClick={handleOpenAdd}>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
disabled={tabChecked[tab.key].length !== 1}
onClick={() => {
const row = tabData[tab.key].find(
(r: any) => String(r.id) === tabChecked[tab.key][0]
);
if (row) handleOpenEdit(row);
}}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-8 text-xs text-destructive hover:bg-destructive/10"
disabled={tabChecked[tab.key].length === 0}
onClick={handleDelete}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
<div className="mx-1 h-5 w-px bg-border" />
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={handleExcelDownload}
>
<Download className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => fetchTabData(tab.key)}
>
<RefreshCw
className={cn(
"h-3.5 w-3.5",
tabLoading[tab.key] && "animate-spin"
)}
/>
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => activeTs.setOpen(true)}
>
<Settings2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
{tabLoading[tab.key] ? (
<div className="flex h-40 items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm"> ...</span>
</div>
) : displayData.length === 0 ? (
<div className="flex h-40 flex-col items-center justify-center gap-2 text-muted-foreground">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-muted/50">
<Inbox className="h-6 w-6 opacity-40" />
</div>
<span className="text-sm"> {tab.label} </span>
<span className="text-xs text-muted-foreground/60">
</span>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] p-2">
<Checkbox
checked={isAllChecked}
onCheckedChange={(checked) =>
toggleAllCheck(tab.key, !!checked)
}
/>
</TableHead>
{getVisibleColumns(tab.key).map((col) => (
<TableHead
key={col.key}
className={cn(
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground",
col.align === "right" && "text-right",
col.align === "center" && "text-center"
)}
style={
col.width
? { width: col.width, minWidth: col.width }
: undefined
}
>
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{displayData.map((row: any, idx: number) => {
const rowId = String(row.id);
const isChecked = tabChecked[tab.key].includes(rowId);
return (
<TableRow
key={row.id ?? idx}
className={cn(
"cursor-pointer transition-colors hover:bg-accent/50",
isChecked && "bg-primary/5 hover:bg-primary/10"
)}
onClick={() => toggleRowCheck(tab.key, rowId)}
onDoubleClick={() => handleOpenEdit(row)}
>
<TableCell className="w-[40px] p-2">
<Checkbox
checked={isChecked}
onCheckedChange={() => toggleRowCheck(tab.key, rowId)}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
{getVisibleColumns(tab.key).map((col) => {
const val = row[col.key];
const display =
col.formatNumber && val != null && val !== ""
? Number(val).toLocaleString()
: val ?? "";
return (
<TableCell
key={col.key}
className={cn(
"max-w-[240px] truncate p-2 text-sm",
col.align === "right" && "text-right",
col.align === "center" && "text-center"
)}
>
{display}
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</TabsContent>
);
})}
</Tabs>
{/* 등록/수정 모달 */}
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[680px]">
<DialogHeader className="shrink-0 border-b px-6 py-4">
<DialogTitle>
{activeConfig.label} {editMode ? "수정" : "등록"}
</DialogTitle>
<DialogDescription>
{editMode
? `${activeConfig.label} 정보를 수정해주세요.`
: `${activeConfig.label} 정보를 입력해주세요.`}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-2 gap-4">
{activeConfig.formFields.map((field) => (
<div key={field.key} className="flex flex-col gap-1.5">
<Label className="text-xs font-semibold text-muted-foreground">
{field.label}
{field.required && (
<span className="ml-0.5 text-destructive">*</span>
)}
</Label>
{renderFormField(field)}
</div>
))}
</div>
</div>
<div className="shrink-0 border-t">
<div className="flex items-center justify-end gap-2 px-6 py-3">
<Button variant="outline" onClick={() => setFormOpen(false)}>
</Button>
<Button onClick={handleSave}>
{editMode ? "수정" : "등록"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={activeTs.open}
onOpenChange={activeTs.setOpen}
tableName={activeTs.tableName}
settingsId={activeTs.settingsId}
defaultVisibleKeys={activeTs.defaultVisibleKeys}
onSave={activeTs.applySettings}
/>
{ConfirmDialogComponent}
</div>
);
}