refactor(WEB): claudedocs 재정리 및 AuthContext/Zustand/유틸 코드 개선

- claudedocs 폴더 구조 재정리: archive/sessions, guides/migration·mobile·universal-list, refactoring 분류
- 오래된 세션 컨텍스트/체크리스트 문서 정리 (아카이브 이동 또는 삭제)
- AuthContext → authStore(Zustand) 전환 시작, RootProvider 간소화
- GenericCRUDDialog 공통 다이얼로그 컴포넌트 추가
- PermissionDialog 삭제 → GenericCRUDDialog로 대체
- RankDialog/TitleDialog GenericCRUDDialog 기반으로 리팩토링
- toast-utils.ts 삭제 (미사용)
- fileDownload.ts 개선, excel-download.ts 정리
- menuStore/themeStore Zustand 셀렉터 최적화
- useColumnSettings/useTableColumnStore 기능 보강
- 세금계산서/견적/작업자화면/결재 등 소규모 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-23 17:17:13 +09:00
parent 6c3572e568
commit 07374c826c
75 changed files with 1704 additions and 1376 deletions

247
src/stores/authStore.ts Normal file
View File

@@ -0,0 +1,247 @@
/**
* Auth Zustand Store
*
* AuthContext(React Context + useState)에서 마이그레이션.
* - persist: custom storage로 기존 localStorage 키(mes-users, mes-currentUser) 유지
* - devtools: Redux DevTools 디버깅 지원
* - subscribe: 테넌트 전환 감지 + masterDataStore 동기화
*/
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { useMasterDataStore } from '@/stores/masterDataStore';
// ===== 타입 정의 =====
export interface Tenant {
id: number;
company_name: string;
business_num: string;
tenant_st_code: string;
options?: {
company_scale?: string;
industry?: string;
};
}
export interface Role {
id: number;
name: string;
description: string;
}
export interface MenuItem {
id: string;
label: string;
iconName: string;
path: string;
}
export interface User {
userId: string;
name: string;
position: string;
roles: Role[];
tenant: Tenant;
menu: MenuItem[];
}
export type UserRole = 'CEO' | 'ProductionManager' | 'Worker' | 'SystemAdmin' | 'Sales';
// ===== Store 타입 =====
interface AuthState {
// State
users: User[];
currentUser: User | null;
// Actions
setCurrentUser: (user: User | null) => void;
addUser: (user: User) => void;
updateUser: (userId: string, updates: Partial<User>) => void;
deleteUser: (userId: string) => void;
getUserByUserId: (userId: string) => User | undefined;
logout: () => Promise<void>;
clearTenantCache: (tenantId: number) => void;
resetAllData: () => void;
}
// ===== 초기 데이터 =====
const initialUsers: User[] = [
{
userId: "TestUser1",
name: "김대표",
position: "대표이사",
roles: [{ id: 1, name: "ceo", description: "최고경영자" }],
tenant: {
id: 282,
company_name: "(주)테크컴퍼니",
business_num: "123-45-67890",
tenant_st_code: "trial",
},
menu: [{ id: "13664", label: "시스템 대시보드", iconName: "layout-dashboard", path: "/dashboard" }],
},
{
userId: "TestUser2",
name: "박관리",
position: "생산관리자",
roles: [{ id: 2, name: "production_manager", description: "생산관리자" }],
tenant: {
id: 282,
company_name: "(주)테크컴퍼니",
business_num: "123-45-67890",
tenant_st_code: "trial",
},
menu: [{ id: "13664", label: "시스템 대시보드", iconName: "layout-dashboard", path: "/dashboard" }],
},
{
userId: "TestUser3",
name: "드미트리",
position: "시스템 관리자",
roles: [{ id: 19, name: "system_manager", description: "시스템 관리자" }],
tenant: {
id: 282,
company_name: "(주)테크컴퍼니",
business_num: "123-45-67890",
tenant_st_code: "trial",
},
menu: [{ id: "13664", label: "시스템 대시보드", iconName: "layout-dashboard", path: "/dashboard" }],
},
];
// ===== Custom Storage =====
// 기존 코드가 mes-users / mes-currentUser 두 개 키를 사용하므로 호환성 유지
const authStorage = createJSONStorage<Pick<AuthState, 'users' | 'currentUser'>>(() => ({
getItem: (_name: string): string | null => {
if (typeof window === 'undefined') return null;
try {
const users = localStorage.getItem('mes-users');
const currentUser = localStorage.getItem('mes-currentUser');
return JSON.stringify({
state: {
users: users ? JSON.parse(users) : initialUsers,
currentUser: currentUser ? JSON.parse(currentUser) : null,
},
});
} catch {
localStorage.removeItem('mes-users');
localStorage.removeItem('mes-currentUser');
return null;
}
},
setItem: (_name: string, value: string): void => {
if (typeof window === 'undefined') return;
try {
const parsed = JSON.parse(value);
const { users, currentUser } = parsed.state;
localStorage.setItem('mes-users', JSON.stringify(users));
if (currentUser) {
localStorage.setItem('mes-currentUser', JSON.stringify(currentUser));
}
} catch {
// 저장 실패 무시
}
},
removeItem: (_name: string): void => {
if (typeof window === 'undefined') return;
localStorage.removeItem('mes-users');
localStorage.removeItem('mes-currentUser');
},
}));
// ===== Store 생성 =====
export const useAuthStore = create<AuthState>()(
devtools(
persist(
(set, get) => ({
// State
users: initialUsers,
currentUser: null,
// Actions
setCurrentUser: (user) => set({ currentUser: user }),
addUser: (user) => set((state) => ({ users: [...state.users, user] })),
updateUser: (userId, updates) =>
set((state) => ({
users: state.users.map((u) =>
u.userId === userId ? { ...u, ...updates } : u
),
})),
deleteUser: (userId) =>
set((state) => ({
users: state.users.filter((u) => u.userId !== userId),
})),
getUserByUserId: (userId) => get().users.find((u) => u.userId === userId),
logout: async () => {
set({ currentUser: null });
const { performFullLogout } = await import('@/lib/auth/logout');
await performFullLogout({
skipServerLogout: false,
redirectTo: null,
});
},
clearTenantCache: (tenantId: number) => {
if (typeof window === 'undefined') return;
const tenantAwarePrefix = `mes-${tenantId}-`;
const pageConfigPrefix = `page_config_${tenantId}_`;
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(tenantAwarePrefix)) {
localStorage.removeItem(key);
}
});
Object.keys(sessionStorage).forEach((key) => {
if (key.startsWith(tenantAwarePrefix) || key.startsWith(pageConfigPrefix)) {
sessionStorage.removeItem(key);
}
});
},
resetAllData: () => set({ users: initialUsers, currentUser: null }),
}),
{
name: 'auth-store',
storage: authStorage,
partialize: (state) => ({
users: state.users,
currentUser: state.currentUser,
}),
}
),
{ name: 'AuthStore' }
)
);
// ===== Subscribe: 테넌트 전환 감지 + masterDataStore 동기화 =====
let _prevTenantId: number | null = null;
useAuthStore.subscribe((state) => {
const currentTenantId = state.currentUser?.tenant?.id ?? null;
// 테넌트 전환 감지 (이전값이 있고, 현재값과 다를 때만)
if (_prevTenantId && currentTenantId && _prevTenantId !== currentTenantId) {
state.clearTenantCache(_prevTenantId);
}
_prevTenantId = currentTenantId;
// masterDataStore 동기화
useMasterDataStore.getState().setCurrentTenantId(currentTenantId);
});
// ===== 셀렉터 훅 =====
export const useCurrentUser = () => useAuthStore((state) => state.currentUser);
export const useAuthLogout = () => useAuthStore((state) => state.logout);

View File

@@ -63,4 +63,22 @@ export const useMenuStore = create<MenuState>()(
},
}
)
);
);
// ===== 셀렉터 훅 =====
/** 사이드바 접힘 상태만 구독 */
export const useSidebarCollapsed = () =>
useMenuStore((state) => state.sidebarCollapsed);
/** 활성 메뉴 ID만 구독 */
export const useActiveMenu = () =>
useMenuStore((state) => state.activeMenu);
/** 메뉴 아이템 목록만 구독 */
export const useMenuItems = () =>
useMenuStore((state) => state.menuItems);
/** 하이드레이션 완료 여부만 구독 */
export const useMenuHydrated = () =>
useMenuStore((state) => state._hasHydrated);

View File

@@ -41,4 +41,14 @@ export const useThemeStore = create<ThemeState>()(
},
}
)
);
);
// ===== 셀렉터 훅 =====
/** 현재 테마만 구독 */
export const useTheme = () =>
useThemeStore((state) => state.theme);
/** setTheme 액션만 구독 */
export const useSetTheme = () =>
useThemeStore((state) => state.setTheme);

View File

@@ -99,3 +99,17 @@ export const useTableColumnStore = create<TableColumnState>()(
}
)
);
// ===== 셀렉터 훅 =====
/** 특정 페이지의 컬럼 설정만 구독 */
export const usePageColumnSettings = (pageId: string) =>
useTableColumnStore((state) => state.pageSettings[pageId] ?? DEFAULT_PAGE_SETTINGS);
/** 특정 페이지의 숨김 컬럼만 구독 */
export const useHiddenColumns = (pageId: string) =>
useTableColumnStore((state) => state.pageSettings[pageId]?.hiddenColumns ?? []);
/** 특정 페이지의 컬럼 너비만 구독 */
export const useColumnWidths = (pageId: string) =>
useTableColumnStore((state) => state.pageSettings[pageId]?.columnWidths ?? {});