Merge pull request 'feature/screen-management' (#203) from feature/screen-management into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/203
This commit is contained in:
kjs 2025-11-12 18:51:51 +09:00
commit 06ef76814a
4 changed files with 135 additions and 227 deletions

View File

@ -117,37 +117,7 @@ class TableCategoryValueService {
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 조회 // 최고 관리자: 모든 카테고리 값 조회
if (menuObjid && siblingObjids.length > 0) { // 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
// 메뉴 스코프 적용
query = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND menu_objid = ANY($3)
`;
params = [tableName, columnName, siblingObjids];
} else {
// 테이블 스코프 (하위 호환성)
query = ` query = `
SELECT SELECT
value_id AS "valueId", value_id AS "valueId",
@ -174,48 +144,10 @@ class TableCategoryValueService {
AND column_name = $2 AND column_name = $2
`; `;
params = [tableName, columnName]; params = [tableName, columnName];
}
logger.info("최고 관리자 카테고리 값 조회"); logger.info("최고 관리자 카테고리 값 조회");
} else { } else {
// 일반 회사: 자신의 카테고리 값만 조회 // 일반 회사: 자신의 카테고리 값만 조회
if (menuObjid && siblingObjids.length > 0) { // 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
// 메뉴 스코프 적용 + created_menu_objid 필터링
// 현재 메뉴 스코프(형제 메뉴)에서 생성된 값만 표시
query = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_menu_objid AS "createdMenuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND menu_objid = ANY($3)
AND company_code = $4
AND (
created_menu_objid = ANY($3) --
OR created_menu_objid IS NULL -- ( )
)
`;
params = [tableName, columnName, siblingObjids, companyCode];
} else {
// 테이블 스코프 (하위 호환성)
query = ` query = `
SELECT SELECT
value_id AS "valueId", value_id AS "valueId",
@ -243,7 +175,6 @@ class TableCategoryValueService {
AND company_code = $3 AND company_code = $3
`; `;
params = [tableName, columnName, companyCode]; params = [tableName, columnName, companyCode];
}
logger.info("회사별 카테고리 값 조회", { companyCode }); logger.info("회사별 카테고리 값 조회", { companyCode });
} }
@ -337,8 +268,8 @@ class TableCategoryValueService {
INSERT INTO table_column_category_values ( INSERT INTO table_column_category_values (
table_name, column_name, value_code, value_label, value_order, table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, description, color, icon, parent_value_id, depth, description, color, icon,
is_active, is_default, company_code, menu_objid, created_menu_objid, created_by is_active, is_default, company_code, menu_objid, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING RETURNING
value_id AS "valueId", value_id AS "valueId",
table_name AS "tableName", table_name AS "tableName",
@ -355,7 +286,6 @@ class TableCategoryValueService {
is_default AS "isDefault", is_default AS "isDefault",
company_code AS "companyCode", company_code AS "companyCode",
menu_objid AS "menuObjid", menu_objid AS "menuObjid",
created_menu_objid AS "createdMenuObjid",
created_at AS "createdAt", created_at AS "createdAt",
created_by AS "createdBy" created_by AS "createdBy"
`; `;
@ -375,7 +305,6 @@ class TableCategoryValueService {
value.isDefault || false, value.isDefault || false,
companyCode, companyCode,
menuObjid, // ← 메뉴 OBJID 저장 menuObjid, // ← 메뉴 OBJID 저장
menuObjid, // ← 🆕 생성 메뉴 OBJID 저장 (같은 값)
userId, userId,
]); ]);

View File

@ -3,11 +3,11 @@
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle,
} from "@/components/ui/resizable-dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Printer, FileDown, FileText } from "lucide-react"; import { Printer, FileDown, FileText } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { useReportDesigner } from "@/contexts/ReportDesignerContext";
@ -895,7 +895,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
</div> </div>
</div> </div>
<ResizableDialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isExporting}> <Button variant="outline" onClick={onClose} disabled={isExporting}>
</Button> </Button>
@ -911,7 +911,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
{isExporting ? "생성 중..." : "WORD"} {isExporting ? "생성 중..." : "WORD"}
</Button> </Button>
</ResizableDialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -4,11 +4,11 @@ import { useState } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle,
} from "@/components/ui/resizable-dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -131,7 +131,7 @@ export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateM
</div> </div>
</div> </div>
<ResizableDialogFooter> <DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isSaving}> <Button variant="outline" onClick={handleClose} disabled={isSaving}>
</Button> </Button>
@ -145,7 +145,7 @@ export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateM
"저장" "저장"
)} )}
</Button> </Button>
</ResizableDialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -10,13 +10,7 @@ import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnV
import { FilterPanel } from "@/components/screen/table-options/FilterPanel"; import { FilterPanel } from "@/components/screen/table-options/FilterPanel";
import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel"; import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
import { TableFilter } from "@/types/table-options"; import { TableFilter } from "@/types/table-options";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface TableSearchWidgetProps { interface TableSearchWidgetProps {
component: { component: {
@ -41,7 +35,9 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
const { registeredTables, selectedTableId, setSelectedTableId, getTable } = useTableOptions(); const { registeredTables, selectedTableId, setSelectedTableId, getTable } = useTableOptions();
// 높이 관리 context (실제 화면에서만 사용) // 높이 관리 context (실제 화면에서만 사용)
let setWidgetHeight: ((screenId: number, componentId: string, height: number, originalHeight: number) => void) | undefined; let setWidgetHeight:
| ((screenId: number, componentId: string, height: number, originalHeight: number) => void)
| undefined;
try { try {
const heightContext = useTableSearchWidgetHeight(); const heightContext = useTableSearchWidgetHeight();
setWidgetHeight = heightContext.setWidgetHeight; setWidgetHeight = heightContext.setWidgetHeight;
@ -76,15 +72,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
useEffect(() => { useEffect(() => {
const tables = Array.from(registeredTables.values()); const tables = Array.from(registeredTables.values());
console.log("🔍 [TableSearchWidget] 테이블 감지:", {
tablesCount: tables.length,
tableIds: tables.map(t => t.tableId),
selectedTableId,
autoSelectFirstTable,
});
if (autoSelectFirstTable && tables.length > 0 && !selectedTableId) { if (autoSelectFirstTable && tables.length > 0 && !selectedTableId) {
console.log("✅ [TableSearchWidget] 첫 번째 테이블 자동 선택:", tables[0].tableId);
setSelectedTableId(tables[0].tableId); setSelectedTableId(tables[0].tableId);
} }
}, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]); }, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]);
@ -132,7 +120,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
} }
const loadSelectOptions = async () => { const loadSelectOptions = async () => {
const selectFilters = activeFilters.filter(f => f.filterType === "select"); const selectFilters = activeFilters.filter((f) => f.filterType === "select");
if (selectFilters.length === 0) { if (selectFilters.length === 0) {
return; return;
@ -159,7 +147,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
loadSelectOptions(); loadSelectOptions();
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues]); // dataCount 제거, tableName으로 변경 }, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues]); // dataCount 제거, tableName으로 변경
// 높이 변화 감지 및 알림 (실제 화면에서만) // 높이 변화 감지 및 알림 (실제 화면에서만)
useEffect(() => { useEffect(() => {
if (!containerRef.current || !screenId || !setWidgetHeight) return; if (!containerRef.current || !screenId || !setWidgetHeight) return;
@ -177,7 +164,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// localStorage에 높이 저장 (새로고침 시 복원용) // localStorage에 높이 저장 (새로고침 시 복원용)
localStorage.setItem( localStorage.setItem(
`table_search_widget_height_screen_${screenId}_${component.id}`, `table_search_widget_height_screen_${screenId}_${component.id}`,
JSON.stringify({ height: newHeight, originalHeight }) JSON.stringify({ height: newHeight, originalHeight }),
); );
// 콜백이 있으면 호출 // 콜백이 있으면 호출
@ -229,10 +216,12 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 필터 적용 함수 // 필터 적용 함수
const applyFilters = (values: Record<string, string> = filterValues) => { const applyFilters = (values: Record<string, string> = filterValues) => {
// 빈 값이 아닌 필터만 적용 // 빈 값이 아닌 필터만 적용
const filtersWithValues = activeFilters.map((filter) => ({ const filtersWithValues = activeFilters
.map((filter) => ({
...filter, ...filter,
value: values[filter.columnName] || "", value: values[filter.columnName] || "",
})).filter((f) => f.value !== ""); }))
.filter((f) => f.value !== "");
currentTable?.onFilterChange(filtersWithValues); currentTable?.onFilterChange(filtersWithValues);
}; };
@ -257,8 +246,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
type="date" type="date"
value={value} value={value}
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)} onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
className="h-9 text-xs sm:text-sm focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0" className="h-9 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
style={{ width: `${width}px`, height: '36px', minHeight: '36px', outline: 'none', boxShadow: 'none' }} style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
placeholder={column?.columnLabel} placeholder={column?.columnLabel}
/> />
); );
@ -269,8 +258,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
type="number" type="number"
value={value} value={value}
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)} onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
className="h-9 text-xs sm:text-sm focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0" className="h-9 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
style={{ width: `${width}px`, height: '36px', minHeight: '36px', outline: 'none', boxShadow: 'none' }} style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
placeholder={column?.columnLabel} placeholder={column?.columnLabel}
/> />
); );
@ -279,27 +268,30 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
let options = selectOptions[filter.columnName] || []; let options = selectOptions[filter.columnName] || [];
// 현재 선택된 값이 옵션 목록에 없으면 추가 (데이터 없을 때도 선택값 유지) // 현재 선택된 값이 옵션 목록에 없으면 추가 (데이터 없을 때도 선택값 유지)
if (value && !options.find(opt => opt.value === value)) { if (value && !options.find((opt) => opt.value === value)) {
const savedLabel = selectedLabels[filter.columnName] || value; const savedLabel = selectedLabels[filter.columnName] || value;
options = [{ value, label: savedLabel }, ...options]; options = [{ value, label: savedLabel }, ...options];
} }
// 중복 제거 (value 기준) // 중복 제거 (value 기준)
const uniqueOptions = options.reduce((acc, option) => { const uniqueOptions = options.reduce(
if (!acc.find(opt => opt.value === option.value)) { (acc, option) => {
if (!acc.find((opt) => opt.value === option.value)) {
acc.push(option); acc.push(option);
} }
return acc; return acc;
}, [] as Array<{ value: string; label: string }>); },
[] as Array<{ value: string; label: string }>,
);
return ( return (
<Select <Select
value={value} value={value}
onValueChange={(val) => { onValueChange={(val) => {
// 선택한 값의 라벨 저장 // 선택한 값의 라벨 저장
const selectedOption = uniqueOptions.find(opt => opt.value === val); const selectedOption = uniqueOptions.find((opt) => opt.value === val);
if (selectedOption) { if (selectedOption) {
setSelectedLabels(prev => ({ setSelectedLabels((prev) => ({
...prev, ...prev,
[filter.columnName]: selectedOption.label, [filter.columnName]: selectedOption.label,
})); }));
@ -308,16 +300,14 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
}} }}
> >
<SelectTrigger <SelectTrigger
className="h-9 min-h-9 text-xs sm:text-sm focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0" className="h-9 min-h-9 text-xs focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
style={{ width: `${width}px`, height: '36px', minHeight: '36px', outline: 'none', boxShadow: 'none' }} style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
> >
<SelectValue placeholder={column?.columnLabel || "선택"} /> <SelectValue placeholder={column?.columnLabel || "선택"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{uniqueOptions.length === 0 ? ( {uniqueOptions.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground"> <div className="text-muted-foreground px-2 py-1.5 text-xs"> </div>
</div>
) : ( ) : (
uniqueOptions.map((option, index) => ( uniqueOptions.map((option, index) => (
<SelectItem key={`${filter.columnName}-${option.value}-${index}`} value={option.value}> <SelectItem key={`${filter.columnName}-${option.value}-${index}`} value={option.value}>
@ -336,8 +326,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
type="text" type="text"
value={value} value={value}
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)} onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
className="h-9 text-xs sm:text-sm focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0" className="h-9 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
style={{ width: `${width}px`, height: '36px', minHeight: '36px', outline: 'none', boxShadow: 'none' }} style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
placeholder={column?.columnLabel} placeholder={column?.columnLabel}
/> />
); );
@ -347,7 +337,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="flex w-full flex-wrap items-center gap-2 border-b bg-card" className="bg-card flex w-full flex-wrap items-center gap-2 border-b"
style={{ style={{
padding: component.style?.padding || "0.75rem", padding: component.style?.padding || "0.75rem",
backgroundColor: component.style?.backgroundColor, backgroundColor: component.style?.backgroundColor,
@ -358,18 +348,11 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
{activeFilters.length > 0 && ( {activeFilters.length > 0 && (
<div className="flex flex-1 flex-wrap items-center gap-2"> <div className="flex flex-1 flex-wrap items-center gap-2">
{activeFilters.map((filter) => ( {activeFilters.map((filter) => (
<div key={filter.columnName}> <div key={filter.columnName}>{renderFilterInput(filter)}</div>
{renderFilterInput(filter)}
</div>
))} ))}
{/* 초기화 버튼 */} {/* 초기화 버튼 */}
<Button <Button variant="outline" size="sm" onClick={handleResetFilters} className="h-9 shrink-0 text-xs sm:text-sm">
variant="outline"
size="sm"
onClick={handleResetFilters}
className="h-9 shrink-0 text-xs sm:text-sm"
>
<X className="mr-1 h-3 w-3 sm:h-4 sm:w-4" /> <X className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button> </Button>
@ -380,10 +363,10 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
{activeFilters.length === 0 && <div className="flex-1" />} {activeFilters.length === 0 && <div className="flex-1" />}
{/* 오른쪽: 데이터 건수 + 설정 버튼들 */} {/* 오른쪽: 데이터 건수 + 설정 버튼들 */}
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex flex-shrink-0 items-center gap-2">
{/* 데이터 건수 표시 */} {/* 데이터 건수 표시 */}
{currentTable?.dataCount !== undefined && ( {currentTable?.dataCount !== undefined && (
<div className="rounded-md bg-muted px-3 py-1.5 text-xs font-medium text-muted-foreground sm:text-sm"> <div className="bg-muted text-muted-foreground rounded-md px-3 py-1.5 text-xs font-medium sm:text-sm">
{currentTable.dataCount.toLocaleString()} {currentTable.dataCount.toLocaleString()}
</div> </div>
)} )}
@ -423,10 +406,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
</div> </div>
{/* 패널들 */} {/* 패널들 */}
<ColumnVisibilityPanel <ColumnVisibilityPanel isOpen={columnVisibilityOpen} onClose={() => setColumnVisibilityOpen(false)} />
isOpen={columnVisibilityOpen}
onClose={() => setColumnVisibilityOpen(false)}
/>
<FilterPanel <FilterPanel
isOpen={filterOpen} isOpen={filterOpen}
onClose={() => setFilterOpen(false)} onClose={() => setFilterOpen(false)}
@ -436,4 +416,3 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
</div> </div>
); );
} }