-
+
-
diff --git a/src/components/approval/DocumentCreate/index.tsx b/src/components/approval/DocumentCreate/index.tsx
index 4d92b93b..92a6847b 100644
--- a/src/components/approval/DocumentCreate/index.tsx
+++ b/src/components/approval/DocumentCreate/index.tsx
@@ -23,7 +23,7 @@ import {
deleteApproval,
getEmployees,
} from './actions';
-import { useAuth } from '@/contexts/AuthContext';
+import { useAuthStore } from '@/stores/authStore';
import { Button } from '@/components/ui/button';
import { BasicInfoSection } from './BasicInfoSection';
import { ApprovalLineSection } from './ApprovalLineSection';
@@ -88,7 +88,7 @@ export function DocumentCreate() {
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
- const { currentUser } = useAuth();
+ const currentUser = useAuthStore((state) => state.currentUser);
const { canCreate, canDelete } = usePermission();
// 수정 모드 / 복제 모드 상태
diff --git a/src/components/business/construction/estimates/EstimateDetailForm.tsx b/src/components/business/construction/estimates/EstimateDetailForm.tsx
index 95467636..9789e0c7 100644
--- a/src/components/business/construction/estimates/EstimateDetailForm.tsx
+++ b/src/components/business/construction/estimates/EstimateDetailForm.tsx
@@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { getExpenseItemOptions, updateEstimate, type ExpenseItemOption } from './actions';
import { createBiddingFromEstimate } from '../bidding/actions';
-import { useAuth } from '@/contexts/AuthContext';
+import { useAuthStore } from '@/stores/authStore';
import { Button } from '@/components/ui/button';
import { ConfirmDialog, DeleteConfirmDialog, SaveConfirmDialog } from '@/components/ui/confirm-dialog';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
@@ -49,7 +49,7 @@ export default function EstimateDetailForm({
initialData,
}: EstimateDetailFormProps) {
const router = useRouter();
- const { currentUser } = useAuth();
+ const currentUser = useAuthStore((state) => state.currentUser);
const isViewMode = mode === 'view';
const isEditMode = mode === 'edit';
diff --git a/src/components/layout/CommandMenuSearch.tsx b/src/components/layout/CommandMenuSearch.tsx
index 88452f5e..981b7ff4 100644
--- a/src/components/layout/CommandMenuSearch.tsx
+++ b/src/components/layout/CommandMenuSearch.tsx
@@ -2,7 +2,7 @@
import { useEffect, useState, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react';
import { useRouter } from 'next/navigation';
-import { useMenuStore, type MenuItem } from '@/stores/menuStore';
+import { useMenuItems, type MenuItem } from '@/stores/menuStore';
import {
CommandDialog,
CommandInput,
@@ -65,7 +65,7 @@ const CommandMenuSearch = forwardRef
((_, ref) => {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const router = useRouter();
- const { menuItems } = useMenuStore();
+ const menuItems = useMenuItems();
// 외부에서 제어할 수 있도록 ref 노출
useImperativeHandle(ref, () => ({
diff --git a/src/components/molecules/GenericCRUDDialog.tsx b/src/components/molecules/GenericCRUDDialog.tsx
new file mode 100644
index 00000000..34ba14a5
--- /dev/null
+++ b/src/components/molecules/GenericCRUDDialog.tsx
@@ -0,0 +1,171 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Loader2 } from 'lucide-react';
+
+/**
+ * 필드 정의
+ */
+export interface CRUDFieldDefinition {
+ key: string;
+ label: string;
+ type: 'text' | 'select';
+ placeholder?: string;
+ options?: { value: string; label: string }[];
+ defaultValue?: string;
+}
+
+export interface GenericCRUDDialogProps {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ mode: 'add' | 'edit';
+ entityName: string;
+ fields: CRUDFieldDefinition[];
+ initialData?: Record;
+ onSubmit: (data: Record) => void;
+ isLoading?: boolean;
+ addLabel?: string;
+ editLabel?: string;
+}
+
+/**
+ * 단순 CRUD 다이얼로그 공통 컴포넌트
+ *
+ * 텍스트 입력 + Select 조합의 단순 폼 다이얼로그를 생성합니다.
+ * RankDialog, TitleDialog 등 동일 패턴의 다이얼로그를 대체합니다.
+ */
+export function GenericCRUDDialog({
+ isOpen,
+ onOpenChange,
+ mode,
+ entityName,
+ fields,
+ initialData,
+ onSubmit,
+ isLoading = false,
+ addLabel = '등록',
+ editLabel = '수정',
+}: GenericCRUDDialogProps) {
+ const [formData, setFormData] = useState>({});
+
+ useEffect(() => {
+ if (isOpen) {
+ if (mode === 'edit' && initialData) {
+ setFormData({ ...initialData });
+ } else {
+ const defaults: Record = {};
+ fields.forEach((f) => {
+ defaults[f.key] = f.defaultValue ?? '';
+ });
+ setFormData(defaults);
+ }
+ }
+ }, [isOpen, mode, initialData, fields]);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ const firstTextField = fields.find((f) => f.type === 'text');
+ if (firstTextField && !formData[firstTextField.key]?.trim()) return;
+
+ const trimmed: Record = {};
+ Object.entries(formData).forEach(([k, v]) => {
+ trimmed[k] = v.trim();
+ });
+ onSubmit(trimmed);
+
+ const defaults: Record = {};
+ fields.forEach((f) => {
+ defaults[f.key] = f.defaultValue ?? '';
+ });
+ setFormData(defaults);
+ };
+
+ const title = mode === 'add' ? `${entityName} 추가` : `${entityName} 수정`;
+ const submitText = mode === 'add' ? addLabel : editLabel;
+
+ const firstTextField = fields.find((f) => f.type === 'text');
+ const isSubmitDisabled =
+ isLoading || (firstTextField ? !formData[firstTextField.key]?.trim() : false);
+
+ return (
+
+ );
+}
diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts
index bbde8a3a..0d81d69a 100644
--- a/src/components/molecules/index.ts
+++ b/src/components/molecules/index.ts
@@ -10,4 +10,7 @@ export { StandardDialog } from "./StandardDialog";
export type { StandardDialogProps } from "./StandardDialog";
export { YearQuarterFilter } from "./YearQuarterFilter";
-export type { Quarter } from "./YearQuarterFilter";
\ No newline at end of file
+export type { Quarter } from "./YearQuarterFilter";
+
+export { GenericCRUDDialog } from "./GenericCRUDDialog";
+export type { GenericCRUDDialogProps, CRUDFieldDefinition } from "./GenericCRUDDialog";
\ No newline at end of file
diff --git a/src/components/production/WorkerScreen/index.tsx b/src/components/production/WorkerScreen/index.tsx
index ab79868b..2f005a1b 100644
--- a/src/components/production/WorkerScreen/index.tsx
+++ b/src/components/production/WorkerScreen/index.tsx
@@ -15,7 +15,7 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import dynamic from 'next/dynamic';
-import { useMenuStore } from '@/stores/menuStore';
+import { useSidebarCollapsed } from '@/stores/menuStore';
import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle, ChevronDown, ChevronUp, List } from 'lucide-react';
import {
Dialog,
@@ -324,7 +324,7 @@ const PROCESS_STEPS: Record([]);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState('');
diff --git a/src/components/settings/PermissionManagement/PermissionDialog.tsx b/src/components/settings/PermissionManagement/PermissionDialog.tsx
deleted file mode 100644
index 61c7757b..00000000
--- a/src/components/settings/PermissionManagement/PermissionDialog.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-'use client';
-
-import { useState, useEffect } from 'react';
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogFooter,
-} from '@/components/ui/dialog';
-import { Input } from '@/components/ui/input';
-import { Button } from '@/components/ui/button';
-import { Label } from '@/components/ui/label';
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select';
-import type { PermissionDialogProps } from './types';
-
-/**
- * 권한 추가/수정 다이얼로그
- */
-export function PermissionDialog({
- isOpen,
- onOpenChange,
- mode,
- permission,
- onSubmit
-}: PermissionDialogProps) {
- const [name, setName] = useState('');
- const [status, setStatus] = useState<'active' | 'hidden'>('active');
-
- // 다이얼로그 열릴 때 초기값 설정
- useEffect(() => {
- if (isOpen) {
- if (mode === 'edit' && permission) {
- setName(permission.name);
- setStatus(permission.status);
- } else {
- setName('');
- setStatus('active');
- }
- }
- }, [isOpen, mode, permission]);
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- if (name.trim()) {
- onSubmit({ name: name.trim(), status });
- setName('');
- setStatus('active');
- }
- };
-
- const dialogTitle = mode === 'add' ? '권한 등록' : '권한 수정';
- const submitText = mode === 'add' ? '등록' : '수정';
-
- return (
-
- );
-}
\ No newline at end of file
diff --git a/src/components/settings/RankManagement/RankDialog.tsx b/src/components/settings/RankManagement/RankDialog.tsx
index c4bd2883..ffca2132 100644
--- a/src/components/settings/RankManagement/RankDialog.tsx
+++ b/src/components/settings/RankManagement/RankDialog.tsx
@@ -1,19 +1,13 @@
'use client';
-import { useState, useEffect } from 'react';
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogFooter,
-} from '@/components/ui/dialog';
-import { Input } from '@/components/ui/input';
-import { Button } from '@/components/ui/button';
-import { Label } from '@/components/ui/label';
-import { Loader2 } from 'lucide-react';
+import { useMemo } from 'react';
+import { GenericCRUDDialog, type CRUDFieldDefinition } from '@/components/molecules/GenericCRUDDialog';
import type { RankDialogProps } from './types';
+const RANK_FIELDS: CRUDFieldDefinition[] = [
+ { key: 'name', label: '직급명', type: 'text', placeholder: '직급명을 입력하세요' },
+];
+
/**
* 직급 추가/수정 다이얼로그
*/
@@ -25,65 +19,21 @@ export function RankDialog({
onSubmit,
isLoading = false,
}: RankDialogProps) {
- const [name, setName] = useState('');
-
- // 다이얼로그 열릴 때 초기값 설정
- useEffect(() => {
- if (isOpen) {
- if (mode === 'edit' && rank) {
- setName(rank.name);
- } else {
- setName('');
- }
- }
- }, [isOpen, mode, rank]);
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- if (name.trim()) {
- onSubmit(name.trim());
- setName('');
- }
- };
-
- const title = mode === 'add' ? '직급 추가' : '직급 수정';
- const submitText = mode === 'add' ? '등록' : '수정';
+ const initialData = useMemo(
+ () => (rank ? { name: rank.name } : undefined),
+ [rank]
+ );
return (
-
+ onSubmit(data.name)}
+ isLoading={isLoading}
+ />
);
}
\ No newline at end of file
diff --git a/src/components/settings/TitleManagement/TitleDialog.tsx b/src/components/settings/TitleManagement/TitleDialog.tsx
index 7329156f..80825a1f 100644
--- a/src/components/settings/TitleManagement/TitleDialog.tsx
+++ b/src/components/settings/TitleManagement/TitleDialog.tsx
@@ -1,19 +1,13 @@
'use client';
-import { useState, useEffect } from 'react';
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogFooter,
-} from '@/components/ui/dialog';
-import { Input } from '@/components/ui/input';
-import { Button } from '@/components/ui/button';
-import { Label } from '@/components/ui/label';
-import { Loader2 } from 'lucide-react';
+import { useMemo } from 'react';
+import { GenericCRUDDialog, type CRUDFieldDefinition } from '@/components/molecules/GenericCRUDDialog';
import type { TitleDialogProps } from './types';
+const TITLE_FIELDS: CRUDFieldDefinition[] = [
+ { key: 'name', label: '직책명', type: 'text', placeholder: '직책명을 입력하세요' },
+];
+
/**
* 직책 추가/수정 다이얼로그
*/
@@ -25,66 +19,21 @@ export function TitleDialog({
onSubmit,
isLoading = false,
}: TitleDialogProps) {
- const [name, setName] = useState('');
-
- // 다이얼로그 열릴 때 초기값 설정
- useEffect(() => {
- if (isOpen) {
- if (mode === 'edit' && title) {
- setName(title.name);
- } else {
- setName('');
- }
- }
- }, [isOpen, mode, title]);
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- if (name.trim()) {
- onSubmit(name.trim());
- setName('');
- }
- };
-
- const dialogTitle = mode === 'add' ? '직책 추가' : '직책 수정';
- const submitText = mode === 'add' ? '등록' : '수정';
+ const initialData = useMemo(
+ () => (title ? { name: title.name } : undefined),
+ [title]
+ );
return (
-
+ onSubmit(data.name)}
+ isLoading={isLoading}
+ />
);
}
\ No newline at end of file
diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx
index ac365128..9ebf0403 100644
--- a/src/contexts/AuthContext.tsx
+++ b/src/contexts/AuthContext.tsx
@@ -1,277 +1,25 @@
'use client';
-import { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react';
-import { performFullLogout } from '@/lib/auth/logout';
-import { useMasterDataStore } from '@/stores/masterDataStore';
+/**
+ * AuthContext - 하위호환 re-export 심
+ *
+ * 실제 구현은 src/stores/authStore.ts (Zustand)로 이동됨.
+ * 기존 import { useAuth } from '@/contexts/AuthContext' 코드가
+ * 깨지지 않도록 타입과 훅을 re-export.
+ */
-// ===== 타입 정의 =====
+import { type ReactNode } from 'react';
+import { useAuthStore } from '@/stores/authStore';
-// ✅ 추가: 테넌트 타입 (실제 서버 응답 구조)
-export interface Tenant {
- id: number; // 테넌트 고유 ID (number)
- company_name: string; // 회사명
- business_num: string; // 사업자번호
- tenant_st_code: string; // 테넌트 상태 코드 (trial, active 등)
- 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;
-}
-
-// ✅ 수정: User 타입을 실제 서버 응답에 맞게 변경
-export interface User {
- userId: string; // 사용자 ID (username 아님)
- name: string; // 사용자 이름
- position: string; // 직책
- roles: Role[]; // 권한 목록 (배열)
- tenant: Tenant; // ✅ 테넌트 정보 (필수!)
- menu: MenuItem[]; // 메뉴 목록
-}
-
-// ❌ 삭제 예정: 기존 UserRole (더 이상 사용하지 않음)
-export type UserRole = 'CEO' | 'ProductionManager' | 'Worker' | 'SystemAdmin' | 'Sales';
-
-// ===== Context 타입 =====
-
-interface AuthContextType {
- users: User[];
- currentUser: User | null;
- setCurrentUser: (user: User | null) => void;
- addUser: (user: User) => void;
- updateUser: (userId: string, updates: Partial) => void;
- deleteUser: (userId: string) => void;
- getUserByUserId: (userId: string) => User | undefined;
- logout: () => Promise; // ✅ 추가: 로그아웃 (완전한 캐시 정리)
- 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"
- }
- ]
- }
-];
-
-// ===== Context 생성 =====
-
-const AuthContext = createContext(undefined);
-
-// ===== Provider 컴포넌트 =====
+// 타입 re-export
+export type { Tenant, Role, MenuItem, User, UserRole } from '@/stores/authStore';
+// AuthProvider: 빈 passthrough (미발견 import 안전망)
export function AuthProvider({ children }: { children: ReactNode }) {
- // 상태 관리 (SSR-safe: 항상 초기값으로 시작)
- const [users, setUsers] = useState(initialUsers);
- const [currentUser, setCurrentUser] = useState(null);
-
- // ✅ 추가: 이전 tenant.id 추적 (테넌트 전환 감지용)
- const previousTenantIdRef = useRef(null);
-
- // localStorage에서 초기 데이터 로드 (클라이언트에서만 실행)
- useEffect(() => {
- try {
- const savedUsers = localStorage.getItem('mes-users');
- if (savedUsers) {
- setUsers(JSON.parse(savedUsers));
- }
-
- const savedCurrentUser = localStorage.getItem('mes-currentUser');
- if (savedCurrentUser) {
- setCurrentUser(JSON.parse(savedCurrentUser));
- }
- } catch (error) {
- console.error('Failed to load auth data from localStorage:', error);
- // 손상된 데이터 제거
- localStorage.removeItem('mes-users');
- localStorage.removeItem('mes-currentUser');
- }
- }, []);
-
- // localStorage 동기화 (상태 변경 시 자동 저장)
- useEffect(() => {
- localStorage.setItem('mes-users', JSON.stringify(users));
- }, [users]);
-
- useEffect(() => {
- if (currentUser) {
- localStorage.setItem('mes-currentUser', JSON.stringify(currentUser));
- }
- }, [currentUser]);
-
- // ✅ 추가: 테넌트 전환 감지
- useEffect(() => {
- const prevTenantId = previousTenantIdRef.current;
- const currentTenantId = currentUser?.tenant?.id;
-
- if (prevTenantId && currentTenantId && prevTenantId !== currentTenantId) {
- clearTenantCache(prevTenantId);
- }
-
- previousTenantIdRef.current = currentTenantId || null;
- }, [currentUser?.tenant?.id]);
-
- // ✅ 추가: masterDataStore에 현재 테넌트 ID 동기화
- useEffect(() => {
- const tenantId = currentUser?.tenant?.id ?? null;
- useMasterDataStore.getState().setCurrentTenantId(tenantId);
- }, [currentUser?.tenant?.id]);
-
- // ✅ 추가: 테넌트별 캐시 삭제 함수 (SSR-safe)
- const clearTenantCache = (tenantId: number) => {
- // 서버 환경에서는 실행 안함
- if (typeof window === 'undefined') return;
-
- const tenantAwarePrefix = `mes-${tenantId}-`;
- const pageConfigPrefix = `page_config_${tenantId}_`;
-
- // localStorage 캐시 삭제
- Object.keys(localStorage).forEach(key => {
- if (key.startsWith(tenantAwarePrefix)) {
- localStorage.removeItem(key);
- }
- });
-
- // sessionStorage 캐시 삭제 (TenantAwareCache + masterDataStore)
- Object.keys(sessionStorage).forEach(key => {
- if (key.startsWith(tenantAwarePrefix) || key.startsWith(pageConfigPrefix)) {
- sessionStorage.removeItem(key);
- }
- });
- };
-
- // ✅ 추가: 로그아웃 함수 (완전한 캐시 정리)
- const logout = async () => {
-
- // 1. React 상태 초기화 (UI 즉시 반영)
- setCurrentUser(null);
-
- // 2. 완전한 로그아웃 수행 (Zustand, sessionStorage, localStorage, 서버 API)
- await performFullLogout({
- skipServerLogout: false, // 서버 API 호출 (HttpOnly 쿠키 삭제)
- redirectTo: null, // 리다이렉트는 호출하는 곳에서 처리
- });
-
- };
-
- // Context value
- const value: AuthContextType = {
- users,
- currentUser,
- setCurrentUser,
- addUser: (user) => setUsers(prev => [...prev, user]),
- updateUser: (userId, updates) => setUsers(prev =>
- prev.map(user => user.userId === userId ? { ...user, ...updates } : user)
- ),
- deleteUser: (userId) => setUsers(prev => prev.filter(user => user.userId !== userId)),
- getUserByUserId: (userId) => users.find(user => user.userId === userId),
- logout,
- clearTenantCache,
- resetAllData: () => {
- setUsers(initialUsers);
- setCurrentUser(null);
- }
- };
-
- return {children};
+ return <>{children}>;
}
-// ===== Custom Hook =====
-
+// useAuth: authStore 전체 상태를 반환 (기존 Context 인터페이스 유지)
export function useAuth() {
- const context = useContext(AuthContext);
- if (context === undefined) {
- throw new Error('useAuth must be used within an AuthProvider');
- }
- return context;
+ return useAuthStore();
}
diff --git a/src/contexts/ItemMasterContext.tsx b/src/contexts/ItemMasterContext.tsx
index 546b3b0e..29e96c5c 100644
--- a/src/contexts/ItemMasterContext.tsx
+++ b/src/contexts/ItemMasterContext.tsx
@@ -1,7 +1,7 @@
'use client';
import { createContext, useContext, useState, useEffect, useMemo, ReactNode } from 'react';
-import { useAuth } from './AuthContext';
+import { useAuthStore } from '@/stores/authStore';
import { TenantAwareCache } from '@/lib/cache';
import { itemMasterApi } from '@/lib/api/item-master';
import { getErrorMessage, ApiError } from '@/lib/api/error-handler';
@@ -224,7 +224,7 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
const initialItemPages: ItemPage[] = [];
// ===== Auth & Cache Setup =====
- const { currentUser } = useAuth();
+ const currentUser = useAuthStore((state) => state.currentUser);
const tenantId = currentUser?.tenant?.id;
// ✅ TenantAwareCache 인스턴스 생성 (tenant.id 기반, SSR-safe)
diff --git a/src/contexts/RootProvider.tsx b/src/contexts/RootProvider.tsx
index fbfc6ab9..70099d46 100644
--- a/src/contexts/RootProvider.tsx
+++ b/src/contexts/RootProvider.tsx
@@ -1,15 +1,14 @@
'use client';
import { ReactNode } from 'react';
-import { AuthProvider } from './AuthContext';
import { PermissionProvider } from './PermissionContext';
import { ItemMasterProvider } from './ItemMasterContext';
/**
* RootProvider - 모든 Context Provider를 통합하는 최상위 Provider
*
- * 현재 사용 중인 Context:
- * 1. AuthContext - 사용자/인증 (2개 상태)
+ * 현재 사용 중인 Context/Store:
+ * 1. authStore (Zustand) - 사용자/인증 (Provider 불필요)
* 2. PermissionContext - 권한 관리 (URL 자동매칭)
* 3. ItemMasterContext - 품목관리 (13개 상태)
*
@@ -19,13 +18,11 @@ import { ItemMasterProvider } from './ItemMasterContext';
*/
export function RootProvider({ children }: { children: ReactNode }) {
return (
-
-
-
- {children}
-
-
-
+
+
+ {children}
+
+
);
}
diff --git a/src/hooks/useColumnSettings.ts b/src/hooks/useColumnSettings.ts
index d7aa9ab3..e7d8ad5a 100644
--- a/src/hooks/useColumnSettings.ts
+++ b/src/hooks/useColumnSettings.ts
@@ -1,5 +1,5 @@
import { useMemo, useCallback } from 'react';
-import { useTableColumnStore } from '@/stores/useTableColumnStore';
+import { useTableColumnStore, usePageColumnSettings } from '@/stores/useTableColumnStore';
import type { TableColumn } from '@/components/templates/UniversalListPage/types';
export interface ColumnWithVisibility extends TableColumn {
@@ -14,8 +14,10 @@ interface UseColumnSettingsParams {
}
export function useColumnSettings({ pageId, columns, alwaysVisibleKeys = [] }: UseColumnSettingsParams) {
- const store = useTableColumnStore();
- const settings = store.getPageSettings(pageId);
+ const settings = usePageColumnSettings(pageId);
+ const setColumnWidthAction = useTableColumnStore((s) => s.setColumnWidth);
+ const toggleColumnVisibilityAction = useTableColumnStore((s) => s.toggleColumnVisibility);
+ const resetPageSettingsAction = useTableColumnStore((s) => s.resetPageSettings);
const visibleColumns = useMemo(() => {
return columns.filter((col) => !settings.hiddenColumns.includes(col.key));
@@ -33,22 +35,22 @@ export function useColumnSettings({ pageId, columns, alwaysVisibleKeys = [] }: U
const setColumnWidth = useCallback(
(key: string, width: number) => {
- store.setColumnWidth(pageId, key, width);
+ setColumnWidthAction(pageId, key, width);
},
- [store, pageId]
+ [setColumnWidthAction, pageId]
);
const toggleColumnVisibility = useCallback(
(key: string) => {
if (alwaysVisibleKeys.includes(key)) return;
- store.toggleColumnVisibility(pageId, key);
+ toggleColumnVisibilityAction(pageId, key);
},
- [store, pageId, alwaysVisibleKeys]
+ [toggleColumnVisibilityAction, pageId, alwaysVisibleKeys]
);
const resetSettings = useCallback(() => {
- store.resetPageSettings(pageId);
- }, [store, pageId]);
+ resetPageSettingsAction(pageId);
+ }, [resetPageSettingsAction, pageId]);
const hasHiddenColumns = settings.hiddenColumns.length > 0;
diff --git a/src/layouts/AuthenticatedLayout.tsx b/src/layouts/AuthenticatedLayout.tsx
index ed99efef..cd9ea21c 100644
--- a/src/layouts/AuthenticatedLayout.tsx
+++ b/src/layouts/AuthenticatedLayout.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useMenuStore } from '@/stores/menuStore';
+import { useMenuStore, useMenuItems, useActiveMenu, useSidebarCollapsed, useMenuHydrated } from '@/stores/menuStore';
import type { SerializableMenuItem } from '@/stores/menuStore';
import type { MenuItem } from '@/stores/menuStore';
import { useRouter, usePathname } from 'next/navigation';
@@ -43,8 +43,8 @@ import {
import Sidebar from '@/components/layout/Sidebar';
import HeaderFavoritesBar from '@/components/layout/HeaderFavoritesBar';
import CommandMenuSearch, { type CommandMenuSearchRef } from '@/components/layout/CommandMenuSearch';
-import { useThemeStore } from '@/stores/themeStore';
-import { useAuth } from '@/contexts/AuthContext';
+import { useTheme, useSetTheme } from '@/stores/themeStore';
+import { useAuthStore } from '@/stores/authStore';
import { deserializeMenuItems } from '@/lib/utils/menuTransform';
import { stripLocalePrefix } from '@/lib/utils/locale';
import { safeJsonParse } from '@/lib/utils';
@@ -96,9 +96,16 @@ interface AuthenticatedLayoutProps {
}
export default function AuthenticatedLayout({ children }: AuthenticatedLayoutProps) {
- const { menuItems, activeMenu, setActiveMenu, setMenuItems, sidebarCollapsed, toggleSidebar, _hasHydrated } = useMenuStore();
- const { theme, setTheme } = useThemeStore();
- const { logout } = useAuth();
+ const menuItems = useMenuItems();
+ const activeMenu = useActiveMenu();
+ const sidebarCollapsed = useSidebarCollapsed();
+ const _hasHydrated = useMenuHydrated();
+ const setActiveMenu = useMenuStore((s) => s.setActiveMenu);
+ const setMenuItems = useMenuStore((s) => s.setMenuItems);
+ const toggleSidebar = useMenuStore((s) => s.toggleSidebar);
+ const theme = useTheme();
+ const setTheme = useSetTheme();
+ const logout = useAuthStore((state) => state.logout);
const router = useRouter();
const pathname = usePathname(); // 현재 경로 추적
diff --git a/src/lib/api/toast-utils.ts b/src/lib/api/toast-utils.ts
deleted file mode 100644
index 64febbe8..00000000
--- a/src/lib/api/toast-utils.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-/**
- * API 에러 토스트 유틸리티
- * - 개발 중 디버깅을 위해 에러 코드와 메시지를 함께 표시
- * - 나중에 프로덕션에서 코드 숨기려면 이 파일만 수정하면 됨
- */
-import { toast } from 'sonner';
-import { ApiError, DuplicateCodeError, getErrorMessage } from './error-handler';
-
-/**
- * 디버그 모드 설정
- * - true: 에러 코드 표시 (개발/테스트)
- * - false: 메시지만 표시 (프로덕션)
- *
- * TODO: 프로덕션 배포 시 false로 변경하거나 환경변수 사용
- */
-const SHOW_ERROR_CODE = true;
-
-/**
- * API 에러를 토스트로 표시
- * - ApiError: [상태코드] 메시지 형식
- * - DuplicateCodeError: 중복 코드 정보 포함
- * - 일반 Error: 메시지만 표시
- *
- * @param error - 발생한 에러 객체
- * @param fallbackMessage - 에러 메시지가 없을 때 표시할 기본 메시지
- */
-export function toastApiError(
- error: unknown,
- fallbackMessage = '오류가 발생했습니다.'
-): void {
- // DuplicateCodeError - 중복 코드 에러 (별도 처리 필요할 수 있음)
- if (error instanceof DuplicateCodeError) {
- const message = SHOW_ERROR_CODE
- ? `[${error.status}] ${error.message} (코드: ${error.duplicateCode})`
- : error.message;
- toast.error(message);
- return;
- }
-
- // ApiError - HTTP 에러
- if (error instanceof ApiError) {
- const message = SHOW_ERROR_CODE
- ? `[${error.status}] ${error.message}`
- : error.message;
-
- // Validation 에러가 있으면 첫 번째 에러도 표시
- if (error.errors && SHOW_ERROR_CODE) {
- const firstErrorField = Object.keys(error.errors)[0];
- if (firstErrorField) {
- const firstError = error.errors[firstErrorField][0];
- toast.error(`${message}\n${firstErrorField}: ${firstError}`);
- return;
- }
- }
-
- toast.error(message);
- return;
- }
-
- // 일반 Error
- if (error instanceof Error) {
- toast.error(error.message || fallbackMessage);
- return;
- }
-
- // unknown 타입
- toast.error(fallbackMessage);
-}
-
-/**
- * API 성공 토스트
- * - 일관된 성공 메시지 표시
- *
- * @param message - 성공 메시지
- */
-export function toastSuccess(message: string): void {
- toast.success(message);
-}
-
-/**
- * API 경고 토스트
- *
- * @param message - 경고 메시지
- */
-export function toastWarning(message: string): void {
- toast.warning(message);
-}
-
-/**
- * API 정보 토스트
- *
- * @param message - 정보 메시지
- */
-export function toastInfo(message: string): void {
- toast.info(message);
-}
-
-/**
- * 에러 메시지 포맷팅 (토스트 외 용도)
- * - 에러 코드 포함 여부는 SHOW_ERROR_CODE 설정 따름
- *
- * @param error - 발생한 에러 객체
- * @param fallbackMessage - 기본 메시지
- * @returns 포맷팅된 에러 메시지
- */
-export function formatApiError(
- error: unknown,
- fallbackMessage = '오류가 발생했습니다.'
-): string {
- if (error instanceof ApiError) {
- return SHOW_ERROR_CODE
- ? `[${error.status}] ${error.message}`
- : error.message;
- }
- return getErrorMessage(error) || fallbackMessage;
-}
\ No newline at end of file
diff --git a/src/lib/auth/logout.ts b/src/lib/auth/logout.ts
index cffad69c..b5393017 100644
--- a/src/lib/auth/logout.ts
+++ b/src/lib/auth/logout.ts
@@ -13,6 +13,7 @@
import { useMasterDataStore } from '@/stores/masterDataStore';
import { useItemMasterStore } from '@/stores/item-master/useItemMasterStore';
+import { useAuthStore } from '@/stores/authStore';
// FCM은 Capacitor 환경에서만 사용 (동적 import로 웹 빌드 에러 방지)
@@ -87,6 +88,9 @@ export function clearLocalStorageCache(): void {
*/
export function resetZustandStores(): void {
try {
+ // authStore 초기화
+ useAuthStore.getState().resetAllData();
+
// masterDataStore 초기화
const masterDataStore = useMasterDataStore.getState();
masterDataStore.reset();
diff --git a/src/lib/utils/excel-download.ts b/src/lib/utils/excel-download.ts
index 6bb58966..1e89b5d6 100644
--- a/src/lib/utils/excel-download.ts
+++ b/src/lib/utils/excel-download.ts
@@ -23,6 +23,7 @@
*/
import { getTodayString } from '@/lib/utils/date';
+import { generateExportFilename } from '@/lib/utils/export';
// xlsx는 ~400KB로 무거워서, 실제 사용 시점에 동적 로드
async function loadXLSX() {
@@ -74,17 +75,13 @@ function getNestedValue(obj: Record, path: string): unknown {
/**
* 날짜 형식의 파일명 생성
+ * export.ts의 generateExportFilename에 위임
*/
function generateFilename(baseName: string, appendDate: boolean): string {
if (!appendDate) {
return `${baseName}.xlsx`;
}
-
- const now = new Date();
- const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
- const timeStr = now.toTimeString().slice(0, 5).replace(/:/g, '');
-
- return `${baseName}_${dateStr}_${timeStr}.xlsx`;
+ return generateExportFilename(baseName, 'xlsx');
}
/**
diff --git a/src/lib/utils/fileDownload.ts b/src/lib/utils/fileDownload.ts
index 5259fa32..ad29b55c 100644
--- a/src/lib/utils/fileDownload.ts
+++ b/src/lib/utils/fileDownload.ts
@@ -5,6 +5,26 @@
* 프록시: GET /api/proxy/files/{id}/download
*/
+import { downloadBlob } from './export';
+
+/**
+ * Content-Disposition 헤더에서 파일명 추출
+ */
+function extractFilenameFromHeader(response: Response): string | null {
+ const contentDisposition = response.headers.get('Content-Disposition');
+ if (!contentDisposition) return null;
+
+ const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
+ if (!match?.[1]) return null;
+
+ const raw = match[1].replace(/['"]/g, '');
+ try {
+ return decodeURIComponent(raw);
+ } catch {
+ return raw;
+ }
+}
+
/**
* 파일 ID로 다운로드
* @param fileId 파일 ID
@@ -19,40 +39,11 @@ export async function downloadFileById(fileId: number, fileName?: string): Promi
}
const blob = await response.blob();
+ const downloadFileName = fileName
+ ?? extractFilenameFromHeader(response)
+ ?? `file_${fileId}`;
- // 파일명이 없으면 Content-Disposition 헤더에서 추출 시도
- let downloadFileName = fileName;
- if (!downloadFileName) {
- const contentDisposition = response.headers.get('Content-Disposition');
- if (contentDisposition) {
- const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
- if (match && match[1]) {
- downloadFileName = match[1].replace(/['"]/g, '');
- // URL 디코딩 (한글 파일명 처리)
- try {
- downloadFileName = decodeURIComponent(downloadFileName);
- } catch {
- // 디코딩 실패 시 그대로 사용
- }
- }
- }
- }
-
- // 그래도 없으면 기본 파일명
- if (!downloadFileName) {
- downloadFileName = `file_${fileId}`;
- }
-
- // Blob URL 생성 및 다운로드 트리거
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = downloadFileName;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
-
+ downloadBlob(blob, downloadFileName);
} catch (error) {
console.error('[fileDownload] 다운로드 오류:', error);
throw error;
diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts
new file mode 100644
index 00000000..b8089d24
--- /dev/null
+++ b/src/stores/authStore.ts
@@ -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) => void;
+ deleteUser: (userId: string) => void;
+ getUserByUserId: (userId: string) => User | undefined;
+ logout: () => Promise;
+ 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>(() => ({
+ 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()(
+ 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);
diff --git a/src/stores/menuStore.ts b/src/stores/menuStore.ts
index a926673e..834eed25 100644
--- a/src/stores/menuStore.ts
+++ b/src/stores/menuStore.ts
@@ -63,4 +63,22 @@ export const useMenuStore = create()(
},
}
)
-);
\ No newline at end of file
+);
+
+// ===== 셀렉터 훅 =====
+
+/** 사이드바 접힘 상태만 구독 */
+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);
\ No newline at end of file
diff --git a/src/stores/themeStore.ts b/src/stores/themeStore.ts
index 96a1f885..e6fe3ddf 100644
--- a/src/stores/themeStore.ts
+++ b/src/stores/themeStore.ts
@@ -41,4 +41,14 @@ export const useThemeStore = create()(
},
}
)
-);
\ No newline at end of file
+);
+
+// ===== 셀렉터 훅 =====
+
+/** 현재 테마만 구독 */
+export const useTheme = () =>
+ useThemeStore((state) => state.theme);
+
+/** setTheme 액션만 구독 */
+export const useSetTheme = () =>
+ useThemeStore((state) => state.setTheme);
\ No newline at end of file
diff --git a/src/stores/useTableColumnStore.ts b/src/stores/useTableColumnStore.ts
index e7dc836a..ba5e1917 100644
--- a/src/stores/useTableColumnStore.ts
+++ b/src/stores/useTableColumnStore.ts
@@ -99,3 +99,17 @@ export const useTableColumnStore = create()(
}
)
);
+
+// ===== 셀렉터 훅 =====
+
+/** 특정 페이지의 컬럼 설정만 구독 */
+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 ?? {});