fix(WEB): 마스터데이터 캐시 테넌트 격리 및 상세 템플릿 개선

- masterDataStore: 테넌트별 캐시 격리 로직 강화
- AuthContext: 인증 컨텍스트 안정성 개선
- IntegratedDetailTemplate: 상세 템플릿 동작 수정
- VendorDetail: 거래처 상세 수정
- AttendanceManagement: 타입 정의 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-29 16:57:49 +09:00
parent 4014b3fb84
commit 106ce09482
7 changed files with 211 additions and 29 deletions

View File

@@ -362,7 +362,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
error={!!validationErrors.businessNumber}
/>
</div>
{renderField('거래처코드', 'vendorCode', formData.vendorCode, { placeholder: '자동생성' })}
{renderField('거래처코드', 'vendorCode', formData.vendorCode, { placeholder: '자동생성', disabled: true })}
{renderField('거래처명', 'vendorName', formData.vendorName, { required: true })}
{renderField('대표자명', 'representativeName', formData.representativeName)}
{renderSelectField('거래처 유형', 'category', formData.category, VENDOR_CATEGORY_SELECTOR_OPTIONS, true)}

View File

@@ -259,7 +259,9 @@ export const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => ({
export const MINUTE_OPTIONS = [
{ value: '0', label: '0분' },
{ value: '15', label: '15분' },
{ value: '30', label: '30분' },
{ value: '45', label: '45분' },
];
// 연장 시간 옵션 (0-12시간)

View File

@@ -232,8 +232,12 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
setFormData(transformed);
}
setErrors({});
// URL도 view 모드로 변경 (handleEdit와 대칭)
if (itemId) {
router.push(`/${locale}${config.basePath}/${itemId}?mode=view`);
}
}
}, [onCancel, isCreateMode, navigateToList, initialData, config.transformInitialData]);
}, [onCancel, isCreateMode, navigateToList, initialData, config.transformInitialData, itemId, router, locale, config.basePath]);
// ===== 제출 핸들러 =====
const handleSubmit = useCallback(async () => {

View File

@@ -2,6 +2,7 @@
import { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react';
import { performFullLogout } from '@/lib/auth/logout';
import { useMasterDataStore } from '@/stores/masterDataStore';
// ===== 타입 정의 =====
@@ -201,24 +202,31 @@ export function AuthProvider({ children }: { children: ReactNode }) {
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 prefix = `mes-${tenantId}-`;
const tenantAwarePrefix = `mes-${tenantId}-`;
const pageConfigPrefix = `page_config_${tenantId}_`;
// localStorage 캐시 삭제
Object.keys(localStorage).forEach(key => {
if (key.startsWith(prefix)) {
if (key.startsWith(tenantAwarePrefix)) {
localStorage.removeItem(key);
console.log(`[Cache] Cleared localStorage: ${key}`);
}
});
// sessionStorage 캐시 삭제
// sessionStorage 캐시 삭제 (TenantAwareCache + masterDataStore)
Object.keys(sessionStorage).forEach(key => {
if (key.startsWith(prefix)) {
if (key.startsWith(tenantAwarePrefix) || key.startsWith(pageConfigPrefix)) {
sessionStorage.removeItem(key);
console.log(`[Cache] Cleared sessionStorage: ${key}`);
}

View File

@@ -79,6 +79,7 @@ const createEmptyPageConfigs = (): Record<PageType, PageConfig | null> => ({
});
const initialState = {
currentTenantId: null as number | null,
pageConfigs: createEmptyPageConfigs(),
loading: {} as Record<PageType, boolean>,
errors: {} as Record<PageType, string | null>,
@@ -92,15 +93,27 @@ const STORAGE_PREFIX = 'page_config_';
const STORAGE_TIMESTAMP_SUFFIX = '_timestamp';
const CACHE_TTL = 10 * 60 * 1000; // 10분
/**
* 테넌트별 캐시 키 생성
* tenantId가 있으면: page_config_{tenantId}_{pageType}
* tenantId가 없으면: page_config_{pageType} (하위 호환)
*/
function getStorageKey(tenantId: number | null, pageType: PageType): string {
return tenantId != null
? `${STORAGE_PREFIX}${tenantId}_${pageType}`
: `${STORAGE_PREFIX}${pageType}`;
}
/**
* sessionStorage에서 페이지 구성 가져오기
*/
function getConfigFromSessionStorage(pageType: PageType): PageConfig | null {
function getConfigFromSessionStorage(tenantId: number | null, pageType: PageType): PageConfig | null {
if (typeof window === 'undefined') return null;
try {
const cachedData = window.sessionStorage.getItem(`${STORAGE_PREFIX}${pageType}`);
const timestamp = window.sessionStorage.getItem(`${STORAGE_PREFIX}${pageType}${STORAGE_TIMESTAMP_SUFFIX}`);
const key = getStorageKey(tenantId, pageType);
const cachedData = window.sessionStorage.getItem(key);
const timestamp = window.sessionStorage.getItem(`${key}${STORAGE_TIMESTAMP_SUFFIX}`);
if (!cachedData || !timestamp) return null;
@@ -108,8 +121,8 @@ function getConfigFromSessionStorage(pageType: PageType): PageConfig | null {
const cacheAge = Date.now() - parseInt(timestamp, 10);
if (cacheAge > CACHE_TTL) {
// 만료된 캐시 삭제
window.sessionStorage.removeItem(`${STORAGE_PREFIX}${pageType}`);
window.sessionStorage.removeItem(`${STORAGE_PREFIX}${pageType}${STORAGE_TIMESTAMP_SUFFIX}`);
window.sessionStorage.removeItem(key);
window.sessionStorage.removeItem(`${key}${STORAGE_TIMESTAMP_SUFFIX}`);
return null;
}
@@ -123,12 +136,13 @@ function getConfigFromSessionStorage(pageType: PageType): PageConfig | null {
/**
* sessionStorage에 페이지 구성 저장
*/
function setConfigToSessionStorage(pageType: PageType, config: PageConfig): void {
function setConfigToSessionStorage(tenantId: number | null, pageType: PageType, config: PageConfig): void {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(`${STORAGE_PREFIX}${pageType}`, JSON.stringify(config));
window.sessionStorage.setItem(`${STORAGE_PREFIX}${pageType}${STORAGE_TIMESTAMP_SUFFIX}`, Date.now().toString());
const key = getStorageKey(tenantId, pageType);
window.sessionStorage.setItem(key, JSON.stringify(config));
window.sessionStorage.setItem(`${key}${STORAGE_TIMESTAMP_SUFFIX}`, Date.now().toString());
} catch (error) {
console.error(`[sessionStorage] Failed to set config for ${pageType}:`, error);
}
@@ -137,12 +151,13 @@ function setConfigToSessionStorage(pageType: PageType, config: PageConfig): void
/**
* sessionStorage에서 페이지 구성 삭제
*/
function removeConfigFromSessionStorage(pageType: PageType): void {
function removeConfigFromSessionStorage(tenantId: number | null, pageType: PageType): void {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.removeItem(`${STORAGE_PREFIX}${pageType}`);
window.sessionStorage.removeItem(`${STORAGE_PREFIX}${pageType}${STORAGE_TIMESTAMP_SUFFIX}`);
const key = getStorageKey(tenantId, pageType);
window.sessionStorage.removeItem(key);
window.sessionStorage.removeItem(`${key}${STORAGE_TIMESTAMP_SUFFIX}`);
} catch (error) {
console.error(`[sessionStorage] Failed to remove config for ${pageType}:`, error);
}
@@ -156,10 +171,15 @@ export const useMasterDataStore = create<MasterDataStore>()(
// Initial state
...initialState,
// ===== 테넌트 관리 =====
setCurrentTenantId: (tenantId: number | null) =>
set({ currentTenantId: tenantId }, false, 'setCurrentTenantId'),
// ===== 하이브리드 로딩 전략 =====
fetchPageConfig: async (pageType: PageType) => {
const { pageConfigs, loading, errors } = get();
const { pageConfigs, loading, errors, currentTenantId } = get();
// 🔒 이미 로딩 중이면 중복 요청 방지
if (loading[pageType]) {
@@ -180,7 +200,7 @@ export const useMasterDataStore = create<MasterDataStore>()(
}
// 2⃣ sessionStorage 확인
const cachedConfig = getConfigFromSessionStorage(pageType);
const cachedConfig = getConfigFromSessionStorage(currentTenantId, pageType);
if (cachedConfig) {
console.log(`✅ [Cache Hit - Session] ${pageType}`);
@@ -239,7 +259,7 @@ export const useMasterDataStore = create<MasterDataStore>()(
'fetchPageConfig/success'
);
setConfigToSessionStorage(pageType, config);
setConfigToSessionStorage(currentTenantId, pageType, config);
console.log(`✅ [Config Loaded] ${pageType}`, config);
return config;
@@ -270,7 +290,7 @@ export const useMasterDataStore = create<MasterDataStore>()(
// ===== 캐시 무효화 =====
invalidateConfig: (pageType: PageType) => {
const { pageConfigs } = get();
const { pageConfigs, currentTenantId } = get();
const newConfigs = { ...pageConfigs };
delete newConfigs[pageType];
@@ -280,16 +300,17 @@ export const useMasterDataStore = create<MasterDataStore>()(
'invalidateConfig'
);
removeConfigFromSessionStorage(pageType);
removeConfigFromSessionStorage(currentTenantId, pageType);
console.log(`🗑️ [Cache Invalidated] ${pageType}`);
},
invalidateAllConfigs: () => {
const { currentTenantId } = get();
const pageTypes: PageType[] = ['item-master', 'quotation', 'sales-order', 'formula', 'pricing'];
pageTypes.forEach((pageType) => {
removeConfigFromSessionStorage(pageType);
removeConfigFromSessionStorage(currentTenantId, pageType);
});
set(
@@ -376,11 +397,12 @@ export const useMasterDataStore = create<MasterDataStore>()(
// 1. 메모리 캐시 초기화
set(initialState, false, 'reset');
// 2. sessionStorage 캐시도 정리
// 2. sessionStorage 캐시도 정리 (프리픽스 기반으로 모든 테넌트 캐시 제거)
if (typeof window !== 'undefined') {
const pageTypes: PageType[] = ['item-master', 'quotation', 'sales-order', 'formula', 'pricing'];
pageTypes.forEach((pageType) => {
removeConfigFromSessionStorage(pageType);
Object.keys(window.sessionStorage).forEach(key => {
if (key.startsWith(STORAGE_PREFIX)) {
window.sessionStorage.removeItem(key);
}
});
console.log('[masterDataStore] Reset: cleared memory and sessionStorage cache');
}