feat: COMPANY_29 및 COMPANY_7 고객 관리 및 부서 관리 페이지 개선

- 부서 등록 시 부서코드 자동 생성 로직 수정
- 고객 관리 페이지에서 거래처 담당자 및 사내 담당자 컬럼 추가
- 고객 관리 페이지에서 사원 목록 로드 기능 추가
- 다중 선택 기능을 위한 포털 구현 및 외부 클릭 시 저장 기능 추가
- 테이블 컴포넌트에서 다중 선택 컬럼 자동 감지 기능 추가

이 커밋은 부서 및 고객 관리 기능을 개선하고, 사용자 경험을 향상시키기 위한 여러 변경 사항을 포함합니다.
This commit is contained in:
kjs 2026-03-30 11:51:12 +09:00
parent ac5292f9b0
commit 08a095a8e5
7 changed files with 328 additions and 36 deletions

View File

@ -2833,17 +2833,19 @@ export class TableManagementService {
.join(", ");
const columnNames = columns.map((col) => `"${col}"`).join(", ");
const hasIdColumn = columnTypeMap.has("id");
const returningClause = hasIdColumn ? "RETURNING id" : "RETURNING *";
const insertQuery = `
INSERT INTO "${tableName}" (${columnNames})
VALUES (${placeholders})
RETURNING id
${returningClause}
`;
logger.info(`실행할 쿼리: ${insertQuery}`);
logger.info(`쿼리 파라미터:`, values);
const insertResult = await query(insertQuery, values) as any[];
const insertedId = insertResult?.[0]?.id ?? null;
const insertedId = insertResult?.[0]?.id ?? insertResult?.[0]?.[columns[0]] ?? null;
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${insertedId}`);

View File

@ -194,7 +194,7 @@ export default function DepartmentPage() {
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, {
dept_code: "",
dept_code: deptForm.dept_code && !deptForm.dept_code.startsWith("(") ? deptForm.dept_code : "",
dept_name: deptForm.dept_name,
parent_dept_code: deptForm.parent_dept_code || null,
});

View File

@ -52,7 +52,8 @@ const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "customer_code", label: "거래처코드", width: "w-[110px]" },
{ key: "customer_name", label: "거래처명", minWidth: "min-w-[120px]" },
{ key: "division", label: "거래유형", width: "w-[80px]" },
{ key: "contact_person", label: "담당자", width: "w-[80px]" },
{ key: "contact_person", label: "거래처담당자", width: "w-[90px]" },
{ key: "internal_manager", label: "사내담당자", width: "w-[90px]" },
{ key: "contact_phone", label: "전화번호", width: "w-[110px]" },
{ key: "business_number", label: "사업자번호", width: "w-[110px]" },
{ key: "email", label: "이메일", width: "w-[130px]" },
@ -85,6 +86,7 @@ export default function CustomerManagementPage() {
const [customerCount, setCustomerCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [gridColumns, setGridColumns] = useState<DataGridColumn[]>(LEFT_COLUMNS);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(null);
@ -141,6 +143,8 @@ export default function CustomerManagementPage() {
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 사원 목록 (사내담당자 선택용)
const [employeeOptions, setEmployeeOptions] = useState<{ user_id: string; user_name: string; position_name?: string }[]>([]);
// 카테고리 로드
useEffect(() => {
@ -173,9 +177,33 @@ export default function CustomerManagementPage() {
setPriceCategoryOptions(priceOpts);
};
load();
// 사원 목록 로드
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true })
.then((res) => {
const users = res.data?.data?.data || res.data?.data?.rows || [];
setEmployeeOptions(users.map((u: any) => ({
user_id: u.user_id, user_name: u.user_name || u.user_id, position_name: u.position_name,
})));
}).catch(() => {});
}, []);
const applyTableSettings = useCallback((settings: TableSettings) => {
// 컬럼 표시/숨김/순서/너비
const colMap = new Map(LEFT_COLUMNS.map((c) => [c.key, c]));
const applied: DataGridColumn[] = [];
for (const cs of settings.columns) {
if (!cs.visible) continue;
const orig = colMap.get(cs.columnName);
if (orig) {
applied.push({ ...orig, width: `w-[${cs.width}px]`, minWidth: undefined });
}
}
const settingKeys = new Set(settings.columns.map((c) => c.columnName));
for (const col of LEFT_COLUMNS) {
if (!settingKeys.has(col.key)) applied.push(col);
}
setGridColumns(applied.length > 0 ? applied : LEFT_COLUMNS);
// 필터 설정
setFilterConfig(settings.filters);
}, []);
@ -206,6 +234,9 @@ export default function CustomerManagementPage() {
...r,
division: resolve("division", r.division),
status: resolve("status", r.status),
internal_manager: r.internal_manager
? (employeeOptions.find((e) => e.user_id === r.internal_manager)?.user_name || r.internal_manager)
: "",
}));
setCustomers(data);
setCustomerCount(res.data?.data?.total || raw.length);
@ -215,7 +246,7 @@ export default function CustomerManagementPage() {
} finally {
setCustomerLoading(false);
}
}, [searchFilters, categoryOptions]);
}, [searchFilters, categoryOptions, employeeOptions]);
useEffect(() => { fetchCustomers(); }, [fetchCustomers]);
@ -632,13 +663,28 @@ export default function CustomerManagementPage() {
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,
unit_price: price.calculated_price ? Number(price.calculated_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 {
// 신규 등록 모드
// 신규 등록 모드 — 거래처 품번이 없는 경우만 중복 체크
if (!mappingRows.length || !mappingRows[0]?.customer_item_code) {
const existingCheck = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
if ((existingCheck.data?.data?.data || existingCheck.data?.data?.rows || []).length > 0) {
toast.warning(`${item.item_name || itemKey} 품목은 이미 등록되어 있습니다.`);
continue;
}
}
let mappingId: string | null = null;
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
customer_id: selectedCustomer.customer_code, item_id: itemKey,
@ -655,22 +701,16 @@ export default function CustomerManagementPage() {
});
}
const allPriceRows = itemPrices[itemKey] || [];
const priceRows = allPriceRows.filter((p) =>
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
);
if (allPriceRows.length > 0 && priceRows.length === 0) {
toast.error("단가 정보를 입력해주세요. (기준가 또는 적용시작일 필수)");
setSaving(false);
savingRef.current = false;
return;
}
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, {
mapping_id: mappingId || "", customer_id: selectedCustomer.customer_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,
unit_price: price.calculated_price ? Number(price.calculated_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,
@ -859,7 +899,7 @@ export default function CustomerManagementPage() {
</div>
<DataGrid
gridId="customer-left"
columns={LEFT_COLUMNS}
columns={gridColumns}
data={customers}
loading={customerLoading}
selectedId={selectedCustomerId}
@ -975,9 +1015,23 @@ export default function CustomerManagementPage() {
{renderSelect("status", customerForm.status, (v) => setCustomerForm((p) => ({ ...p, status: v })), "상태")}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Label className="text-sm"></Label>
<Input value={customerForm.contact_person || ""} onChange={(e) => setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))}
placeholder="담당자" className="h-9" />
placeholder="거래처담당자" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={customerForm.internal_manager || "__none__"} onValueChange={(v) => setCustomerForm((p) => ({ ...p, internal_manager: v === "__none__" ? "" : v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="사내담당자 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{employeeOptions.map((emp) => (
<SelectItem key={emp.user_id} value={emp.user_id}>
{emp.user_name}{emp.position_name ? ` (${emp.position_name})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
@ -1129,7 +1183,14 @@ export default function CustomerManagementPage() {
<div className="space-y-2">
{mappingRows.length === 0 ? (
<div className="text-xs text-muted-foreground py-2"> </div>
) : mappingRows.map((mRow, mIdx) => (
) : (<>
<div className="flex gap-2 items-center text-[10px] text-muted-foreground font-medium">
<span className="w-4 shrink-0"></span>
<span className="flex-1"> </span>
<span className="flex-1"> </span>
<span className="w-7 shrink-0"></span>
</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.customer_item_code}
@ -1144,6 +1205,7 @@ export default function CustomerManagementPage() {
</Button>
</div>
))}
</>)}
</div>
</div>

View File

@ -194,7 +194,7 @@ export default function DepartmentPage() {
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, {
dept_code: "",
dept_code: deptForm.dept_code && !deptForm.dept_code.startsWith("(") ? deptForm.dept_code : "",
dept_name: deptForm.dept_name,
parent_dept_code: deptForm.parent_dept_code || null,
});

View File

@ -52,7 +52,8 @@ const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "customer_code", label: "거래처코드", width: "w-[110px]" },
{ key: "customer_name", label: "거래처명", minWidth: "min-w-[120px]" },
{ key: "division", label: "거래유형", width: "w-[80px]" },
{ key: "contact_person", label: "담당자", width: "w-[80px]" },
{ key: "contact_person", label: "거래처담당자", width: "w-[90px]" },
{ key: "internal_manager", label: "사내담당자", width: "w-[90px]" },
{ key: "contact_phone", label: "전화번호", width: "w-[110px]" },
{ key: "business_number", label: "사업자번호", width: "w-[110px]" },
{ key: "email", label: "이메일", width: "w-[130px]" },
@ -85,6 +86,7 @@ export default function CustomerManagementPage() {
const [customerCount, setCustomerCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [gridColumns, setGridColumns] = useState<DataGridColumn[]>(LEFT_COLUMNS);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(null);
@ -141,6 +143,8 @@ export default function CustomerManagementPage() {
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 사원 목록 (사내담당자 선택용)
const [employeeOptions, setEmployeeOptions] = useState<{ user_id: string; user_name: string; position_name?: string }[]>([]);
// 카테고리 로드
useEffect(() => {
@ -173,9 +177,33 @@ export default function CustomerManagementPage() {
setPriceCategoryOptions(priceOpts);
};
load();
// 사원 목록 로드
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true })
.then((res) => {
const users = res.data?.data?.data || res.data?.data?.rows || [];
setEmployeeOptions(users.map((u: any) => ({
user_id: u.user_id, user_name: u.user_name || u.user_id, position_name: u.position_name,
})));
}).catch(() => {});
}, []);
const applyTableSettings = useCallback((settings: TableSettings) => {
// 컬럼 표시/숨김/순서/너비
const colMap = new Map(LEFT_COLUMNS.map((c) => [c.key, c]));
const applied: DataGridColumn[] = [];
for (const cs of settings.columns) {
if (!cs.visible) continue;
const orig = colMap.get(cs.columnName);
if (orig) {
applied.push({ ...orig, width: `w-[${cs.width}px]`, minWidth: undefined });
}
}
const settingKeys = new Set(settings.columns.map((c) => c.columnName));
for (const col of LEFT_COLUMNS) {
if (!settingKeys.has(col.key)) applied.push(col);
}
setGridColumns(applied.length > 0 ? applied : LEFT_COLUMNS);
// 필터 설정
setFilterConfig(settings.filters);
}, []);
@ -206,6 +234,9 @@ export default function CustomerManagementPage() {
...r,
division: resolve("division", r.division),
status: resolve("status", r.status),
internal_manager: r.internal_manager
? (employeeOptions.find((e) => e.user_id === r.internal_manager)?.user_name || r.internal_manager)
: "",
}));
setCustomers(data);
setCustomerCount(res.data?.data?.total || raw.length);
@ -215,7 +246,7 @@ export default function CustomerManagementPage() {
} finally {
setCustomerLoading(false);
}
}, [searchFilters, categoryOptions]);
}, [searchFilters, categoryOptions, employeeOptions]);
useEffect(() => { fetchCustomers(); }, [fetchCustomers]);
@ -632,13 +663,28 @@ export default function CustomerManagementPage() {
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,
unit_price: price.calculated_price ? Number(price.calculated_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 {
// 신규 등록 모드
// 신규 등록 모드 — 거래처 품번이 없는 경우만 중복 체크
if (!mappingRows.length || !mappingRows[0]?.customer_item_code) {
const existingCheck = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
if ((existingCheck.data?.data?.data || existingCheck.data?.data?.rows || []).length > 0) {
toast.warning(`${item.item_name || itemKey} 품목은 이미 등록되어 있습니다.`);
continue;
}
}
let mappingId: string | null = null;
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
customer_id: selectedCustomer.customer_code, item_id: itemKey,
@ -655,22 +701,16 @@ export default function CustomerManagementPage() {
});
}
const allPriceRows = itemPrices[itemKey] || [];
const priceRows = allPriceRows.filter((p) =>
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
);
if (allPriceRows.length > 0 && priceRows.length === 0) {
toast.error("단가 정보를 입력해주세요. (기준가 또는 적용시작일 필수)");
setSaving(false);
savingRef.current = false;
return;
}
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, {
mapping_id: mappingId || "", customer_id: selectedCustomer.customer_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,
unit_price: price.calculated_price ? Number(price.calculated_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,
@ -859,7 +899,7 @@ export default function CustomerManagementPage() {
</div>
<DataGrid
gridId="customer-left"
columns={LEFT_COLUMNS}
columns={gridColumns}
data={customers}
loading={customerLoading}
selectedId={selectedCustomerId}
@ -975,9 +1015,23 @@ export default function CustomerManagementPage() {
{renderSelect("status", customerForm.status, (v) => setCustomerForm((p) => ({ ...p, status: v })), "상태")}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Label className="text-sm"></Label>
<Input value={customerForm.contact_person || ""} onChange={(e) => setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))}
placeholder="담당자" className="h-9" />
placeholder="거래처담당자" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={customerForm.internal_manager || "__none__"} onValueChange={(v) => setCustomerForm((p) => ({ ...p, internal_manager: v === "__none__" ? "" : v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="사내담당자 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{employeeOptions.map((emp) => (
<SelectItem key={emp.user_id} value={emp.user_id}>
{emp.user_name}{emp.position_name ? ` (${emp.position_name})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
@ -1129,7 +1183,14 @@ export default function CustomerManagementPage() {
<div className="space-y-2">
{mappingRows.length === 0 ? (
<div className="text-xs text-muted-foreground py-2"> </div>
) : mappingRows.map((mRow, mIdx) => (
) : (<>
<div className="flex gap-2 items-center text-[10px] text-muted-foreground font-medium">
<span className="w-4 shrink-0"></span>
<span className="flex-1"> </span>
<span className="flex-1"> </span>
<span className="w-7 shrink-0"></span>
</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.customer_item_code}
@ -1144,6 +1205,7 @@ export default function CustomerManagementPage() {
</Button>
</div>
))}
</>)}
</div>
</div>

View File

@ -1,6 +1,7 @@
"use client";
import React from "react";
import ReactDOM from "react-dom";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
@ -40,6 +41,7 @@ interface SingleTableWithStickyProps {
// 인라인 편집 타입별 옵션 (select/category/code, number, date 지원)
columnMeta?: Record<string, { inputType?: string; detailSettings?: any }>;
categoryMappings?: Record<string, Record<string, { label: string }>>;
multiSelectColumns?: Set<string>;
// 검색 하이라이트 관련 props
searchHighlights?: Set<string>;
currentSearchIndex?: number;
@ -77,6 +79,7 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
editInputRef,
columnMeta,
categoryMappings,
multiSelectColumns,
// 검색 하이라이트 관련 props
searchHighlights,
currentSearchIndex = 0,
@ -331,6 +334,8 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
<TableCell
key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined}
data-row={index}
data-col={colIndex}
className={cn(
"text-foreground h-10 align-middle text-[11px] transition-colors",
column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]",
@ -391,6 +396,48 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
value,
label: info.label,
}));
// 다중선택 판별: 화면 모달 설정에서 multiple: true인 컬럼
const isMultiSelect = multiSelectColumns?.has(column.columnName) || false;
if (isMultiSelect) {
const selectedValues = (editingValue ?? "").split(",").filter(Boolean);
const toggleValue = (val: string) => {
const next = selectedValues.includes(val)
? selectedValues.filter((v: string) => v !== val)
: [...selectedValues, val];
onEditingValueChange?.(next.join(","));
};
// Portal로 body에 직접 렌더링 (overflow:hidden 우회)
const cellEl = document.querySelector(
`[data-row="${index}"][data-col="${colIndex}"]`
) as HTMLElement | null;
const rect = cellEl?.getBoundingClientRect();
const portalContent = rect ? ReactDOM.createPortal(
<div
data-multi-select-portal="true"
className="fixed min-w-[180px] max-h-[250px] overflow-auto rounded border border-primary bg-background p-1 shadow-xl"
style={{ top: rect.top - 4, left: rect.left, transform: "translateY(-100%)", zIndex: 99999 }}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Escape" && onEditKeyDown) onEditKeyDown(e as any);
if (e.key === "Enter") handleBlurSave();
}}
onMouseDown={(e) => e.stopPropagation()}
>
{selectOptions.map((opt) => (
<label key={opt.value} className="flex items-center gap-1.5 px-2 py-1 text-xs cursor-pointer hover:bg-muted rounded whitespace-nowrap">
<input type="checkbox" checked={selectedValues.includes(opt.value)}
onChange={() => toggleValue(opt.value)} className="h-3 w-3" />
{opt.label}
</label>
))}
</div>,
document.body
) : null;
return <>{portalContent}<span className="text-xs text-muted-foreground">{selectedValues.length} </span></>;
}
return (
<select
ref={editInputRef as React.RefObject<HTMLSelectElement>}

View File

@ -1,9 +1,10 @@
"use client";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import ReactDOM from "react-dom";
import { TableListConfig, ColumnConfig } from "./types";
import { WebType } from "@/types/common";
import { tableTypeApi } from "@/lib/api/screen";
import { tableTypeApi, screenApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache";
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
@ -2537,11 +2538,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return;
}
// 이전 다중선택 편집 중이면 먼저 저장
if (multiSelectPortalRef.current && editingCell) {
saveEditingRef.current?.();
}
setEditingCell({ rowIndex, colIndex, columnName, originalValue: value });
setEditingValue(value !== null && value !== undefined ? String(value) : "");
setFocusedCell({ rowIndex, colIndex });
},
[visibleColumns],
[visibleColumns, editingCell],
);
// 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후)
@ -2572,6 +2578,52 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return result;
}, [data, tableConfig.columns, joinColumnMapping]);
// 모달 화면 설정에서 다중선택(multiple) 컬럼 자동 감지
const [multiSelectColumns, setMultiSelectColumns] = useState<Set<string>>(new Set());
useEffect(() => {
const numScreenId = typeof screenId === "string" ? parseInt(screenId) : screenId;
if (!numScreenId) return;
const detectMultiSelect = async () => {
try {
const layout = await screenApi.getLayout(numScreenId);
if (!layout?.components) { console.log("[multiSelect] layout.components 없음", numScreenId); return; }
// 버튼 컴포넌트에서 모달 screenId 추출
const modalScreenIds = new Set<number>();
const findModalRefs = (obj: any) => {
if (!obj || typeof obj !== "object") return;
if (Array.isArray(obj)) { obj.forEach(findModalRefs); return; }
const sid = obj.modalScreenId || obj.targetScreenId;
if (sid && typeof sid === "number") modalScreenIds.add(sid);
if (obj.action?.targetScreenId) modalScreenIds.add(obj.action.targetScreenId);
if (obj.action?.screenId) modalScreenIds.add(obj.action.screenId);
for (const v of Object.values(obj)) findModalRefs(v);
};
findModalRefs(layout.components);
modalScreenIds.delete(numScreenId);
console.log("[multiSelect] modalScreenIds:", [...modalScreenIds]);
if (modalScreenIds.size === 0) return;
// 모달 화면 layout에서 multiple: true 컬럼 추출
const multiCols = new Set<string>();
for (const msId of modalScreenIds) {
try {
const modalLayout = await screenApi.getLayout(msId);
if (!modalLayout?.components) continue;
const findMultiple = (obj: any) => {
if (!obj || typeof obj !== "object") return;
if (Array.isArray(obj)) { obj.forEach(findMultiple); return; }
if (obj.multiple === true && obj.columnName) multiCols.add(obj.columnName);
for (const v of Object.values(obj)) findMultiple(v);
};
findMultiple(modalLayout.components);
} catch (e) { console.warn("[multiSelect] 모달 layout 조회 실패:", msId, e); }
}
console.log("[multiSelect] 다중선택 컬럼:", [...multiCols]);
if (multiCols.size > 0) setMultiSelectColumns(multiCols);
} catch (e) { console.error("[multiSelect] 감지 실패:", e); }
};
detectMultiSelect();
}, [screenId]);
// 데이터 변경 시 헤더 필터 드롭다운 캐시 초기화
useEffect(() => {
setAsyncColumnUniqueValues({});
@ -2682,6 +2734,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
tableContainerRef.current?.focus();
}, []);
const multiSelectPortalRef = useRef<boolean>(false);
// 🆕 편집 저장 (즉시 저장 또는 배치 저장)
const saveEditing = useCallback(async () => {
if (!editingCell) return;
@ -2782,6 +2836,30 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
pendingChanges.size,
]);
// saveEditing을 ref로 노출 (호이스팅 우회)
const saveEditingRef = useRef<() => void>();
saveEditingRef.current = saveEditing;
// 다중선택 Portal 외부 클릭 시 저장 후 닫기
useEffect(() => {
if (!editingCell) { multiSelectPortalRef.current = false; return; }
const isMulti = multiSelectColumns.has(editingCell.columnName);
multiSelectPortalRef.current = isMulti;
if (!isMulti) return;
const handleOutsideClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest("[data-multi-select-portal]")) return;
saveEditingRef.current?.();
};
const timer = setTimeout(() => {
document.addEventListener("mousedown", handleOutsideClick);
}, 100);
return () => {
clearTimeout(timer);
document.removeEventListener("mousedown", handleOutsideClick);
};
}, [editingCell, multiSelectColumns]);
// 🆕 배치 저장: 모든 변경사항 한번에 저장
const saveBatchChanges = useCallback(async () => {
if (pendingChanges.size === 0) {
@ -5634,6 +5712,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
editInputRef={editInputRef}
columnMeta={columnMeta}
categoryMappings={categoryMappings}
multiSelectColumns={multiSelectColumns}
/>
</div>
@ -6675,6 +6754,46 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}),
);
// 다중선택 판별
const isMultiSelect = multiSelectColumns.has(column.columnName);
if (isMultiSelect && !cascadingConfig) {
const selectedValues = (editingValue ?? "").split(",").filter(Boolean);
const toggleValue = (val: string) => {
const next = selectedValues.includes(val)
? selectedValues.filter((v: string) => v !== val)
: [...selectedValues, val];
setEditingValue(next.join(","));
};
// Portal로 body에 렌더링 (overflow:hidden 우회)
const cellEl = document.querySelector(
`[data-row="${index}"][data-col="${colIndex}"]`
) as HTMLElement | null;
const rect = cellEl?.getBoundingClientRect();
const portalContent = rect ? ReactDOM.createPortal(
<div
data-multi-select-portal="true"
className="fixed min-w-[180px] max-h-[250px] overflow-auto rounded border-2 border-primary bg-background p-1 shadow-xl text-xs"
style={{ top: rect.top - 4, left: rect.left, transform: "translateY(-100%)", zIndex: 99999 }}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Escape") handleEditKeyDown(e as any);
if (e.key === "Enter") saveEditing();
}}
>
{selectOptions.map((opt) => (
<label key={opt.value} className="flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-xs hover:bg-muted whitespace-nowrap">
<input type="checkbox" checked={selectedValues.includes(opt.value)}
onChange={() => toggleValue(opt.value)} className="h-3 w-3" />
{opt.label}
</label>
))}
</div>,
document.body
) : null;
return <>{portalContent}<span className="text-xs text-muted-foreground">{selectedValues.length} </span></>;
}
return (
<select
ref={editInputRef as any}