Merge pull request 'Merge branch 'main' into hjjeong' (#8) from hjjeong into main
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m35s
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user