feat: COMPANY_29 및 COMPANY_7 고객 관리 및 부서 관리 페이지 개선
- 부서 등록 시 부서코드 자동 생성 로직 수정 - 고객 관리 페이지에서 거래처 담당자 및 사내 담당자 컬럼 추가 - 고객 관리 페이지에서 사원 목록 로드 기능 추가 - 다중 선택 기능을 위한 포털 구현 및 외부 클릭 시 저장 기능 추가 - 테이블 컴포넌트에서 다중 선택 컬럼 자동 감지 기능 추가 이 커밋은 부서 및 고객 관리 기능을 개선하고, 사용자 경험을 향상시키기 위한 여러 변경 사항을 포함합니다.
This commit is contained in:
parent
ac5292f9b0
commit
08a095a8e5
|
|
@ -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}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue