From b3ad7871790e3b4014dbf65dae786d94b7c5300e Mon Sep 17 00:00:00 2001 From: gbpark Date: Sun, 19 Apr 2026 21:15:25 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EC=99=84=EB=A3=8C=20=EC=84=B8=EC=84=B8?= =?UTF-8?q?=ED=95=9C=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../erp/controller/DashboardController.java | 58 +- .../com/erp/service/DashboardService.java | 39 +- .../src/main/resources/mapper/admin.xml | 3 + .../src/main/resources/mapper/dashboard.xml | 173 ++-- docker/dev/docker-compose.invyone.yml | 6 +- frontend/app/(auth)/login/login.css | 42 +- frontend/app/(auth)/login/page.tsx | 2 +- frontend/app/(main)/[menuSeq]/page.tsx | 79 ++ frontend/app/(main)/admin/builder/page.tsx | 338 ++++--- .../(main)/admin/template-preview/page.tsx | 78 ++ .../(main)/dashboard/[dashboardId]/page.tsx | 313 ------ frontend/app/(main)/dashboard/page.tsx | 273 ------ frontend/app/globals.css | 3 + frontend/app/layout.tsx | 7 + .../components/control/ControlToolbar.tsx | 4 +- .../components/dash/CreateDashboardModal.tsx | 162 ++++ frontend/components/dash/DashboardCanvas.tsx | 19 +- frontend/components/dash/DashboardCard.tsx | 70 +- frontend/components/dash/DashboardEmpty.tsx | 33 +- frontend/components/dash/DashboardLayout.tsx | 112 ++- .../components/dash/TemplateLibraryModal.tsx | 2 +- frontend/components/dash/TemplateRenderer.tsx | 917 ++++++++++++++---- .../dash/TemplateResponsivePreview.tsx | 153 +++ frontend/components/dataflow/DataFlowList.tsx | 2 +- .../components/layout/AdminPageRenderer.tsx | 13 +- frontend/components/layout/AppLayout.tsx | 93 +- .../components/layout/CosmicBackground.tsx | 2 +- frontend/components/layout/EmptyDashboard.tsx | 49 +- frontend/components/layout/SettingsModal.tsx | 100 ++ .../screen/RealtimePreviewDynamic.tsx | 16 +- frontend/components/screen/ScreenDesigner.tsx | 179 +++- .../components/screen/toolbar/SlimToolbar.tsx | 2 +- frontend/hooks/useColorTheme.ts | 40 + frontend/lib/api/dashMenu.ts | 6 - frontend/lib/colorTransition.ts | 45 + .../AccordionBasicComponent.tsx | 2 +- .../components/button/ButtonComponent.tsx | 13 +- .../container/ContainerComponent.tsx | 3 +- .../divider-line/DividerLineComponent.tsx | 3 +- .../components/divider/DividerComponent.tsx | 7 +- .../ApprovalStepComponent.tsx | 12 +- .../image-display/ImageDisplayComponent.tsx | 3 +- .../components/input/InputComponent.tsx | 3 +- .../components/search/SearchComponent.tsx | 3 +- .../slider-basic/SliderBasicComponent.tsx | 3 +- .../components/stats/StatsComponent.tsx | 42 +- .../table-list/TableListComponent.tsx | 7 +- .../components/table/TableComponent.tsx | 3 +- .../test-input/TestInputComponent.tsx | 3 +- .../text-display/TextDisplayComponent.tsx | 2 +- .../components/title/TitleComponent.tsx | 3 +- .../toggle-switch/ToggleSwitchComponent.tsx | 3 +- .../v2-divider-line/DividerLineComponent.tsx | 3 +- .../v2-split-line/SplitLineComponent.tsx | 5 +- .../v2-table-list/TableListComponent.tsx | 7 +- .../v2-text-display/TextDisplayComponent.tsx | 2 +- frontend/lib/registry/responsive-policy.ts | 364 +++++++ frontend/lib/utils/templateAdapter.ts | 161 +++ frontend/stores/dashboardStore.ts | 13 + frontend/styles/control-mode.css | 76 +- frontend/styles/dashboard.css | 54 +- frontend/styles/developer.css | 4 +- frontend/styles/v5-layout.css | 372 +++++-- frontend/types/invyone-component.ts | 51 + frontend/types/screen-management.ts | 10 + .../2026-04-19-dashboard-runtime-fixes.md | 116 +++ .../2026-04-19-menu-dashboard-unification.md | 309 ++++++ ...template-dashboard-integration-progress.md | 54 ++ .../2026-04-19-template-responsive-impl.md | 353 +++++++ .../2026-04-19-template-responsive-policy.md | 232 +++++ 70 files changed, 4366 insertions(+), 1368 deletions(-) create mode 100644 frontend/app/(main)/[menuSeq]/page.tsx create mode 100644 frontend/app/(main)/admin/template-preview/page.tsx delete mode 100644 frontend/app/(main)/dashboard/[dashboardId]/page.tsx delete mode 100644 frontend/app/(main)/dashboard/page.tsx create mode 100644 frontend/components/dash/CreateDashboardModal.tsx create mode 100644 frontend/components/dash/TemplateResponsivePreview.tsx create mode 100644 frontend/components/layout/SettingsModal.tsx create mode 100644 frontend/hooks/useColorTheme.ts create mode 100644 frontend/lib/colorTransition.ts create mode 100644 frontend/lib/registry/responsive-policy.ts create mode 100644 frontend/lib/utils/templateAdapter.ts create mode 100644 notes/gbpark/2026-04-19-dashboard-runtime-fixes.md create mode 100644 notes/gbpark/2026-04-19-menu-dashboard-unification.md create mode 100644 notes/gbpark/2026-04-19-template-dashboard-integration-progress.md create mode 100644 notes/gbpark/2026-04-19-template-responsive-impl.md create mode 100644 notes/gbpark/2026-04-19-template-responsive-policy.md diff --git a/backend-spring/src/main/java/com/erp/controller/DashboardController.java b/backend-spring/src/main/java/com/erp/controller/DashboardController.java index 47dedfb0..3bf81ab9 100644 --- a/backend-spring/src/main/java/com/erp/controller/DashboardController.java +++ b/backend-spring/src/main/java/com/erp/controller/DashboardController.java @@ -15,7 +15,7 @@ public class DashboardController { private final DashboardService dashboardService; - // ═══ 대시보드 CRUD ═══ + // ═══ 대시보드 CRUD (MENU_INFO 단일 본체) ═══ @GetMapping public ResponseEntity>> getDashboardList( @@ -33,11 +33,11 @@ public class DashboardController { return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardList(params))); } - @GetMapping("/{dashboardId}") + @GetMapping("/{objid}") public ResponseEntity>> getDashboardInfo( - @PathVariable String dashboardId) { + @PathVariable String objid) { Map params = new HashMap<>(); - params.put("dashboard_id", dashboardId); + params.put("objid", objid); Map result = dashboardService.getDashboardInfo(params); if (result == null) { return ResponseEntity.ok(ApiResponse.error("대시보드를 찾을 수 없습니다")); @@ -55,23 +55,23 @@ public class DashboardController { return ResponseEntity.ok(ApiResponse.success(dashboardService.insertDashboard(body))); } - @PutMapping("/{dashboardId}") + @PutMapping("/{objid}") public ResponseEntity> updateDashboard( - @PathVariable String dashboardId, + @PathVariable String objid, @RequestAttribute("user_id") String userId, @RequestBody Map body) { - body.put("dashboard_id", dashboardId); + body.put("objid", objid); body.put("user_id", userId); dashboardService.updateDashboard(body); return ResponseEntity.ok(ApiResponse.success(null, "수정 완료")); } - @DeleteMapping("/{dashboardId}") + @DeleteMapping("/{objid}") public ResponseEntity> deleteDashboard( - @PathVariable String dashboardId, + @PathVariable String objid, @RequestAttribute("user_id") String userId) { Map params = new HashMap<>(); - params.put("dashboard_id", dashboardId); + params.put("objid", objid); params.put("user_id", userId); dashboardService.deleteDashboard(params); return ResponseEntity.ok(ApiResponse.success(null, "삭제 완료")); @@ -79,36 +79,36 @@ public class DashboardController { // ═══ 카드 CRUD ═══ - @GetMapping("/{dashboardId}/cards") + @GetMapping("/{objid}/cards") public ResponseEntity>>> getDashboardCards( - @PathVariable String dashboardId) { + @PathVariable String objid) { Map params = new HashMap<>(); - params.put("dashboard_id", dashboardId); + params.put("menu_objid", objid); return ResponseEntity.ok(ApiResponse.success(dashboardService.getDashboardCardList(params))); } - @PostMapping("/{dashboardId}/cards") + @PostMapping("/{objid}/cards") public ResponseEntity>> insertDashboardCard( - @PathVariable String dashboardId, + @PathVariable String objid, @RequestBody Map body) { - body.put("dashboard_id", dashboardId); + body.put("menu_objid", objid); return ResponseEntity.ok(ApiResponse.success(dashboardService.insertDashboardCard(body))); } - @PutMapping("/{dashboardId}/cards/{cardId}") + @PutMapping("/{objid}/cards/{cardId}") public ResponseEntity> updateDashboardCard( - @PathVariable String dashboardId, + @PathVariable String objid, @PathVariable String cardId, @RequestBody Map body) { - body.put("dashboard_id", dashboardId); + body.put("menu_objid", objid); body.put("card_id", cardId); dashboardService.updateDashboardCard(body); return ResponseEntity.ok(ApiResponse.success(null)); } - @DeleteMapping("/{dashboardId}/cards/{cardId}") + @DeleteMapping("/{objid}/cards/{cardId}") public ResponseEntity> deleteDashboardCard( - @PathVariable String dashboardId, + @PathVariable String objid, @PathVariable String cardId) { Map params = new HashMap<>(); params.put("card_id", cardId); @@ -116,23 +116,11 @@ public class DashboardController { return ResponseEntity.ok(ApiResponse.success(null)); } - @PutMapping("/{dashboardId}/cards/batch") + @PutMapping("/{objid}/cards/batch") public ResponseEntity> updateCardPositions( - @PathVariable String dashboardId, + @PathVariable String objid, @RequestBody Map body) { dashboardService.updateCardPositions(body); return ResponseEntity.ok(ApiResponse.success(null, "일괄 업데이트 완료")); } - - // ═══ 사이드바 메뉴 ═══ - - @GetMapping("/sidebar/menu") - public ResponseEntity>>> getSidebarMenu( - @RequestAttribute("company_code") String companyCode, - @RequestAttribute("user_id") String userId) { - Map params = new HashMap<>(); - params.put("company_code", companyCode); - params.put("user_id", userId); - return ResponseEntity.ok(ApiResponse.success(dashboardService.getSidebarMenu(params))); - } } diff --git a/backend-spring/src/main/java/com/erp/service/DashboardService.java b/backend-spring/src/main/java/com/erp/service/DashboardService.java index 9093dbef..1c02e0e2 100644 --- a/backend-spring/src/main/java/com/erp/service/DashboardService.java +++ b/backend-spring/src/main/java/com/erp/service/DashboardService.java @@ -17,7 +17,7 @@ public class DashboardService extends BaseService { private static final String NS = "dashboard."; - // ═══ 대시보드 CRUD ═══ + // ═══ 대시보드 CRUD (MENU_INFO 단일 본체) ═══ public Map getDashboardList(Map params) { commonService.applyPagination(params); @@ -32,17 +32,32 @@ public class DashboardService extends BaseService { @Transactional public Map insertDashboard(Map params) { - String dashboardId = "dash_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12); - params.put("dashboard_id", dashboardId); - if (params.get("icon") == null) { - params.put("icon", "\uD83D\uDCCB"); - } - if (params.get("display_order") == null) { - params.put("display_order", 0); + String objid = String.valueOf(System.currentTimeMillis()); + params.put("objid", objid); + params.put("writer", params.get("user_id")); + + // 개인 대시보드 여부: is_personal=true 이면 USER_ID 채움, 아니면 NULL (회사 공용) + Object isPersonal = params.get("is_personal"); + boolean personal = isPersonal != null + && (Boolean.TRUE.equals(isPersonal) || "true".equalsIgnoreCase(String.valueOf(isPersonal))); + if (!personal) { + params.put("user_id", null); } + + // 회사별 자동 시퀀스 = URL. COMPANY_CODE 내 /숫자 형 URL 중 최대값 + 1 + Integer nextSeq = sqlSession.selectOne(NS + "getNextMenuSeq", params); + if (nextSeq == null) nextSeq = 1; + String menuUrl = "/" + nextSeq; + params.put("seq", String.valueOf(nextSeq)); + params.put("menu_url", menuUrl); + sqlSession.insert(NS + "insertDashboard", params); + Map result = new HashMap<>(); - result.put("dashboard_id", dashboardId); + result.put("objid", objid); + result.put("dashboard_id", objid); // 프론트 하위호환 + result.put("menu_url", menuUrl); + result.put("seq", nextSeq); return result; } @@ -97,10 +112,4 @@ public class DashboardService extends BaseService { } } } - - // ═══ 사이드바 메뉴 ═══ - - public List> getSidebarMenu(Map params) { - return sqlSession.selectList(NS + "getSidebarMenu", params); - } } diff --git a/backend-spring/src/main/resources/mapper/admin.xml b/backend-spring/src/main/resources/mapper/admin.xml index d03428b5..ff0bf30c 100644 --- a/backend-spring/src/main/resources/mapper/admin.xml +++ b/backend-spring/src/main/resources/mapper/admin.xml @@ -333,6 +333,7 @@ , LANG_KEY , LANG_KEY_DESC , MENU_ICON + , USER_ID ) VALUES ( #{objid} , #{menu_type} @@ -348,6 +349,7 @@ , #{lang_key} , #{lang_key_desc} , #{menu_icon} + , #{user_id} ) @@ -382,6 +384,7 @@ WHERE OBJID = #{menu_id} + diff --git a/backend-spring/src/main/resources/mapper/dashboard.xml b/backend-spring/src/main/resources/mapper/dashboard.xml index 015484e7..aa148bf0 100644 --- a/backend-spring/src/main/resources/mapper/dashboard.xml +++ b/backend-spring/src/main/resources/mapper/dashboard.xml @@ -2,117 +2,134 @@ + + + + + - INSERT INTO DASHBOARDS ( - DASHBOARD_ID - , NAME - , ICON - , DISPLAY_ORDER - , COMPANY_CODE - , USER_ID - , IS_ACTIVE - , CREATED_BY + INSERT INTO MENU_INFO ( + OBJID + , MENU_TYPE + , PARENT_OBJ_ID + , MENU_NAME_KOR + , MENU_URL + , SEQ + , WRITER , CREATED_DATE - , UPDATED_BY - , UPDATED_DATE + , STATUS + , COMPANY_CODE + , MENU_ICON + , USER_ID ) VALUES ( - #{dashboard_id} + #{objid} + , '1' + , '0' , #{name} - , #{icon} - , #{display_order} + , #{menu_url} + , #{seq} + , #{writer} + , NOW() + , 'active' , #{company_code} + , COALESCE(#{icon}, '📋') , #{user_id} - , 'Y' - , #{user_id} - , CURRENT_TIMESTAMP - , #{user_id} - , CURRENT_TIMESTAMP ) - UPDATE DASHBOARDS - SET UPDATED_DATE = CURRENT_TIMESTAMP - , UPDATED_BY = #{user_id} - - , NAME = #{name} - - - , ICON = #{icon} - - - , DISPLAY_ORDER = #{display_order} - - WHERE DASHBOARD_ID = #{dashboard_id} - AND IS_ACTIVE = 'Y' + UPDATE MENU_INFO + SET + MENU_NAME_KOR = #{name}, + MENU_ICON = #{icon}, + SEQ = #{display_order}, + OBJID = OBJID + WHERE OBJID = #{objid} + AND MENU_TYPE = '1' - UPDATE DASHBOARDS - SET IS_ACTIVE = 'D' - , UPDATED_DATE = CURRENT_TIMESTAMP - , UPDATED_BY = #{user_id} - WHERE DASHBOARD_ID = #{dashboard_id} + UPDATE MENU_INFO + SET STATUS = 'inactive' + WHERE OBJID = #{objid} + AND MENU_TYPE = '1' INSERT INTO DASHBOARD_CARDS ( CARD_ID - , DASHBOARD_ID + , MENU_OBJID , TEMPLATE_ID , POSITION_X , POSITION_Y @@ -151,7 +168,7 @@ , UPDATED_DATE ) VALUES ( #{card_id} - , #{dashboard_id} + , #{menu_objid} , #{template_id} , #{position_x} , #{position_y} @@ -187,7 +204,7 @@ , DISPLAY_ORDER = #{display_order} WHERE CARD_ID = #{card_id} - AND IS_ACTIVE = 'Y' + AND IS_ACTIVE = 'Y' @@ -199,7 +216,7 @@ , IS_COLLAPSED = #{is_collapsed} , UPDATED_DATE = CURRENT_TIMESTAMP WHERE CARD_ID = #{card_id} - AND IS_ACTIVE = 'Y' + AND IS_ACTIVE = 'Y' @@ -209,18 +226,4 @@ WHERE CARD_ID = #{card_id} - - - - diff --git a/docker/dev/docker-compose.invyone.yml b/docker/dev/docker-compose.invyone.yml index b4a19f9c..36b463ad 100644 --- a/docker/dev/docker-compose.invyone.yml +++ b/docker/dev/docker-compose.invyone.yml @@ -31,10 +31,8 @@ services: environment: SPRING_PROFILES_ACTIVE: dev SERVER_PORT: 8081 - # test_dev DB (211.115.91.141:11134) - SPRING_DATASOURCE_URL: jdbc:postgresql://211.115.91.141:11134/test_dev - SPRING_DATASOURCE_USERNAME: postgres - SPRING_DATASOURCE_PASSWORD: vexplor0909!! + # DB 연결 정보는 backend-spring/src/main/resources/application.yml 사용 (단일 소스). + # env 로 override 하고 싶으면 여기에 SPRING_DATASOURCE_URL/USERNAME/PASSWORD 를 다시 추가. # JWT_SECRET 은 docker/dev/.env 에서 주입 (이 파일은 git 추적, .env 는 gitignored + syncthing 동기화) JWT_SECRET: ${JWT_SECRET:?JWT_SECRET 환경변수 필요. docker/dev/.env 파일 확인} JWT_EXPIRATION: ${JWT_EXPIRATION:-86400000} diff --git a/frontend/app/(auth)/login/login.css b/frontend/app/(auth)/login/login.css index 2ea8957e..e1848bc0 100644 --- a/frontend/app/(auth)/login/login.css +++ b/frontend/app/(auth)/login/login.css @@ -4,14 +4,14 @@ .inv-login { --bg:#fafaff; --bg-subtle:#f3f2fa; --surface:rgba(255,255,255,0.55); --surface-solid:#ffffff; --surface-hover:rgba(255,255,255,0.7); --text:#0f0e1a; --text-sec:#6b6a80; --text-muted:#9998ad; - --primary:#6c5ce7; --primary-light:#a29bfe; --primary-glow:rgba(108,92,231,0.25); - --cyan:#00cec9; --cyan-glow:rgba(0,206,201,0.2); --pink:#fd79a8; --pink-glow:rgba(253,121,168,0.15); + --primary:#6c5ce7; --primary-light:#a29bfe; --primary-glow:rgba(var(--v5-primary-rgb),0.25); + --cyan:#00cec9; --cyan-glow:rgba(var(--v5-cyan-rgb),0.2); --pink:#fd79a8; --pink-glow:rgba(var(--v5-pink-rgb),0.15); --red:#ff4757; --green:#00b894; --amber:#fdcb6e; - --border:rgba(108,92,231,0.12); --border-subtle:rgba(0,0,0,0.05); + --border:rgba(var(--v5-primary-rgb),0.12); --border-subtle:rgba(0,0,0,0.05); --glass:rgba(255,255,255,0.45); --glass-strong:rgba(255,255,255,0.65); - --glass-border:rgba(108,92,231,0.12); - --glow-sm:0 0 20px rgba(108,92,231,0.12); --glow-md:0 0 40px rgba(108,92,231,0.2); - --glow-lg:0 0 80px rgba(108,92,231,0.25); + --glass-border:rgba(var(--v5-primary-rgb),0.12); + --glow-sm:0 0 20px rgba(var(--v5-primary-rgb),0.12); --glow-md:0 0 40px rgba(var(--v5-primary-rgb),0.2); + --glow-lg:0 0 80px rgba(var(--v5-primary-rgb),0.25); position:fixed;inset:0;z-index:1; font-family:'Inter',system-ui,sans-serif; @@ -24,14 +24,14 @@ .inv-login.dark { --bg:#06050e; --bg-subtle:#0c0b18; --surface:rgba(17,16,42,0.5); --surface-solid:#11102a; --surface-hover:rgba(25,24,64,0.6); --text:#eae8f4; --text-sec:#8d8ba8; --text-muted:#5a587a; - --primary:#a29bfe; --primary-light:#c8c4ff; --primary-glow:rgba(162,155,254,0.25); - --cyan:#55efc4; --cyan-glow:rgba(85,239,196,0.15); --pink:#fd79a8; --red:#ff6b6b; + --primary:#a29bfe; --primary-light:#c8c4ff; --primary-glow:rgba(var(--v5-primary-rgb),0.25); + --cyan:#55efc4; --cyan-glow:rgba(var(--v5-cyan-rgb),0.15); --pink:#fd79a8; --red:#ff6b6b; --green:#55efc4; --amber:#ffeaa7; - --border:rgba(162,155,254,0.1); --border-subtle:rgba(255,255,255,0.04); + --border:rgba(var(--v5-primary-rgb),0.1); --border-subtle:rgba(255,255,255,0.04); --glass:rgba(17,16,42,0.45); --glass-strong:rgba(17,16,42,0.65); - --glass-border:rgba(162,155,254,0.12); - --glow-sm:0 0 20px rgba(162,155,254,0.1); --glow-md:0 0 40px rgba(162,155,254,0.18); - --glow-lg:0 0 80px rgba(162,155,254,0.22); + --glass-border:rgba(var(--v5-primary-rgb),0.12); + --glow-sm:0 0 20px rgba(var(--v5-primary-rgb),0.1); --glow-md:0 0 40px rgba(var(--v5-primary-rgb),0.18); + --glow-lg:0 0 80px rgba(var(--v5-primary-rgb),0.22); } /* ===== COSMIC BACKGROUND ===== */ @@ -50,7 +50,7 @@ .inv-login .neb-1{width:700px;height:700px;top:-20%;right:-15%;background:radial-gradient(circle,var(--primary-glow),transparent 70%);animation-duration:18s;} .inv-login .neb-2{width:600px;height:600px;bottom:-25%;left:-10%;background:radial-gradient(circle,var(--cyan-glow),transparent 70%);animation-duration:14s;animation-delay:-4s;} .inv-login .neb-3{width:450px;height:450px;top:35%;left:40%;background:radial-gradient(circle,var(--pink-glow),transparent 70%);animation-duration:12s;animation-delay:-8s;} -.inv-login .neb-4{width:350px;height:350px;top:60%;right:25%;background:radial-gradient(circle,rgba(108,92,231,0.08),transparent 70%);animation-duration:20s;animation-delay:-2s;} +.inv-login .neb-4{width:350px;height:350px;top:60%;right:25%;background:radial-gradient(circle,rgba(var(--v5-primary-rgb),0.08),transparent 70%);animation-duration:20s;animation-delay:-2s;} @keyframes inv-drift{0%{transform:translate(0,0) scale(1)}100%{transform:translate(30px,-25px) scale(1.1)}} .inv-login:not(.dark) .cosmos{background:linear-gradient(180deg,#e8e4ff 0%,#f0edff 30%,#fafaff 60%,#f5f0ff 100%);} @@ -62,7 +62,7 @@ .inv-login:not(.dark) .neb-3{width:800px;height:350px;top:auto;bottom:5%;left:30%; background:radial-gradient(ellipse,rgba(255,255,255,0.8),rgba(240,220,255,0.3),transparent 70%);animation-duration:22s;} .inv-login:not(.dark) .neb-4{width:600px;height:600px;top:-10%;right:20%;bottom:auto; - background:radial-gradient(circle,rgba(108,92,231,0.08),rgba(0,206,201,0.04),transparent 70%);} + background:radial-gradient(circle,rgba(var(--v5-primary-rgb),0.08),rgba(var(--v5-cyan-rgb),0.04),transparent 70%);} .inv-login .shooting-star{position:absolute;width:80px;height:1px; background:linear-gradient(90deg,rgba(255,255,255,0.6),transparent); @@ -100,17 +100,17 @@ animation:inv-cardIn 1s cubic-bezier(.16,1,.3,1) .3s both;position:relative;z-index:5; } .inv-login:not(.dark) .login-card{background:rgba(255,255,255,0.82);} -.inv-login.dark .login-card{box-shadow:0 12px 48px rgba(0,0,0,0.6),var(--glow-md),inset 0 0 0 1px rgba(162,155,254,0.06);} +.inv-login.dark .login-card{box-shadow:0 12px 48px rgba(0,0,0,0.6),var(--glow-md),inset 0 0 0 1px rgba(var(--v5-primary-rgb),0.06);} @keyframes inv-cardIn{from{opacity:0;transform:translateY(40px) scale(.96)}to{opacity:1;transform:none}} /* Orbital decoration */ .inv-login .login-orbit{width:80px;height:80px;margin:0 auto 1rem;position:relative;animation:inv-logoIn 1.2s cubic-bezier(.16,1,.3,1) .4s both;} .inv-login .orbit-core{position:absolute;inset:22px;border-radius:50%; background:linear-gradient(135deg,var(--primary),var(--cyan)); - box-shadow:0 0 30px var(--primary-glow),0 0 60px rgba(0,206,201,0.15); + box-shadow:0 0 30px var(--primary-glow),0 0 60px rgba(var(--v5-cyan-rgb),0.15); animation:inv-corePulse 3s ease-in-out infinite;} -@keyframes inv-corePulse{0%,100%{box-shadow:0 0 30px var(--primary-glow),0 0 60px rgba(0,206,201,0.15)} - 50%{box-shadow:0 0 40px var(--primary-glow),0 0 80px rgba(0,206,201,0.25)}} +@keyframes inv-corePulse{0%,100%{box-shadow:0 0 30px var(--primary-glow),0 0 60px rgba(var(--v5-cyan-rgb),0.15)} + 50%{box-shadow:0 0 40px var(--primary-glow),0 0 80px rgba(var(--v5-cyan-rgb),0.25)}} .inv-login .orbit-ring{position:absolute;inset:0;border-radius:50%;border:1.5px solid transparent; border-top-color:var(--primary);border-right-color:var(--cyan); animation:inv-orbitSpin 4s linear infinite;opacity:.6;} @@ -124,7 +124,7 @@ /* Fail shake */ @keyframes inv-denied{ - 0%{transform:scale(1.1);box-shadow:0 0 60px rgba(255,71,87,0.4),0 0 0 2px rgba(255,71,87,0.5),inset 0 0 30px rgba(255,71,87,0.05)} + 0%{transform:scale(1.1);box-shadow:0 0 60px rgba(var(--v5-red-rgb),0.4),0 0 0 2px rgba(var(--v5-red-rgb),0.5),inset 0 0 30px rgba(var(--v5-red-rgb),0.05)} 18%{transform:scale(1) translateX(-6px)} 36%{transform:scale(1) translateX(5px)} 52%{transform:scale(1) translateX(-4px)} @@ -166,7 +166,7 @@ background-color:#ffffff !important; backdrop-filter:none !important; -webkit-backdrop-filter:none !important; - border-color:rgba(108,92,231,0.18) !important; + border-color:rgba(var(--v5-primary-rgb),0.18) !important; color:#0f0e1a !important; } .inv-login:not(.dark) input.fi:-webkit-autofill, @@ -177,7 +177,7 @@ } .inv-login .fi::placeholder{color:var(--text-muted);font-weight:400;} .inv-login .fi:focus{border-color:var(--primary);box-shadow:0 0 0 4px var(--primary-glow),var(--glow-sm);} -.inv-login .fi.error{border-color:rgba(255,71,87,0.4) !important;box-shadow:0 0 12px rgba(255,71,87,0.12) !important;} +.inv-login .fi.error{border-color:rgba(var(--v5-red-rgb),0.4) !important;box-shadow:0 0 12px rgba(var(--v5-red-rgb),0.12) !important;} .inv-login .pw-w .fi{padding-right:2.8rem;} .inv-login .pw-w{position:relative;display:flex;align-items:center;} .inv-login .pw-b{position:absolute;right:14px;top:50%;transform:translateY(-50%);background:none;border:none; diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index e2b348b8..df33301a 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -39,7 +39,7 @@ export default function LoginPage() { useEffect(() => { const co = cosmosRef.current; if (!co) return; - const cs = ["rgba(162,155,254,.8)", "rgba(85,239,196,.7)", "rgba(253,121,168,.7)"]; + const cs = ["rgba(var(--v5-primary-rgb),.8)", "rgba(var(--v5-cyan-rgb),.7)", "rgba(var(--v5-pink-rgb),.7)"]; for (let i = 0; i < 150; i++) { const s = document.createElement("div"); s.className = "star" + (Math.random() > 0.83 ? " c" : ""); diff --git a/frontend/app/(main)/[menuSeq]/page.tsx b/frontend/app/(main)/[menuSeq]/page.tsx new file mode 100644 index 00000000..b8d06ec3 --- /dev/null +++ b/frontend/app/(main)/[menuSeq]/page.tsx @@ -0,0 +1,79 @@ +"use client"; + +// INVYONE 대시보드(=사용자 메뉴) 뷰 +// - URL: /{seq} (회사별 자동 시퀀스, 메뉴 그 자체) +// - menuSeq 로 사용자 대시보드 목록에서 매칭 → OBJID 획득 → DashboardLayout 렌더 + +import { use, useEffect, useState } from "react"; +import { DashboardLayout } from "@/components/dash/DashboardLayout"; +import { getDashboardList } from "@/lib/api/dashMenu"; + +interface PageProps { + params: Promise<{ menuSeq: string }>; +} + +export default function MenuSeqPage({ params }: PageProps) { + const { menuSeq } = use(params); + const isNumeric = /^\d+$/.test(menuSeq); + + const [objid, setObjid] = useState(null); + const [loading, setLoading] = useState(true); + const [missing, setMissing] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + + useEffect(() => { + if (!isNumeric) { + setLoading(false); + setMissing(true); + return; + } + (async () => { + try { + console.log("[MenuSeq] 조회 시작:", menuSeq); + const result = await getDashboardList(); + console.log("[MenuSeq] getDashboardList 응답:", result); + const list: Record[] = result?.list ?? []; + const wanted = `/${menuSeq}`; + const match = list.find((d) => (d.menu_url ?? d.MENU_URL) === wanted); + console.log("[MenuSeq] 매칭:", { wanted, matched: match, listCount: list.length }); + if (match) { + setObjid(String(match.objid ?? match.OBJID)); + } else { + setMissing(true); + setErrorMsg(`'/${menuSeq}' 에 해당하는 대시보드를 목록(${list.length}개)에서 찾지 못했습니다.`); + } + } catch (err: any) { + console.error("[MenuSeq] 조회 실패:", err); + setMissing(true); + setErrorMsg(`대시보드 목록 조회 실패: ${err?.response?.status ?? ""} ${err?.message ?? err}`); + } finally { + setLoading(false); + } + })(); + }, [menuSeq, isNumeric]); + + if (loading) { + return ( +
+ 대시보드 로드 중... +
+ ); + } + + if (missing || !objid) { + return ( +
+
📋
+
+ 대시보드를 찾을 수 없습니다 +
+
+ 경로: /{menuSeq} + {errorMsg ?
{errorMsg}
: null} +
+
+ ); + } + + return ; +} diff --git a/frontend/app/(main)/admin/builder/page.tsx b/frontend/app/(main)/admin/builder/page.tsx index 60462d77..9912ba99 100644 --- a/frontend/app/(main)/admin/builder/page.tsx +++ b/frontend/app/(main)/admin/builder/page.tsx @@ -1,37 +1,75 @@ "use client"; -// INVYONE 빌더 진입 화면 -// - 템플릿 목록 + 새 템플릿 생성 -// - URL 에 ?id=xxx 가 있으면 빌더로 바로 진입 -// - 없으면 템플릿 선택/생성 UI 표시 +// INVYONE 스튜디오 진입 페이지 (templates 테이블 기반) +// - 템플릿 목록 + 새 템플릿 생성 → templates 테이블 CRUD +// - URL ?id= 로 바로 진입 +// - ScreenDesigner 는 template_id 를 통해 templates API 로 저장/로드 import { Suspense, useState, useEffect, useCallback } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import ScreenDesigner from "@/components/screen/ScreenDesigner"; import type { ScreenDefinition } from "@/types/screen"; -import { screenApi } from "@/lib/api/screen"; +import { getTemplateList, deleteTemplate } from "@/lib/api/template"; +import { createTemplate } from "@/lib/utils/templateAdapter"; import { useAuth } from "@/hooks/useAuth"; -import { Plus, LayoutTemplate, Search, FileText } from "lucide-react"; +import { Plus, LayoutTemplate, Search, FileText, Trash2 } from "lucide-react"; import { Input } from "@/components/ui/input"; +import { toast } from "sonner"; + +const CATEGORIES: { id: string; label: string }[] = [ + { id: "", label: "전체" }, + { id: "sales", label: "영업/CRM" }, + { id: "production", label: "생산/공정" }, + { id: "hr", label: "인사/급여" }, + { id: "inventory", label: "재고/물류" }, + { id: "finance", label: "재무/회계" }, + { id: "admin", label: "관리자" }, + { id: "custom", label: "기타" }, +]; + +function templateToScreenDef(t: Record): ScreenDefinition { + const templateId = t.template_id ?? t.TEMPLATE_ID; + return { + // ScreenDesigner 가 screen_id 를 옵셔널하게 참조하도록 undefined 허용 + screen_id: undefined as any, + screen_name: t.name ?? t.NAME ?? "", + screen_code: "", + table_name: t.primary_table ?? t.PRIMARY_TABLE ?? "", + company_code: (t.company_code ?? t.COMPANY_CODE ?? "") as any, + description: t.description ?? t.DESCRIPTION ?? "", + is_active: "Y" as any, + created_date: new Date(), + updated_date: new Date(), + template_id: templateId, + is_template_mode: true, + template_category: t.category ?? t.CATEGORY ?? "", + template_status: (t.status ?? t.STATUS) as any, + }; +} function TemplateGallery({ templates, onSelect, onCreate, + onDelete, loading, }: { - templates: ScreenDefinition[]; - onSelect: (t: ScreenDefinition) => void; + templates: Record[]; + onSelect: (t: Record) => void; onCreate: () => void; + onDelete: (t: Record) => void; loading: boolean; }) { const [query, setQuery] = useState(""); + const [activeCategory, setActiveCategory] = useState(""); + const filtered = templates.filter((t) => { + const name = (t.name ?? t.NAME ?? "").toLowerCase(); + const cat = (t.category ?? t.CATEGORY ?? "").toLowerCase(); + const desc = (t.description ?? t.DESCRIPTION ?? "").toLowerCase(); const q = query.toLowerCase(); - return ( - !q || - (t.screen_name || "").toLowerCase().includes(q) || - (t.screen_code || "").toLowerCase().includes(q) - ); + const matchQ = !q || name.includes(q) || desc.includes(q); + const matchCat = !activeCategory || cat === activeCategory; + return matchQ && matchCat; }); return ( @@ -42,7 +80,7 @@ function TemplateGallery({

템플릿

- 기존 템플릿을 열거나 새로 만들어보세요 + INVYONE 스튜디오 — 기존 템플릿을 열거나 새로 만드세요

- {/* 검색 */} -
- - setQuery(e.target.value)} - className="pl-9 h-9" - /> + {/* 검색 + 카테고리 */} +
+
+ + setQuery(e.target.value)} + className="pl-9 h-9" + /> +
+
+ {CATEGORIES.map((cat) => ( + + ))} +
{/* 로딩 */} @@ -76,7 +134,10 @@ function TemplateGallery({ {/* 빈 상태 */} {!loading && templates.length === 0 && (
- +

아직 템플릿이 없습니다

@@ -88,38 +149,66 @@ function TemplateGallery({ onClick={onCreate} className="rounded-md border border-primary/30 bg-primary/10 px-4 py-2 text-sm font-semibold text-primary hover:bg-primary/15" > - - 새 템플릿 만들기 + 새 템플릿 만들기
)} - {/* 템플릿 그리드 */} + {/* 그리드 */} {!loading && filtered.length > 0 && (
- {filtered.map((t) => ( - +
-

- {t.screen_name} -

- {t.screen_code && ( -

- {t.screen_code} -

- )} -

- {(t as any).description || "설명 없음"} -

- - ))} + ); + })}
)} @@ -140,9 +229,10 @@ function CreateTemplateModal({ }: { open: boolean; onClose: () => void; - onCreate: (name: string) => Promise; + onCreate: (name: string, category: string) => Promise; }) { const [name, setName] = useState(""); + const [category, setCategory] = useState("custom"); const [creating, setCreating] = useState(false); if (!open) return null; @@ -152,24 +242,37 @@ function CreateTemplateModal({

새 템플릿

- 템플릿 이름을 입력하세요. 나중에 각 컴포넌트에서 테이블을 선택합니다. + 템플릿 이름과 카테고리를 입력하세요.

- setName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && name.trim() && !creating) { - setCreating(true); - onCreate(name.trim()).finally(() => setCreating(false)); - } else if (e.key === "Escape") { - onClose(); - } - }} - className="mb-4 h-10" - /> -
+
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && name.trim() && !creating) { + setCreating(true); + onCreate(name.trim(), category).finally(() => setCreating(false)); + } else if (e.key === "Escape") { + onClose(); + } + }} + className="h-10" + /> + +
+