- {renderDocumentContent()}
+ {/* Content Area - 줌/드래그 가능한 영역 */}
+
100 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
+ >
+
+ {renderDocumentContent()}
+
+
+ {/* 모바일 줌 힌트 */}
+ {zoom === 100 && (
+
+ 확대 후 드래그로 이동
+
+ )}
);
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
index c52a131a..d941f281 100644
--- a/src/app/api/auth/login/route.ts
+++ b/src/app/api/auth/login/route.ts
@@ -158,6 +158,7 @@ export async function POST(request: NextRequest) {
'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility)
'Path=/',
`Max-Age=${data.expires_in || 7200}`,
+ // `Max-Age=10` // 여기서만 10초 하면 최초 1회만 10 초 후에 액세스 끊어짐 구동테스트 완벽하게 하기 위해서는 refresh 쪽도 10초로 수정해야 함
].join('; ');
const refreshTokenCookie = [
@@ -166,7 +167,7 @@ export async function POST(request: NextRequest) {
...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production (Safari fix)
'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility)
'Path=/',
- 'Max-Age=604800', // 7 days (longer for refresh token)
+ 'Max-Age=604800', // TODO: 테스트용 10초, 원래 604800 (7 days)
].join('; ');
console.log('✅ Login successful - Access & Refresh tokens stored in HttpOnly cookies');
diff --git a/src/components/auth/LoginPage.tsx b/src/components/auth/LoginPage.tsx
index c25ea9bc..919f54bf 100644
--- a/src/components/auth/LoginPage.tsx
+++ b/src/components/auth/LoginPage.tsx
@@ -22,8 +22,8 @@ export function LoginPage() {
const t = useTranslations('auth');
const tCommon = useTranslations('common');
const tValidation = useTranslations('validation');
- const [userId, setUserId] = useState("");
- const [password, setPassword] = useState("");
+ const [userId, setUserId] = useState("TestUser5");
+ const [password, setPassword] = useState("password123!");
const [showPassword, setShowPassword] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const [error, setError] = useState("");
@@ -130,6 +130,9 @@ export function LoginPage() {
console.log('💾 localStorage에 저장할 데이터:', userData);
localStorage.setItem('user', JSON.stringify(userData));
+ // 메뉴 폴링 재시작 플래그 설정 (세션 만료 후 재로그인 시)
+ sessionStorage.setItem('auth_just_logged_in', 'true');
+
// 대시보드로 이동
router.push("/dashboard");
} catch (err) {
diff --git a/src/components/reports/ComprehensiveAnalysis/index.tsx b/src/components/reports/ComprehensiveAnalysis/index.tsx
index a7fea007..15d2a507 100644
--- a/src/components/reports/ComprehensiveAnalysis/index.tsx
+++ b/src/components/reports/ComprehensiveAnalysis/index.tsx
@@ -34,15 +34,96 @@ const formatAmount = (amount: number): string => {
return new Intl.NumberFormat('ko-KR').format(amount) + '원';
};
-// 기본 데이터
+// 기본 데이터 (목데이터)
const defaultData: ComprehensiveAnalysisData = {
- todayIssue: { filterOptions: ['전체필터'], items: [] },
- monthlyExpense: { cards: [], checkPoints: [] },
- cardManagement: { cards: [], checkPoints: [] },
- entertainment: { cards: [], checkPoints: [] },
- welfare: { cards: [], checkPoints: [] },
- receivable: { cards: [], checkPoints: [], hasDetailButton: true, detailButtonLabel: '거래처별 미수금 현황', detailButtonPath: '/accounting/receivables-status' },
- debtCollection: { cards: [], checkPoints: [] },
+ todayIssue: {
+ filterOptions: ['전체필터', '결재', '매출', '매입', '자금'],
+ items: [
+ { id: '1', category: '결재', description: '지출결의서 #2024-001 승인 대기 중', requiresApproval: true, time: '09:30' },
+ { id: '2', category: '매출', description: '(주)대한전자 외상매출금 90일 초과', requiresApproval: false, time: '10:15' },
+ { id: '3', category: '자금', description: '법인카드 한도 초과 경고', requiresApproval: false, time: '11:00' },
+ ],
+ },
+ monthlyExpense: {
+ cards: [
+ { id: 'me1', label: '매입', amount: 3123000, previousAmount: 2826000, previousLabel: '전월 대비 +10.5%' },
+ { id: 'me2', label: '카드', amount: 3123000, previousAmount: 2826000, previousLabel: '전월 대비 +10.5%' },
+ { id: 'me3', label: '발행어음', amount: 3123000, previousAmount: 2826000, previousLabel: '전월 대비 +10.5%' },
+ { id: 'me4', label: '총 예상 지출 합계', amount: 3123000, previousAmount: 2826000, previousLabel: '전월 대비 +10.5%' },
+ ],
+ checkPoints: [
+ { id: 'me-cp1', type: 'warning', message: '이번 달 예상 지출이', highlight: '전월 대비 15% 증가했습니다. 매입 비용 증가가 주요 원인입니다.' },
+ { id: 'me-cp2', type: 'error', message: '이번 달 예상 지출이', highlight: '예산을 12% 초과했습니다. 비용 항목별 점검이 필요합니다.' },
+ { id: 'me-cp3', type: 'success', message: '이번 달 예상 지출이', highlight: '전월 대비 8% 감소했습니다. (계정과목명) 비용이 줄었습니다.' },
+ ],
+ },
+ cardManagement: {
+ cards: [
+ { id: 'cm1', label: '카드', amount: 3123000, subAmount: 50000, subLabel: '약정 5만 (가지급금 예외)' },
+ { id: 'cm2', label: '가지급금', amount: 3123000, previousAmount: 2826000, previousLabel: '전월 대비 +10.5%' },
+ { id: 'cm3', label: '미정산 가불', amount: 3123000, previousAmount: 2826000, previousLabel: '전월 대비 +10.5%' },
+ { id: 'cm4', label: '총 잔액', amount: 3123000, previousAmount: 2826000, previousLabel: '전월 대비 +10.5%' },
+ ],
+ checkPoints: [
+ { id: 'cm-cp1', type: 'warning', message: '법인카드 사용 총 85만원이 가지급금으로 전환되었습니다.', highlight: '연 4.6% 인정이자가 발생합니다.' },
+ { id: 'cm-cp2', type: 'warning', message: '전 가지급금 1,520만원은 4.6%,', highlight: '연 약 70만원의 인정이자가 발생합니다.' },
+ { id: 'cm-cp3', type: 'warning', message: '상품권/귀금속 등 현대비 불인정 항목 매입 건이 있습니다. 가지급금 처리 예정입니다.' },
+ { id: 'cm-cp4', type: 'info', message: '주말 카드 사용 총 100만원 중 결과 지의. 업무관련성 소명이 어려울 수 있으니 기록을 남겨주세요.' },
+ ],
+ },
+ entertainment: {
+ cards: [
+ { id: 'et1', label: '접대비 한도', amount: 30000123000, subAmount: 2123, subLabel: '사용' },
+ { id: 'et2', label: '접대비 사용액', amount: 40123000 },
+ { id: 'et3', label: '한도 잔액', amount: 30123000 },
+ { id: 'et4', label: '전월 사용액', amount: 3123000 },
+ ],
+ checkPoints: [
+ { id: 'et-cp1', type: 'success', message: '접대비 사용 총 2,400만원 중 / 한도 4,000만원 (60%).', highlight: '여유 있게 운영 중입니다.' },
+ { id: 'et-cp2', type: 'warning', message: '접대비 85% 도달. 연내 한도 600만원 잔액입니다.', highlight: '사용 계획에 집중해 주세요.' },
+ { id: 'et-cp3', type: 'error', message: '접대비 한도 초과 320만원 발생.', highlight: '손금불산입되어 법인세 부담 증가합니다.' },
+ { id: 'et-cp4', type: 'info', message: '접대비 사용 총 3건(45만원)이 거래처 한도 누락되어있습니다. 기록을 보완해 주세요.' },
+ ],
+ },
+ welfare: {
+ cards: [
+ { id: 'wf1', label: '총 복리후생비 한도', amount: 3123000 },
+ { id: 'wf2', label: '누적 복리후생비 사용', amount: 1123000 },
+ { id: 'wf3', label: '잠정 복리후생비 사용액', amount: 30123000 },
+ { id: 'wf4', label: '잠정 복리후생비 누적 한도', amount: 3123000 },
+ ],
+ checkPoints: [
+ { id: 'wf-cp1', type: 'success', message: '1인당 월 복리후생비 18만원. 업계 평균(15~25만원) 내', highlight: '정상 운영 중입니다.' },
+ { id: 'wf-cp2', type: 'warning', message: '식대가 월 25만원으로', highlight: '비과세 한도(20만원)를 초과했습니다. 초과분은 근로소득 과세됩니다.' },
+ ],
+ },
+ receivable: {
+ cards: [
+ { id: 'rv1', label: '누계 미수금', amount: 30123000, subAmount: 6123000, subLabel: '매출', previousAmount: 30000, previousLabel: '입금' },
+ { id: 'rv2', label: '30일 초과', amount: 30123000, subAmount: 60123000, subLabel: '매출', previousAmount: 30000, previousLabel: '입금' },
+ { id: 'rv3', label: '60일 초과', amount: 3123000, subAmount: 6123000, subLabel: '매출', previousAmount: 30000, previousLabel: '입금' },
+ { id: 'rv4', label: '90일 초과', amount: 3123000, subAmount: 6123000, subLabel: '매출', previousAmount: 30000, previousLabel: '입금' },
+ ],
+ checkPoints: [
+ { id: 'rv-cp1', type: 'error', message: '90일 이상 장기 미수금 3건(2,500만원) 발생.', highlight: '회수 조치가 필요합니다.' },
+ { id: 'rv-cp2', type: 'warning', message: '(주)대한전자 미수금 4,500만원으로', highlight: '전체의 35%를 차지합니다. 리스크 분산이 필요합니다.' },
+ ],
+ hasDetailButton: true,
+ detailButtonLabel: '거래처별 미수금 현황',
+ detailButtonPath: '/accounting/receivables-status',
+ },
+ debtCollection: {
+ cards: [
+ { id: 'dc1', label: '총 채권', amount: 30123000, subAmount: 25, subLabel: '건' },
+ { id: 'dc2', label: '추심 진행', amount: 30123000, subAmount: 12, subLabel: '건' },
+ { id: 'dc3', label: '추심 완료', amount: 3123000, subAmount: 3, subLabel: '건' },
+ { id: 'dc4', label: '포기/손실', amount: 3123000, subAmount: 10, subLabel: '건' },
+ ],
+ checkPoints: [
+ { id: 'dc-cp1', type: 'info', message: '(주)대한전자 건 지급명령 신청 완료.', highlight: '법원 결정까지 약 2주 소요 예정입니다.' },
+ { id: 'dc-cp2', type: 'warning', message: '(주)삼성테크 건 회수 불가 판정.', highlight: '대손 처리 검토가 필요합니다.' },
+ ],
+ },
};
// Props 인터페이스
@@ -199,25 +280,27 @@ export default function ComprehensiveAnalysis({ initialData }: ComprehensiveAnal
const [rejectTargetId, setRejectTargetId] = useState
(null);
const [rejectReason, setRejectReason] = useState('');
- // 데이터 로드
- const loadData = useCallback(async () => {
- setIsLoading(true);
- try {
- const result = await getComprehensiveAnalysis();
- if (result.success && result.data) {
- setData(result.data);
- }
- } catch (error) {
- console.error('Failed to load comprehensive analysis:', error);
- } finally {
- setIsLoading(false);
- }
- }, []);
+ // 데이터 로드 (API 연동 시 활성화)
+ // const loadData = useCallback(async () => {
+ // setIsLoading(true);
+ // try {
+ // const result = await getComprehensiveAnalysis();
+ // if (result.success && result.data) {
+ // setData(result.data);
+ // }
+ // } catch (error) {
+ // console.error('Failed to load comprehensive analysis:', error);
+ // } finally {
+ // setIsLoading(false);
+ // }
+ // }, []);
- // 초기 로드
+ // 초기 로드 - 현재 목데이터 사용 중 (API 연동 시 활성화)
useEffect(() => {
- loadData();
- }, [loadData]);
+ // API 연동 시 아래 주석 해제
+ // loadData();
+ setIsLoading(false);
+ }, []);
const handleReceivableDetail = () => {
router.push('/ko/accounting/receivables-status');
diff --git a/src/hooks/useMenuPolling.ts b/src/hooks/useMenuPolling.ts
index 24ae3540..4bad92c3 100644
--- a/src/hooks/useMenuPolling.ts
+++ b/src/hooks/useMenuPolling.ts
@@ -2,6 +2,8 @@
* 메뉴 폴링 훅
*
* 일정 간격으로 메뉴 변경사항을 확인하고 자동 갱신합니다.
+ * 401 응답이 3회 연속 발생하면 세션 만료로 판단하고 폴링을 중지합니다.
+ * 토큰 갱신 시 자동으로 폴링을 재시작합니다.
*
* @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md
*/
@@ -9,6 +11,26 @@
import { useEffect, useRef, useCallback } from 'react';
import { refreshMenus } from '@/lib/utils/menuRefresh';
+// 토큰 갱신 신호 쿠키 이름
+const TOKEN_REFRESHED_COOKIE = 'token_refreshed_at';
+
+/**
+ * 쿠키에서 값 읽기
+ */
+function getCookieValue(name: string): string | null {
+ if (typeof document === 'undefined') return null;
+ const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
+ return match ? match[2] : null;
+}
+
+/**
+ * 쿠키 삭제
+ */
+function deleteCookie(name: string): void {
+ if (typeof document === 'undefined') return;
+ document.cookie = `${name}=; Path=/; Max-Age=0`;
+}
+
// 기본 폴링 간격: 30초
const DEFAULT_POLLING_INTERVAL = 30 * 1000;
@@ -18,6 +40,9 @@ const MIN_POLLING_INTERVAL = 10 * 1000;
// 최대 폴링 간격: 5분
const MAX_POLLING_INTERVAL = 5 * 60 * 1000;
+// 세션 만료 판단 기준: 연속 401 횟수
+const MAX_SESSION_EXPIRED_COUNT = 3;
+
interface UseMenuPollingOptions {
/** 폴링 활성화 여부 (기본: true) */
enabled?: boolean;
@@ -27,6 +52,8 @@ interface UseMenuPollingOptions {
onMenuUpdated?: () => void;
/** 에러 발생 시 콜백 */
onError?: (error: string) => void;
+ /** 세션 만료로 폴링 중지 시 콜백 */
+ onSessionExpired?: () => void;
}
interface UseMenuPollingReturn {
@@ -36,8 +63,12 @@ interface UseMenuPollingReturn {
pause: () => void;
/** 폴링 재개 */
resume: () => void;
+ /** 인증 성공 후 폴링 재시작 (401 카운트 리셋) */
+ restartAfterAuth: () => void;
/** 현재 폴링 상태 */
isPaused: boolean;
+ /** 세션 만료로 중지된 상태 */
+ isSessionExpired: boolean;
}
/**
@@ -64,6 +95,7 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
interval = DEFAULT_POLLING_INTERVAL,
onMenuUpdated,
onError,
+ onSessionExpired,
} = options;
// 폴링 간격 유효성 검사
@@ -71,21 +103,53 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
const intervalRef = useRef(null);
const isPausedRef = useRef(false);
+ const sessionExpiredCountRef = useRef(0); // 연속 401 카운트
+ const isSessionExpiredRef = useRef(false); // 세션 만료로 중지된 상태
+
+ // 폴링 중지 (내부용)
+ const stopPolling = useCallback(() => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ }, []);
// 메뉴 갱신 실행
const executeRefresh = useCallback(async () => {
- if (isPausedRef.current) return;
+ if (isPausedRef.current || isSessionExpiredRef.current) return;
const result = await refreshMenus();
- if (result.success && result.updated) {
- onMenuUpdated?.();
+ // 성공 시 401 카운트 리셋
+ if (result.success) {
+ sessionExpiredCountRef.current = 0;
+
+ if (result.updated) {
+ onMenuUpdated?.();
+ }
+ return;
}
- if (!result.success && result.error) {
+ // 401 세션 만료 응답
+ if (result.sessionExpired) {
+ sessionExpiredCountRef.current += 1;
+ console.log(`[Menu] 401 응답 (${sessionExpiredCountRef.current}/${MAX_SESSION_EXPIRED_COUNT})`);
+
+ // 3회 연속 401 → 폴링 중지
+ if (sessionExpiredCountRef.current >= MAX_SESSION_EXPIRED_COUNT) {
+ console.log('[Menu] 세션 만료로 폴링 중지');
+ isSessionExpiredRef.current = true;
+ stopPolling();
+ onSessionExpired?.();
+ }
+ return;
+ }
+
+ // 기타 에러 (네트워크 등) → 401 카운트 리셋하지 않음
+ if (result.error) {
onError?.(result.error);
}
- }, [onMenuUpdated, onError]);
+ }, [onMenuUpdated, onError, onSessionExpired, stopPolling]);
// 수동 갱신 함수
const refresh = useCallback(async () => {
@@ -95,27 +159,41 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
// 폴링 일시 중지
const pause = useCallback(() => {
isPausedRef.current = true;
- if (intervalRef.current) {
- clearInterval(intervalRef.current);
- intervalRef.current = null;
- }
- }, []);
+ stopPolling();
+ }, [stopPolling]);
// 폴링 재개
const resume = useCallback(() => {
+ // 세션 만료 상태면 재개 불가 (restartAfterAuth 사용)
+ if (isSessionExpiredRef.current) {
+ console.log('[Menu] 세션 만료 상태 - resume 불가, restartAfterAuth 사용 필요');
+ return;
+ }
+
isPausedRef.current = false;
if (enabled && !intervalRef.current) {
intervalRef.current = setInterval(executeRefresh, safeInterval);
}
}, [enabled, safeInterval, executeRefresh]);
+ // 인증 성공 후 폴링 재시작 (401 카운트 리셋)
+ const restartAfterAuth = useCallback(() => {
+ console.log('[Menu] 인증 성공 - 폴링 재시작');
+ sessionExpiredCountRef.current = 0;
+ isSessionExpiredRef.current = false;
+ isPausedRef.current = false;
+
+ if (enabled && !intervalRef.current) {
+ // 즉시 한 번 실행 후 폴링 시작
+ executeRefresh();
+ intervalRef.current = setInterval(executeRefresh, safeInterval);
+ }
+ }, [enabled, safeInterval, executeRefresh]);
+
// 폴링 설정
useEffect(() => {
- if (!enabled) {
- if (intervalRef.current) {
- clearInterval(intervalRef.current);
- intervalRef.current = null;
- }
+ if (!enabled || isSessionExpiredRef.current) {
+ stopPolling();
return;
}
@@ -124,24 +202,21 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
intervalRef.current = setInterval(executeRefresh, safeInterval);
return () => {
- if (intervalRef.current) {
- clearInterval(intervalRef.current);
- intervalRef.current = null;
- }
+ stopPolling();
};
- }, [enabled, safeInterval, executeRefresh]);
+ }, [enabled, safeInterval, executeRefresh, stopPolling]);
// 탭 가시성 변경 시 처리
useEffect(() => {
if (!enabled) return;
const handleVisibilityChange = () => {
+ // 세션 만료 상태면 무시
+ if (isSessionExpiredRef.current) return;
+
if (document.hidden) {
// 탭이 숨겨지면 폴링 중지 (리소스 절약)
- if (intervalRef.current) {
- clearInterval(intervalRef.current);
- intervalRef.current = null;
- }
+ stopPolling();
} else {
// 탭이 다시 보이면 즉시 갱신 후 폴링 재개
if (!isPausedRef.current) {
@@ -156,13 +231,57 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
+ }, [enabled, safeInterval, executeRefresh, stopPolling]);
+
+ // 🔔 토큰 갱신 신호 쿠키 감지 → 폴링 자동 재시작
+ // 서버에서 토큰 갱신 시 설정한 쿠키를 감지하여 폴링을 재시작
+ useEffect(() => {
+ if (!enabled) return;
+
+ // 마지막으로 처리한 토큰 갱신 타임스탬프 추적
+ let lastProcessedTimestamp: string | null = null;
+
+ const checkTokenRefresh = () => {
+ const refreshedAt = getCookieValue(TOKEN_REFRESHED_COOKIE);
+
+ // 새로운 토큰 갱신 감지
+ if (refreshedAt && refreshedAt !== lastProcessedTimestamp) {
+ lastProcessedTimestamp = refreshedAt;
+
+ // 세션 만료 상태였다면 폴링 재시작
+ if (isSessionExpiredRef.current) {
+ console.log('[Menu] 🔔 토큰 갱신 감지 - 폴링 재시작');
+ sessionExpiredCountRef.current = 0;
+ isSessionExpiredRef.current = false;
+ isPausedRef.current = false;
+
+ // 폴링 재시작
+ if (!intervalRef.current) {
+ executeRefresh();
+ intervalRef.current = setInterval(executeRefresh, safeInterval);
+ }
+ }
+
+ // 쿠키 삭제 (처리 완료)
+ deleteCookie(TOKEN_REFRESHED_COOKIE);
+ }
+ };
+
+ // 1초마다 쿠키 확인
+ const checkInterval = setInterval(checkTokenRefresh, 1000);
+
+ return () => {
+ clearInterval(checkInterval);
+ };
}, [enabled, safeInterval, executeRefresh]);
return {
refresh,
pause,
resume,
+ restartAfterAuth,
isPaused: isPausedRef.current,
+ isSessionExpired: isSessionExpiredRef.current,
};
}
diff --git a/src/layouts/AuthenticatedLayout.tsx b/src/layouts/AuthenticatedLayout.tsx
index ec9041e6..614e5c16 100644
--- a/src/layouts/AuthenticatedLayout.tsx
+++ b/src/layouts/AuthenticatedLayout.tsx
@@ -23,6 +23,8 @@ import {
ChevronLeft,
Home,
X,
+ BarChart3,
+ Award,
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@@ -63,14 +65,27 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
// 메뉴 폴링 (30초마다 메뉴 변경 확인)
// 백엔드 GET /api/v1/menus API 준비되면 자동 동작
- useMenuPolling({
+ const { restartAfterAuth } = useMenuPolling({
enabled: true,
interval: 30000, // 30초
onMenuUpdated: () => {
console.log('[Menu] 메뉴가 업데이트되었습니다');
},
+ onSessionExpired: () => {
+ console.log('[Menu] 세션 만료로 폴링 중지됨');
+ },
});
+ // 로그인 성공 후 메뉴 폴링 재시작
+ useEffect(() => {
+ const justLoggedIn = sessionStorage.getItem('auth_just_logged_in');
+ if (justLoggedIn === 'true') {
+ console.log('[Menu] 로그인 감지 - 폴링 재시작');
+ sessionStorage.removeItem('auth_just_logged_in');
+ restartAfterAuth();
+ }
+ }, [restartAfterAuth]);
+
// 모바일 감지
useEffect(() => {
const checkScreenSize = () => {
@@ -283,8 +298,30 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
)}
- {/* 우측 영역: 검색, 테마, 유저, 메뉴 */}
+ {/* 우측 영역: 종합분석, 품질인정심사, 검색, 테마, 유저, 메뉴 */}