Merge pull request 'Merge branch 'main' into hjjeong' (#8) from hjjeong into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m35s

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-05-12 02:48:19 +00:00
10 changed files with 467 additions and 20 deletions
@@ -0,0 +1,73 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.service.FavoritesService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/favorites")
@RequiredArgsConstructor
@Slf4j
public class FavoritesController {
private final FavoritesService favoritesService;
/**
* GET /api/favorites/menus
* 로그인 사용자의 즐겨찾기 메뉴 목록.
*/
@GetMapping("/menus")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getMyFavorites(
@RequestAttribute("user_id") String userId) {
Map<String, Object> params = new HashMap<>();
params.put("user_id", userId);
return ResponseEntity.ok(ApiResponse.success(
favoritesService.getFavoriteMenuList(params),
"즐겨찾기 메뉴 조회 성공"));
}
/**
* POST /api/favorites/menus
* 즐겨찾기 추가. body: { menu_objid, sort_order? }
*/
@PostMapping("/menus")
public ResponseEntity<ApiResponse<Map<String, Object>>> addFavorite(
@RequestAttribute("user_id") String userId,
@RequestBody Map<String, Object> body) {
Object menuObjid = body.get("menu_objid");
if (menuObjid == null || String.valueOf(menuObjid).isBlank()) {
return ResponseEntity.badRequest().body(ApiResponse.error("menu_objid 필수입니다."));
}
Map<String, Object> params = new HashMap<>();
params.put("user_id", userId);
params.put("menu_objid", String.valueOf(menuObjid));
params.put("sort_order", body.get("sort_order"));
return ResponseEntity.ok(ApiResponse.success(
favoritesService.insertFavorite(params),
"즐겨찾기 추가 성공"));
}
/**
* DELETE /api/favorites/menus/{menuObjid}
* 즐겨찾기 제거.
*/
@DeleteMapping("/menus/{menuObjid}")
public ResponseEntity<ApiResponse<Map<String, Object>>> removeFavorite(
@RequestAttribute("user_id") String userId,
@PathVariable String menuObjid) {
Map<String, Object> params = new HashMap<>();
params.put("user_id", userId);
params.put("menu_objid", menuObjid);
int affected = favoritesService.deleteFavorite(params);
Map<String, Object> result = new HashMap<>();
result.put("deleted", affected);
return ResponseEntity.ok(ApiResponse.success(result, "즐겨찾기 제거 성공"));
}
}
@@ -91,6 +91,21 @@ public class StartupSchemaMigrator {
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ERP_MANAGED",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS DATA_TYPE",
// V020: 사용자별 메뉴 즐겨찾기 테이블.
// 메타 DB 는 Flyway V020 으로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
// CREATE IF NOT EXISTS 로 멱등성 보장.
"""
CREATE TABLE IF NOT EXISTS USER_MENU_FAVORITES (
OBJID BIGSERIAL PRIMARY KEY,
USER_ID VARCHAR(100) NOT NULL,
MENU_OBJID VARCHAR(50) NOT NULL,
SORT_ORDER INTEGER NOT NULL DEFAULT 0,
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT UQ_USER_MENU_FAVORITES UNIQUE (USER_ID, MENU_OBJID)
)
""",
"CREATE INDEX IF NOT EXISTS IDX_USER_MENU_FAVORITES_USER ON USER_MENU_FAVORITES (USER_ID)",
// RUN_086 (1) btree_gist 확장 — USER_SUBSTITUTES 의 EXCLUDE 제약 의존성
"CREATE EXTENSION IF NOT EXISTS btree_gist",
@@ -0,0 +1,38 @@
package com.erp.service;
import com.erp.common.BaseService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@Slf4j
public class FavoritesService extends BaseService {
public List<Map<String, Object>> getFavoriteMenuList(Map<String, Object> params) {
return sqlSession.selectList("favorites.selectFavoriteMenuList", params);
}
@Transactional
public Map<String, Object> insertFavorite(Map<String, Object> params) {
sqlSession.insert("favorites.insertFavorite", params);
Map<String, Object> result = new HashMap<>();
result.put("user_id", params.get("user_id"));
result.put("menu_objid", params.get("menu_objid"));
return result;
}
@Transactional
public int deleteFavorite(Map<String, Object> params) {
return sqlSession.delete("favorites.deleteFavorite", params);
}
public boolean exists(Map<String, Object> params) {
Integer cnt = sqlSession.selectOne("favorites.selectFavoriteExists", params);
return cnt != null && cnt > 0;
}
}
@@ -0,0 +1,16 @@
-- V020: 사용자별 메뉴 즐겨찾기 테이블
-- 로그인 사용자가 사이드바 메뉴 항목을 즐겨찾기에 등록/해제하면 한 행씩 쌓이고,
-- 사이드바 최상단 '즐겨찾기' 섹션이 이 행들을 읽어 표시한다.
-- 테넌트 DB 별로 격리 (회사마다 메뉴가 달라 cross-tenant 공용으로 묶지 않음).
CREATE TABLE IF NOT EXISTS USER_MENU_FAVORITES (
OBJID BIGSERIAL PRIMARY KEY,
USER_ID VARCHAR(100) NOT NULL,
MENU_OBJID VARCHAR(50) NOT NULL,
SORT_ORDER INTEGER NOT NULL DEFAULT 0,
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT UQ_USER_MENU_FAVORITES UNIQUE (USER_ID, MENU_OBJID)
);
CREATE INDEX IF NOT EXISTS IDX_USER_MENU_FAVORITES_USER
ON USER_MENU_FAVORITES (USER_ID);
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="favorites">
<!-- ================================================================
사용자별 메뉴 즐겨찾기
USER_MENU_FAVORITES (USER_ID, MENU_OBJID) UNIQUE
================================================================ -->
<!-- 내 즐겨찾기 메뉴 목록 (MENU_INFO 와 JOIN, 활성 메뉴만) -->
<select id="selectFavoriteMenuList" parameterType="map" resultType="map">
SELECT
UMF.OBJID AS favorite_objid
, UMF.USER_ID AS user_id
, UMF.MENU_OBJID AS menu_objid
, UMF.SORT_ORDER AS sort_order
, UMF.CREATED_AT AS created_at
, MENU.MENU_NAME_KOR AS menu_name_kor
, MENU.MENU_URL AS menu_url
, MENU.MENU_ICON AS menu_icon
, MENU.PARENT_OBJ_ID AS parent_obj_id
, MENU.MENU_TYPE AS menu_type
, MENU.COMPANY_CODE AS company_code
FROM USER_MENU_FAVORITES UMF
JOIN MENU_INFO MENU ON MENU.OBJID = UMF.MENU_OBJID
WHERE UMF.USER_ID = #{user_id}
AND MENU.STATUS = 'active'
ORDER BY UMF.SORT_ORDER ASC, UMF.CREATED_AT ASC
</select>
<!-- 즐겨찾기 추가 (이미 있으면 무시) -->
<insert id="insertFavorite" parameterType="map">
INSERT INTO USER_MENU_FAVORITES (USER_ID, MENU_OBJID, SORT_ORDER)
VALUES (#{user_id}, #{menu_objid}, COALESCE(#{sort_order}, 0))
ON CONFLICT (USER_ID, MENU_OBJID) DO NOTHING
</insert>
<!-- 즐겨찾기 제거 -->
<delete id="deleteFavorite" parameterType="map">
DELETE FROM USER_MENU_FAVORITES
WHERE USER_ID = #{user_id}
AND MENU_OBJID = #{menu_objid}
</delete>
<!-- 단건 존재 확인 (toggle 동작에 활용) -->
<select id="selectFavoriteExists" parameterType="map" resultType="int">
SELECT COUNT(*) FROM USER_MENU_FAVORITES
WHERE USER_ID = #{user_id}
AND MENU_OBJID = #{menu_objid}
</select>
</mapper>
@@ -592,19 +592,21 @@ export default function RolesPage() {
}
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="w-full space-y-3 p-6">
<div className="space-y-1 border-b pb-3">
<div className="flex h-screen flex-col overflow-hidden bg-background">
<div className="flex w-full flex-1 flex-col space-y-3 overflow-hidden p-6">
<div className="shrink-0 space-y-1 border-b pb-3">
<h1 className="text-xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-xs">
/ , .
</p>
</div>
<CrossTenantBanner meta={crossTenantMeta} />
<div className="shrink-0">
<CrossTenantBanner meta={crossTenantMeta} />
</div>
{error && (
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-3">
<div className="border-destructive/50 bg-destructive/10 shrink-0 rounded-lg border p-3">
<div className="flex items-center justify-between">
<p className="text-destructive text-sm font-semibold">{error}</p>
<button onClick={() => setError(null)} className="text-destructive">
@@ -615,7 +617,7 @@ export default function RolesPage() {
)}
{/* 상단 4분할: 권한목록 | 권한있는직원 | 이동버튼 | 권한없는직원 */}
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[260px_1fr_auto_1fr]">
<div className="grid shrink-0 grid-cols-1 gap-4 xl:grid-cols-[260px_1fr_auto_1fr]">
{/* 권한 목록 */}
<div className="bg-card flex flex-col rounded-lg border shadow-sm">
<div className="flex items-center justify-between border-b p-3">
@@ -657,7 +659,10 @@ export default function RolesPage() {
</div>
)}
</div>
<div className="flex-1 overflow-y-auto" style={{ maxHeight: "440px" }}>
<div
className="flex-1 overflow-y-auto"
style={{ maxHeight: "clamp(220px, 32vh, 320px)" }}
>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<div className="border-primary h-5 w-5 animate-spin rounded-full border-2 border-t-transparent" />
@@ -760,7 +765,10 @@ export default function RolesPage() {
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto" style={{ height: "380px" }}>
<div
className="flex-1 overflow-y-auto"
style={{ height: "clamp(220px, 32vh, 320px)" }}
>
{!selectedRole ? (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-xs"> </p>
@@ -857,7 +865,10 @@ export default function RolesPage() {
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto" style={{ height: "380px" }}>
<div
className="flex-1 overflow-y-auto"
style={{ height: "clamp(220px, 32vh, 320px)" }}
>
{!selectedRole ? (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-xs"> </p>
@@ -903,8 +914,11 @@ export default function RolesPage() {
</div>
{/* 하단: 메뉴 권한 트리 (등록/수정, 삭제, 조회 3컬럼) */}
<div className="bg-card rounded-lg border shadow-sm">
<div className="border-b p-3">
<div
className="bg-card flex min-h-0 flex-1 flex-col rounded-lg border shadow-sm"
style={{ maxHeight: "clamp(280px, 40vh, 430px)" }}
>
<div className="shrink-0 border-b p-3">
<h2 className="text-sm font-semibold">
{" "}
{selectedRole && (
@@ -916,19 +930,19 @@ export default function RolesPage() {
</p>
</div>
{!selectedRole ? (
<div className="flex h-40 items-center justify-center">
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
) : isLoadingWorkspace ? (
<div className="flex h-40 items-center justify-center">
<div className="flex flex-1 items-center justify-center">
<div className="border-primary h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
</div>
) : menuTree.length === 0 ? (
<p className="text-muted-foreground p-12 text-center text-sm">
</p>
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
) : (
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 32rem)" }}>
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0 z-10">
<tr>
+5 -2
View File
@@ -1,5 +1,6 @@
import { AuthProvider } from "@/contexts/AuthContext";
import { MenuProvider } from "@/contexts/MenuContext";
import { FavoritesProvider } from "@/contexts/FavoritesContext";
import { AppLayout } from "@/components/layout/AppLayout";
import { ApprovalGlobalListener } from "@/components/approval/ApprovalGlobalListener";
import { RequireAuth } from "@/components/auth/AuthGuard";
@@ -9,8 +10,10 @@ export default function MainLayout({ children }: { children: React.ReactNode })
<AuthProvider>
<RequireAuth>
<MenuProvider>
<AppLayout>{children}</AppLayout>
<ApprovalGlobalListener />
<FavoritesProvider>
<AppLayout>{children}</AppLayout>
<ApprovalGlobalListener />
</FavoritesProvider>
</MenuProvider>
</RequireAuth>
</AuthProvider>
+109 -1
View File
@@ -28,6 +28,7 @@ import {
Sun,
Moon,
Bell,
Star,
} from "lucide-react";
import { useDashboardStore } from "@/stores/dashboardStore";
import { useControlMode } from "@/components/control/hooks/useControlMode";
@@ -35,6 +36,7 @@ import { insertDashboard, getDashboardList, updateDashboard } from "@/lib/api/da
import { CreateDashboardModal } from "@/components/dash/CreateDashboardModal";
import { MenuItemActions } from "@/components/layout/MenuItemActions";
import { useMenu } from "@/contexts/MenuContext";
import { useFavorites } from "@/contexts/FavoritesContext";
import { useAuth } from "@/hooks/useAuth";
import { useProfile } from "@/hooks/useProfile";
import { MenuItem, menuApi } from "@/lib/api/menu";
@@ -273,6 +275,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const searchParams = useSearchParams();
const { user, logout, refreshUserData } = useAuth();
const { user_menus: userMenus, admin_menus: adminMenus, loading, refreshMenus } = useMenu();
const { isFavorite, toggle: toggleFavorite } = useFavorites();
const [sidebarOpen, setSidebarOpen] = useState(true);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [tabsCollapsed, setTabsCollapsed] = useState(false);
@@ -816,6 +819,26 @@ function AppLayoutInner({ children }: AppLayoutProps) {
>
<span className="ic">{menu.icon}</span>
<span className="truncate" title={menu.name || ""}>{menu.name?.trim() || "(이름 없음)"}</span>
{isLeaf && !sidebarCollapsed && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleFavorite(menu.id);
}}
className="ml-auto rounded p-0.5 transition-opacity hover:bg-[var(--v5-surface-hover)]"
title={isFavorite(menu.id) ? "즐겨찾기 해제" : "즐겨찾기 추가"}
style={{
opacity: isFavorite(menu.id) ? 1 : 0.35,
color: isFavorite(menu.id) ? "var(--v5-primary)" : "var(--v5-text-muted)",
}}
>
<Star
className="h-3.5 w-3.5"
fill={isFavorite(menu.id) ? "currentColor" : "none"}
/>
</button>
)}
{menu.hasChildren && !sidebarCollapsed && (
<span className="ml-auto">
{isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
@@ -865,6 +888,26 @@ function AppLayoutInner({ children }: AppLayoutProps) {
>
<span className="ic">{child.icon}</span>
<span className="truncate" title={child.name}>{child.name}</span>
{!child.hasChildren && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleFavorite(child.id);
}}
className="ml-auto rounded p-0.5 transition-opacity hover:bg-[var(--v5-surface-hover)]"
title={isFavorite(child.id) ? "즐겨찾기 해제" : "즐겨찾기 추가"}
style={{
opacity: isFavorite(child.id) ? 1 : 0.35,
color: isFavorite(child.id) ? "var(--v5-primary)" : "var(--v5-text-muted)",
}}
>
<Star
className="h-3.5 w-3.5"
fill={isFavorite(child.id) ? "currentColor" : "none"}
/>
</button>
)}
</div>
</MenuItemActions>
))}
@@ -1219,7 +1262,72 @@ function AppLayoutInner({ children }: AppLayoutProps) {
))}
</div>
) : (
uiMenus.map((menu) => renderMenu(menu))
<>
{(() => {
// 즐겨찾기 섹션: uiMenus 의 모든 leaf 중 즐겨찾기로 등록된 것만.
// uiMenus 기반이라 권한 없는 메뉴는 자동 제외되고 handleMenuClick 도 그대로 재사용.
const flattenLeaves = (nodes: any[]): any[] => {
const out: any[] = [];
for (const n of nodes) {
if (n.hasChildren && n.children?.length) out.push(...flattenLeaves(n.children));
else out.push(n);
}
return out;
};
const favMenus = flattenLeaves(uiMenus).filter((m) => isFavorite(m.id));
if (favMenus.length === 0) return null;
return (
<>
{!sidebarCollapsed && (
<div
className="flex items-center gap-1.5 px-2.5 py-1.5"
style={{ fontSize: "0.62rem", fontWeight: 600, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--v5-text-muted)" }}
>
<Star size={11} fill="currentColor" style={{ color: "var(--v5-primary)" }} />
<span></span>
</div>
)}
{favMenus.map((m) => (
<MenuItemActions key={`fav-${m.id}`} menuUrl={m.url} menuName={m.name}>
<div
className={`v5-si ${isMenuActive(m) ? "on" : ""}`}
title={m.name}
onClick={() => handleMenuClick(m)}
>
<span className="ic">{m.icon}</span>
{!sidebarCollapsed && (
<>
<span className="truncate" title={m.name}>{m.name}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleFavorite(m.id);
}}
className="ml-auto rounded p-0.5 transition-opacity hover:bg-[var(--v5-surface-hover)]"
title="즐겨찾기 해제"
style={{ color: "var(--v5-primary)" }}
>
<Star className="h-3.5 w-3.5" fill="currentColor" />
</button>
</>
)}
</div>
</MenuItemActions>
))}
<div
style={{
height: "1px",
margin: "6px 8px",
background: "var(--v5-border)",
opacity: 0.6,
}}
/>
</>
);
})()}
{uiMenus.map((menu) => renderMenu(menu))}
</>
)}
</nav>
</div>
+97
View File
@@ -0,0 +1,97 @@
"use client";
import React, { createContext, useCallback, useContext, useEffect, useState, ReactNode } from "react";
import { favoritesAPI } from "@/lib/api/favorites";
import { useAuth } from "@/hooks/useAuth";
interface FavoritesContextType {
/** 즐겨찾기 메뉴 행 (MENU_INFO JOIN 결과) */
favorites: Record<string, any>[];
/** menu_objid 기준 Set — 별표 ON/OFF 빠른 체크용 */
favoriteIds: Set<string>;
loading: boolean;
isFavorite: (menuObjid: string | number) => boolean;
toggle: (menuObjid: string | number) => Promise<void>;
refresh: () => Promise<void>;
}
const FavoritesContext = createContext<FavoritesContextType | undefined>(undefined);
export function FavoritesProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
const [favorites, setFavorites] = useState<Record<string, any>[]>([]);
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await favoritesAPI.getList();
const rows = res?.success && Array.isArray(res.data) ? res.data : [];
setFavorites(rows);
setFavoriteIds(new Set(rows.map((r) => String(r.menu_objid))));
} catch (err) {
console.error("즐겨찾기 로드 오류:", err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!user?.user_id) {
setFavorites([]);
setFavoriteIds(new Set());
return;
}
load();
}, [user?.user_id, load]);
const isFavorite = useCallback((menuObjid: string | number) => favoriteIds.has(String(menuObjid)), [favoriteIds]);
const toggle = useCallback(
async (menuObjid: string | number) => {
const id = String(menuObjid);
const currentlyOn = favoriteIds.has(id);
// 낙관적 업데이트
setFavoriteIds((prev) => {
const next = new Set(prev);
if (currentlyOn) next.delete(id);
else next.add(id);
return next;
});
try {
if (currentlyOn) await favoritesAPI.remove(id);
else await favoritesAPI.add(id);
await load();
} catch (err) {
console.error("즐겨찾기 토글 오류:", err);
// 롤백
setFavoriteIds((prev) => {
const next = new Set(prev);
if (currentlyOn) next.add(id);
else next.delete(id);
return next;
});
}
},
[favoriteIds, load],
);
return (
<FavoritesContext.Provider
value={{ favorites, favoriteIds, loading, isFavorite, toggle, refresh: load }}
>
{children}
</FavoritesContext.Provider>
);
}
export function useFavorites() {
const ctx = useContext(FavoritesContext);
if (ctx === undefined) {
throw new Error("useFavorites must be used within a FavoritesProvider");
}
return ctx;
}
+30
View File
@@ -0,0 +1,30 @@
import { apiClient } from "./client";
import { ApiResponse } from "@/types/commonCode";
/**
* 즐겨찾기 메뉴 API
*
* 백엔드 엔드포인트 ↔ DB-per-tenant 의 USER_MENU_FAVORITES 테이블.
* 응답 행은 MENU_INFO 와 JOIN 된 형태 (menu_name_kor / menu_url / menu_icon 등 포함).
*/
export const favoritesAPI = {
getList: async (): Promise<ApiResponse<Record<string, any>[]>> => {
const res = await apiClient.get<ApiResponse<Record<string, any>[]>>("/favorites/menus");
return res.data;
},
add: async (menuObjid: string | number): Promise<ApiResponse<Record<string, any>>> => {
const res = await apiClient.post<ApiResponse<Record<string, any>>>(
"/favorites/menus",
{ menu_objid: String(menuObjid) },
);
return res.data;
},
remove: async (menuObjid: string | number): Promise<ApiResponse<Record<string, any>>> => {
const res = await apiClient.delete<ApiResponse<Record<string, any>>>(
`/favorites/menus/${encodeURIComponent(String(menuObjid))}`,
);
return res.data;
},
};