From 3eda684787b3056526bac64cb82462dabde5a01d Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 22 Apr 2026 18:27:06 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EA=B8=B0=EB=8A=A5=EA=B0=95?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EC=9D=B8=EB=B9=84=EC=98=A8=20=EC=8A=A4?= =?UTF-8?q?=ED=8A=9C=EB=94=94=EC=98=A4=20=EB=A9=94=EB=89=B4=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=9E=90=EC=9E=98=ED=95=9C=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/erp/service/EntityJoinService.java | 140 +- frontend/app/(main)/admin/menu/page.tsx | 13 +- frontend/app/form-popup/page.tsx | 232 +++ frontend/app/globals.css | 85 +- frontend/components/admin/MenuFormModal.tsx | 5 +- frontend/components/admin/MenuIconPicker.tsx | 28 +- .../admin/menu/MenuSettingsPanel.tsx | 91 +- .../components/admin/menu/MenuTreePanel.tsx | 38 +- frontend/components/dash/DashboardCanvas.tsx | 690 +++++++-- frontend/components/dash/DashboardCard.tsx | 247 ++-- frontend/components/dash/DashboardLayout.tsx | 29 +- frontend/components/dash/TemplateRenderer.tsx | 54 + .../dash/TemplateResponsivePreview.tsx | 1 + .../screen/OptimizedButtonComponent.tsx | 2 +- frontend/components/screen/ScreenDesigner.tsx | 725 ++++++++- frontend/components/screen/ViewTabBar.tsx | 21 +- .../ButtonDataflowConfigPanel.tsx | 2 +- .../screen/panels/V2PropertiesPanel.tsx | 7 +- .../components/screen/toolbar/SlimToolbar.tsx | 12 +- .../v2/config-panels/V2FieldConfigPanel.tsx | 188 ++- frontend/lib/api/client.ts | 8 +- frontend/lib/api/dashboard.ts | 20 +- frontend/lib/layout/lineLayout.ts | 104 +- .../lib/registry/DynamicComponentRenderer.tsx | 10 +- .../components/button/ButtonComponent.tsx | 49 + .../components/button/ButtonConfigPanel.tsx | 2 +- .../components/search/SearchComponent.tsx | 40 +- .../components/search/SearchConfigPanel.tsx | 103 +- .../components/table/TableComponent.tsx | 9 + frontend/lib/utils/templateAdapter.ts | 22 + frontend/styles/dashboard.css | 118 +- frontend/styles/v5-layout.css | 114 +- .../gbpark/2026-04-22-company-mng-mockup.html | 1315 +++++++++++++++++ 33 files changed, 3964 insertions(+), 560 deletions(-) create mode 100644 frontend/app/form-popup/page.tsx create mode 100644 notes/gbpark/2026-04-22-company-mng-mockup.html diff --git a/backend-spring/src/main/java/com/erp/service/EntityJoinService.java b/backend-spring/src/main/java/com/erp/service/EntityJoinService.java index 834459fe..f3b6e731 100644 --- a/backend-spring/src/main/java/com/erp/service/EntityJoinService.java +++ b/backend-spring/src/main/java/com/erp/service/EntityJoinService.java @@ -550,15 +550,147 @@ public class EntityJoinService extends BaseService { if (searchConditions == null || searchConditions.isEmpty()) return ""; StringBuilder sb = new StringBuilder(); for (Map.Entry e : searchConditions.entrySet()) { - if (e.getValue() == null) continue; + if (isKeywordSearchKey(e.getKey())) { + SearchCondition condition = normalizeSearchCondition(e.getValue()); + if (condition == null || isBlankValue(condition.value())) continue; + appendKeywordSearchCondition(sb, params, tableName, condition); + continue; + } if (!IDENTIFIER_PATTERN.matcher(e.getKey()).matches()) continue; + SearchCondition condition = normalizeSearchCondition(e.getValue()); + if (condition == null) continue; if (sb.length() > 0) sb.append(" AND "); - sb.append("main.\"").append(e.getKey()).append("\" = ?"); - params.add(e.getValue()); + appendSearchCondition(sb, params, e.getKey(), condition); } return sb.toString(); } + @SuppressWarnings("unchecked") + private SearchCondition normalizeSearchCondition(Object rawValue) { + if (rawValue == null) return null; + + if (rawValue instanceof Map rawMap) { + Map map = (Map) rawMap; + String operator = String.valueOf(map.getOrDefault("operator", "contains")); + Object value = map.get("value"); + + if (!"isEmpty".equalsIgnoreCase(operator) + && !"isNotEmpty".equalsIgnoreCase(operator) + && isBlankValue(value)) { + return null; + } + + return new SearchCondition(operator, value); + } + + if (isBlankValue(rawValue)) return null; + return new SearchCondition("equals", rawValue); + } + + private void appendSearchCondition(StringBuilder sb, + List params, + String columnName, + SearchCondition condition) { + String op = condition.operator(); + Object value = condition.value(); + String colExpr = "main.\"" + columnName + "\""; + + switch (op.toLowerCase()) { + case "contains" -> { + sb.append(colExpr).append("::text ILIKE ?"); + params.add("%" + value + "%"); + } + case "startswith" -> { + sb.append(colExpr).append("::text ILIKE ?"); + params.add(value + "%"); + } + case "endswith" -> { + sb.append(colExpr).append("::text ILIKE ?"); + params.add("%" + value); + } + case "notequals" -> { + sb.append("COALESCE(").append(colExpr).append("::text, '') <> ?"); + params.add(String.valueOf(value)); + } + case "isempty" -> sb.append("(").append(colExpr).append(" IS NULL OR ") + .append(colExpr).append("::text = '')"); + case "isnotempty" -> sb.append("(").append(colExpr).append(" IS NOT NULL AND ") + .append(colExpr).append("::text <> '')"); + case "greaterthan" -> { + sb.append(colExpr).append("::text > ?"); + params.add(String.valueOf(value)); + } + case "lessthan" -> { + sb.append(colExpr).append("::text < ?"); + params.add(String.valueOf(value)); + } + case "equals", "=", "exact" -> { + sb.append(colExpr).append("::text = ?"); + params.add(String.valueOf(value)); + } + default -> { + sb.append(colExpr).append("::text ILIKE ?"); + params.add("%" + value + "%"); + } + } + } + + private void appendKeywordSearchCondition(StringBuilder sb, + List params, + String tableName, + SearchCondition condition) { + List columns = getKeywordSearchColumns(tableName); + if (columns.isEmpty()) return; + + if (sb.length() > 0) sb.append(" AND "); + sb.append("("); + for (int i = 0; i < columns.size(); i++) { + if (i > 0) sb.append(" OR "); + sb.append("main.\"").append(columns.get(i)).append("\"::text ILIKE ?"); + params.add("%" + String.valueOf(condition.value()) + "%"); + } + sb.append(")"); + } + + private List getKeywordSearchColumns(String tableName) { + validateIdentifier(tableName); + + String sql = """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = ? + ORDER BY ordinal_position + """; + + List allColumns = jdbcTemplate.queryForList(sql, String.class, tableName); + + return allColumns.stream() + .filter(Objects::nonNull) + .filter(col -> IDENTIFIER_PATTERN.matcher(col).matches()) + .filter(col -> !Set.of( + "company_code", + "created_date", + "updated_date", + "created_at", + "updated_at", + "create_user", + "update_user" + ).contains(col)) + .toList(); + } + + private boolean isKeywordSearchKey(String key) { + return "keyword".equalsIgnoreCase(key) + || "search".equalsIgnoreCase(key) + || "searchterm".equalsIgnoreCase(key) + || "q".equalsIgnoreCase(key); + } + + private boolean isBlankValue(Object value) { + return value == null || value.toString().isBlank() || "__ALL__".equals(value.toString()); + } + private void validateIdentifier(String name) { if (name == null || !IDENTIFIER_PATTERN.matcher(name).matches()) { throw new IllegalArgumentException("유효하지 않은 식별자: " + name); @@ -586,4 +718,6 @@ public class EntityJoinService extends BaseService { return total == 0 ? 0.0 : (double) hits / total; } } + + private record SearchCondition(String operator, Object value) {} } diff --git a/frontend/app/(main)/admin/menu/page.tsx b/frontend/app/(main)/admin/menu/page.tsx index dc5bb15f..8057336b 100644 --- a/frontend/app/(main)/admin/menu/page.tsx +++ b/frontend/app/(main)/admin/menu/page.tsx @@ -24,10 +24,9 @@ import { MenuScopePanel } from "@/components/admin/menu/MenuScopePanel"; import { MenuTreePanel } from "@/components/admin/menu/MenuTreePanel"; import { MenuOverviewPanel } from "@/components/admin/menu/MenuOverviewPanel"; import { MenuSettingsPanel } from "@/components/admin/menu/MenuSettingsPanel"; -import { MenuActivityPanel } from "@/components/admin/menu/MenuActivityPanel"; type Scope = "admin" | "user"; -type View = "overview" | "settings" | "activity"; +type View = "overview" | "settings"; export default function MenuPage() { const { refreshMenus } = useMenu(); @@ -412,15 +411,6 @@ export default function MenuPage() { Settings - @@ -445,7 +435,6 @@ export default function MenuPage() { )} - {view === "activity" && } diff --git a/frontend/app/form-popup/page.tsx b/frontend/app/form-popup/page.tsx new file mode 100644 index 00000000..107b0635 --- /dev/null +++ b/frontend/app/form-popup/page.tsx @@ -0,0 +1,232 @@ +'use client'; + +/** + * Form Popup — 등록/수정 팝업 창 (window.open 으로 열리는 별도 브라우저 창). + * + * URL: /form-popup?templateId={id}&mode={create|edit}&key={uniqueKey} + * + * 초기 데이터는 부모 창(DashboardCard)이 localStorage 에 임시 저장하고, + * 팝업이 mount 시 key 로 읽어 즉시 삭제한다. 저장 성공 시 부모 창에 + * postMessage({ type: 'form-popup-saved', key, mode }) 를 보내고 자동 닫힘. + * + * (main) 레이아웃 바깥이라 헤더/사이드바 없이 깔끔한 팝업 UI. + */ + +import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { toast } from 'sonner'; +import { X } from 'lucide-react'; +import { FcForm } from '@/components/fc'; +import { getTemplateInfo } from '@/lib/api/template'; +import { fcInsert, fcUpdate } from '@/lib/api/fcData'; +import { + TemplateRenderer, + type TemplateRenderContext, +} from '@/components/dash/TemplateRenderer'; +import type { FieldConfig, Template } from '@/types/invyone-component'; + +export default function FormPopupPage() { + return ( + 로딩 중...}> + + + ); +} + +function FormPopupContent() { + const params = useSearchParams(); + const templateId = params.get('templateId') ?? ''; + const mode = (params.get('mode') ?? 'create') as 'create' | 'edit'; + const key = params.get('key') ?? ''; + + const [template, setTemplate] = useState