refactor(WEB): CEO 대시보드 대규모 개선 및 문서/권한/스토어 리팩토링
- CEO 대시보드: 섹션별 API 연동 강화 (매출/매입/생산 실데이터 표시) - DashboardSettingsDialog 드래그 정렬 및 설정 UX 개선 - dashboard transformers 모듈 분리 (파일 분할) - DocumentTable/DocumentWrapper 공통 문서 컴포넌트 추출 - LineItemsTable organisms 컴포넌트 추가 - PurchaseOrderDocument/InspectionRequestDocument 문서 컴포넌트 리팩토링 - PermissionContext → permissionStore(Zustand) 전환 - useUIStore, stores/utils/userStorage 추가 - favoritesStore/useTableColumnStore 사용자별 저장 지원 - DepositDetail/WithdrawalDetail 삭제 (통합) - PurchaseDetail/SalesDetail 간소화 - amount.ts/formatters.ts 유틸 확장 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,98 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { getRolePermissionMatrix, getPermissionMenuUrlMap } from '@/lib/permissions/actions';
|
||||
import { buildMenuIdToUrlMap, convertMatrixToPermissionMap, findMatchingUrl, mergePermissionMaps } from '@/lib/permissions/utils';
|
||||
import { ALL_DENIED_PERMS } from '@/lib/permissions/types';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import { findMatchingUrl } from '@/lib/permissions/utils';
|
||||
import type { PermissionMap, PermissionAction } from '@/lib/permissions/types';
|
||||
import { AccessDenied } from '@/components/common/AccessDenied';
|
||||
import { stripLocalePrefix } from '@/lib/utils/locale';
|
||||
|
||||
interface PermissionContextType {
|
||||
permissionMap: PermissionMap | null;
|
||||
isLoading: boolean;
|
||||
/** URL 지정 권한 체크 (특수 케이스용) */
|
||||
can: (url: string, action: PermissionAction) => boolean;
|
||||
/** 권한 데이터 다시 로드 (설정 변경 후 호출) */
|
||||
reloadPermissions: () => void;
|
||||
}
|
||||
|
||||
const PermissionContext = createContext<PermissionContextType>({
|
||||
permissionMap: null,
|
||||
isLoading: true,
|
||||
can: () => true,
|
||||
reloadPermissions: () => {},
|
||||
});
|
||||
|
||||
/**
|
||||
* PermissionProvider — Zustand store 초기화 래퍼
|
||||
*
|
||||
* 기존 Context.Provider 역할을 대체합니다.
|
||||
* 마운트 시 한 번 loadPermissions() 호출만 담당합니다.
|
||||
*/
|
||||
export function PermissionProvider({ children }: { children: React.ReactNode }) {
|
||||
const [permissionMap, setPermissionMap] = useState<PermissionMap | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const loadPermissions = usePermissionStore((s) => s.loadPermissions);
|
||||
|
||||
const loadPermissions = useCallback(async () => {
|
||||
const userData = getUserData();
|
||||
if (!userData || userData.roleIds.length === 0) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { roleIds, menuIdToUrl } = userData;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 사이드바 메뉴에 없는 권한 메뉴의 URL 매핑 보완
|
||||
// (기준정보 관리, 공정관리 등 사이드바 미등록 메뉴 대응)
|
||||
const [permMenuUrlMap, ...results] = await Promise.all([
|
||||
getPermissionMenuUrlMap(),
|
||||
...roleIds.map(id => getRolePermissionMatrix(id)),
|
||||
]);
|
||||
|
||||
// 권한 메뉴 URL을 베이스로, 사이드바 메뉴 URL로 덮어쓰기 (사이드바 우선)
|
||||
const mergedMenuIdToUrl = { ...permMenuUrlMap, ...menuIdToUrl };
|
||||
|
||||
const maps = results
|
||||
.filter(r => r.success && r.data?.permissions)
|
||||
.map(r => convertMatrixToPermissionMap(r.data.permissions, mergedMenuIdToUrl));
|
||||
|
||||
if (maps.length > 0) {
|
||||
const merged = mergePermissionMaps(maps);
|
||||
|
||||
// 권한 메뉴에 등록되어 있지만 매트릭스 응답에 없는 메뉴 처리
|
||||
// (모든 권한 OFF → API가 해당 menuId를 생략 → "all denied"로 보완)
|
||||
for (const [, url] of Object.entries(permMenuUrlMap)) {
|
||||
if (url && !merged[url]) {
|
||||
merged[url] = { ...ALL_DENIED_PERMS };
|
||||
}
|
||||
}
|
||||
|
||||
setPermissionMap(merged);
|
||||
} else {
|
||||
setPermissionMap(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Permission] 권한 로드 실패:', error);
|
||||
setPermissionMap(null);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
// 마운트 시 1회 로드
|
||||
useEffect(() => {
|
||||
loadPermissions();
|
||||
}, [loadPermissions]);
|
||||
|
||||
const can = useCallback((url: string, action: PermissionAction): boolean => {
|
||||
if (!permissionMap) return true;
|
||||
const matchedUrl = findMatchingUrl(url, permissionMap);
|
||||
if (!matchedUrl) return true;
|
||||
const perms = permissionMap[matchedUrl];
|
||||
return perms?.[action] ?? true;
|
||||
}, [permissionMap]);
|
||||
|
||||
return (
|
||||
<PermissionContext.Provider value={{ permissionMap, isLoading, can, reloadPermissions: loadPermissions }}>
|
||||
{children}
|
||||
</PermissionContext.Provider>
|
||||
);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +31,7 @@ const BYPASS_PATHS = ['/settings/permissions'];
|
||||
|
||||
function isGateBypassed(pathname: string): boolean {
|
||||
const pathWithoutLocale = stripLocalePrefix(pathname);
|
||||
return BYPASS_PATHS.some(bp => pathWithoutLocale.startsWith(bp));
|
||||
return BYPASS_PATHS.some((bp) => pathWithoutLocale.startsWith(bp));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,7 +39,8 @@ function isGateBypassed(pathname: string): boolean {
|
||||
*/
|
||||
export function PermissionGate({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const { permissionMap, isLoading } = useContext(PermissionContext);
|
||||
const permissionMap = usePermissionStore((s) => s.permissionMap);
|
||||
const isLoading = usePermissionStore((s) => s.isLoading);
|
||||
|
||||
if (isLoading) return null;
|
||||
if (!permissionMap) {
|
||||
@@ -135,27 +65,21 @@ export function PermissionGate({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/** localStorage 'user' 키에서 역할 ID 배열 + menuId→URL 매핑 추출 */
|
||||
function getUserData(): { roleIds: number[]; menuIdToUrl: Record<string, string> } | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = localStorage.getItem('user');
|
||||
if (!raw) return null;
|
||||
/**
|
||||
* 하위호환 훅 — 기존 usePermissionContext() 소비자를 위한 어댑터
|
||||
*
|
||||
* Zustand store에서 읽되, 기존과 동일한 인터페이스를 반환합니다.
|
||||
*/
|
||||
export function usePermissionContext(): {
|
||||
permissionMap: PermissionMap | null;
|
||||
isLoading: boolean;
|
||||
can: (url: string, action: PermissionAction) => boolean;
|
||||
reloadPermissions: () => void;
|
||||
} {
|
||||
const permissionMap = usePermissionStore((s) => s.permissionMap);
|
||||
const isLoading = usePermissionStore((s) => s.isLoading);
|
||||
const can = usePermissionStore((s) => s.can);
|
||||
const loadPermissions = usePermissionStore((s) => s.loadPermissions);
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
const roleIds = Array.isArray(parsed.roles)
|
||||
? parsed.roles.map((r: { id: number }) => r.id).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const menuIdToUrl = Array.isArray(parsed.menu)
|
||||
? buildMenuIdToUrlMap(parsed.menu)
|
||||
: {};
|
||||
|
||||
return { roleIds, menuIdToUrl };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return { permissionMap, isLoading, can, reloadPermissions: loadPermissions };
|
||||
}
|
||||
|
||||
export const usePermissionContext = () => useContext(PermissionContext);
|
||||
|
||||
Reference in New Issue
Block a user