From 08a095a8e52e0cdecbcd09e64c57096b6ca77514 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 30 Mar 2026 11:51:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20COMPANY=5F29=20=EB=B0=8F=20COMPANY=5F7?= =?UTF-8?q?=20=EA=B3=A0=EA=B0=9D=20=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B6=80=EC=84=9C=20=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 부서 등록 시 부서코드 자동 생성 로직 수정 - 고객 관리 페이지에서 거래처 담당자 및 사내 담당자 컬럼 추가 - 고객 관리 페이지에서 사원 목록 로드 기능 추가 - 다중 선택 기능을 위한 포털 구현 및 외부 클릭 시 저장 기능 추가 - 테이블 컴포넌트에서 다중 선택 컬럼 자동 감지 기능 추가 이 커밋은 부서 및 고객 관리 기능을 개선하고, 사용자 경험을 향상시키기 위한 여러 변경 사항을 포함합니다. --- .../src/services/tableManagementService.ts | 6 +- .../master-data/department/page.tsx | 2 +- .../(main)/COMPANY_29/sales/customer/page.tsx | 92 ++++++++++--- .../COMPANY_7/master-data/department/page.tsx | 2 +- .../(main)/COMPANY_7/sales/customer/page.tsx | 92 ++++++++++--- .../v2-table-list/SingleTableWithSticky.tsx | 47 +++++++ .../v2-table-list/TableListComponent.tsx | 123 +++++++++++++++++- 7 files changed, 328 insertions(+), 36 deletions(-) diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 7f5c5f2e..9d63a366 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -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}`); diff --git a/frontend/app/(main)/COMPANY_29/master-data/department/page.tsx b/frontend/app/(main)/COMPANY_29/master-data/department/page.tsx index a898fa2d..89001e84 100644 --- a/frontend/app/(main)/COMPANY_29/master-data/department/page.tsx +++ b/frontend/app/(main)/COMPANY_29/master-data/department/page.tsx @@ -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, }); diff --git a/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx index f909d7dd..c2d521f3 100644 --- a/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx @@ -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([]); const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [gridColumns, setGridColumns] = useState(LEFT_COLUMNS); const [filterConfig, setFilterConfig] = useState(); const [selectedCustomerId, setSelectedCustomerId] = useState(null); @@ -141,6 +143,8 @@ export default function CustomerManagementPage() { // 카테고리 const [categoryOptions, setCategoryOptions] = useState>({}); + // 사원 목록 (사내담당자 선택용) + 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() { setCustomerForm((p) => ({ ...p, status: v })), "상태")}
- + setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))} - placeholder="담당자" className="h-9" /> + placeholder="거래처담당자" className="h-9" /> +
+
+ +
@@ -1129,7 +1183,14 @@ export default function CustomerManagementPage() {
{mappingRows.length === 0 ? (
입력된 거래처 품번이 없습니다
- ) : mappingRows.map((mRow, mIdx) => ( + ) : (<> +
+ + 거래처 품번 + 거래처 품명 + +
+ {mappingRows.map((mRow, mIdx) => (
{mIdx + 1}
))} + )}
diff --git a/frontend/app/(main)/COMPANY_7/master-data/department/page.tsx b/frontend/app/(main)/COMPANY_7/master-data/department/page.tsx index a898fa2d..89001e84 100644 --- a/frontend/app/(main)/COMPANY_7/master-data/department/page.tsx +++ b/frontend/app/(main)/COMPANY_7/master-data/department/page.tsx @@ -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, }); diff --git a/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx index f909d7dd..c2d521f3 100644 --- a/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx @@ -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([]); const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [gridColumns, setGridColumns] = useState(LEFT_COLUMNS); const [filterConfig, setFilterConfig] = useState(); const [selectedCustomerId, setSelectedCustomerId] = useState(null); @@ -141,6 +143,8 @@ export default function CustomerManagementPage() { // 카테고리 const [categoryOptions, setCategoryOptions] = useState>({}); + // 사원 목록 (사내담당자 선택용) + 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() { setCustomerForm((p) => ({ ...p, status: v })), "상태")}
- + setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))} - placeholder="담당자" className="h-9" /> + placeholder="거래처담당자" className="h-9" /> +
+
+ +
@@ -1129,7 +1183,14 @@ export default function CustomerManagementPage() {
{mappingRows.length === 0 ? (
입력된 거래처 품번이 없습니다
- ) : mappingRows.map((mRow, mIdx) => ( + ) : (<> +
+ + 거래처 품번 + 거래처 품명 + +
+ {mappingRows.map((mRow, mIdx) => (
{mIdx + 1}
))} + )}
diff --git a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx index c34a0e57..77abd41a 100644 --- a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx +++ b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx @@ -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; categoryMappings?: Record>; + multiSelectColumns?: Set; // 검색 하이라이트 관련 props searchHighlights?: Set; currentSearchIndex?: number; @@ -77,6 +79,7 @@ export const SingleTableWithSticky: React.FC = ({ editInputRef, columnMeta, categoryMappings, + multiSelectColumns, // 검색 하이라이트 관련 props searchHighlights, currentSearchIndex = 0, @@ -331,6 +334,8 @@ export const SingleTableWithSticky: React.FC = ({ = ({ 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( +
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) => ( + + ))} +
, + document.body + ) : null; + return <>{portalContent}{selectedValues.length}개 선택됨; + } + return ( toggleValue(opt.value)} className="h-3 w-3" /> + {opt.label} + + ))} + , + document.body + ) : null; + return <>{portalContent}{selectedValues.length}개 선택됨; + } + return (