904 lines
36 KiB
TypeScript
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>
|
|
);
|
|
}
|