feat(WEB): 헤더 바로가기 버튼 추가 및 종합분석 목데이터 적용
- 공용 헤더에 종합분석/품질인정심사 바로가기 버튼 추가 (데스크톱/모바일) - 종합분석 페이지 목데이터 적용 (API 호출 비활성화) - 로그인 페이지 기본 계정 설정 - QMS 필터/모달 컴포넌트 개선 - 메뉴 폴링 및 fetch-wrapper 유틸리티 개선 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,64 +24,65 @@ export const Filters = ({
|
|||||||
const years = [2025, 2024, 2023, 2022, 2021];
|
const years = [2025, 2024, 2023, 2022, 2021];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-white p-4 rounded-lg mb-4 shadow-sm flex flex-wrap items-center gap-4">
|
<div className="w-full bg-white p-4 rounded-lg mb-4 shadow-sm">
|
||||||
{/* Year Selection */}
|
{/* 상단: 년도/분기 선택 */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-wrap items-end gap-4 mb-4">
|
||||||
<span className="text-xs font-semibold text-gray-500">년도</span>
|
{/* Year Selection */}
|
||||||
<div className="w-32">
|
<div className="flex flex-col gap-1">
|
||||||
<select
|
<span className="text-xs font-semibold text-gray-500">년도</span>
|
||||||
value={selectedYear}
|
<div className="w-32">
|
||||||
onChange={(e) => onYearChange(parseInt(e.target.value))}
|
<select
|
||||||
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
value={selectedYear}
|
||||||
>
|
onChange={(e) => onYearChange(parseInt(e.target.value))}
|
||||||
{years.map((year) => (
|
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
<option key={year} value={year}>{year}년</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quarter Selection */}
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-xs font-semibold text-gray-500">분기</span>
|
|
||||||
<div className="flex bg-gray-100 rounded-md p-1 gap-1">
|
|
||||||
{quarters.map((q) => (
|
|
||||||
<button
|
|
||||||
key={q}
|
|
||||||
onClick={() => onQuarterChange(q)}
|
|
||||||
className={`px-4 py-1.5 text-sm rounded-sm transition-all ${
|
|
||||||
selectedQuarter === q
|
|
||||||
? 'bg-blue-600 text-white shadow-sm'
|
|
||||||
: 'text-gray-600 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{q}
|
{years.map((year) => (
|
||||||
</button>
|
<option key={year} value={year}>{year}년</option>
|
||||||
))}
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quarter Selection */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-semibold text-gray-500">분기</span>
|
||||||
|
<div className="flex bg-gray-100 rounded-md p-1 gap-1 overflow-x-auto">
|
||||||
|
{quarters.map((q) => (
|
||||||
|
<button
|
||||||
|
key={q}
|
||||||
|
onClick={() => onQuarterChange(q)}
|
||||||
|
className={`px-3 sm:px-4 py-1.5 text-sm rounded-sm transition-all whitespace-nowrap ${
|
||||||
|
selectedQuarter === q
|
||||||
|
? 'bg-blue-600 text-white shadow-sm'
|
||||||
|
: 'text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{q}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Input */}
|
{/* 하단: 검색 입력 + 버튼 */}
|
||||||
<div className="flex flex-col gap-1 flex-1 min-w-[200px]">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs font-semibold text-gray-500">검색</span>
|
<span className="text-xs font-semibold text-gray-500">검색</span>
|
||||||
<div className="relative">
|
<div className="flex gap-2">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
<div className="relative flex-1">
|
||||||
<input
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||||
type="text"
|
<input
|
||||||
placeholder="품질관리서번호, 현장명, 인정품목..."
|
type="text"
|
||||||
value={searchTerm}
|
placeholder="품질관리서번호, 현장명, 인정품목..."
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
value={searchTerm}
|
||||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-md text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
/>
|
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-md text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="bg-[#1e3a8a] text-white px-6 py-2 rounded-md text-sm hover:bg-blue-800 transition-colors whitespace-nowrap">
|
||||||
|
조회
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Button */}
|
|
||||||
<div className="flex flex-col gap-1 justify-end h-full mt-auto mb-0.5">
|
|
||||||
<button className="bg-[#1e3a8a] text-white px-6 py-2 rounded-md text-sm hover:bg-blue-800 transition-colors">
|
|
||||||
조회
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { ZoomIn, ZoomOut, RotateCw, Download, Printer, AlertCircle } from 'lucide-react';
|
import { ZoomIn, ZoomOut, Download, Printer, AlertCircle, Maximize2 } from 'lucide-react';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Document, DocumentItem } from '../types';
|
import { Document, DocumentItem } from '../types';
|
||||||
import { MOCK_ORDER_DATA, MOCK_WORK_ORDER, MOCK_SHIPMENT_DETAIL } from '../mockData';
|
import { MOCK_ORDER_DATA, MOCK_WORK_ORDER, MOCK_SHIPMENT_DETAIL } from '../mockData';
|
||||||
@@ -324,7 +324,102 @@ const WorkLogDocument = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 줌 레벨 상수
|
||||||
|
const ZOOM_LEVELS = [50, 75, 100, 125, 150, 200];
|
||||||
|
const MIN_ZOOM = 50;
|
||||||
|
const MAX_ZOOM = 200;
|
||||||
|
|
||||||
export const InspectionModal = ({ isOpen, onClose, document: doc, documentItem }: InspectionModalProps) => {
|
export const InspectionModal = ({ isOpen, onClose, document: doc, documentItem }: InspectionModalProps) => {
|
||||||
|
// 줌 상태
|
||||||
|
const [zoom, setZoom] = useState(100);
|
||||||
|
|
||||||
|
// 드래그 상태
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [startPos, setStartPos] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
// refs
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 모달 열릴 때 상태 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setZoom(100);
|
||||||
|
setPosition({ x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// 줌 인
|
||||||
|
const handleZoomIn = useCallback(() => {
|
||||||
|
setZoom(prev => {
|
||||||
|
const nextIndex = ZOOM_LEVELS.findIndex(z => z > prev);
|
||||||
|
return nextIndex !== -1 ? ZOOM_LEVELS[nextIndex] : MAX_ZOOM;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 줌 아웃
|
||||||
|
const handleZoomOut = useCallback(() => {
|
||||||
|
setZoom(prev => {
|
||||||
|
const prevIndex = ZOOM_LEVELS.slice().reverse().findIndex(z => z < prev);
|
||||||
|
const index = prevIndex !== -1 ? ZOOM_LEVELS.length - 1 - prevIndex : 0;
|
||||||
|
return ZOOM_LEVELS[index] || MIN_ZOOM;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 줌 리셋
|
||||||
|
const handleZoomReset = useCallback(() => {
|
||||||
|
setZoom(100);
|
||||||
|
setPosition({ x: 0, y: 0 });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 마우스 드래그 시작
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (zoom > 100) {
|
||||||
|
setIsDragging(true);
|
||||||
|
setStartPos({ x: e.clientX - position.x, y: e.clientY - position.y });
|
||||||
|
}
|
||||||
|
}, [zoom, position]);
|
||||||
|
|
||||||
|
// 마우스 이동
|
||||||
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
setPosition({
|
||||||
|
x: e.clientX - startPos.x,
|
||||||
|
y: e.clientY - startPos.y,
|
||||||
|
});
|
||||||
|
}, [isDragging, startPos]);
|
||||||
|
|
||||||
|
// 마우스 드래그 종료
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 터치 드래그 시작
|
||||||
|
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
|
if (zoom > 100 && e.touches.length === 1) {
|
||||||
|
setIsDragging(true);
|
||||||
|
setStartPos({
|
||||||
|
x: e.touches[0].clientX - position.x,
|
||||||
|
y: e.touches[0].clientY - position.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [zoom, position]);
|
||||||
|
|
||||||
|
// 터치 이동
|
||||||
|
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||||
|
if (!isDragging || e.touches.length !== 1) return;
|
||||||
|
setPosition({
|
||||||
|
x: e.touches[0].clientX - startPos.x,
|
||||||
|
y: e.touches[0].clientY - startPos.y,
|
||||||
|
});
|
||||||
|
}, [isDragging, startPos]);
|
||||||
|
|
||||||
|
// 터치 종료
|
||||||
|
const handleTouchEnd = useCallback(() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!doc) return null;
|
if (!doc) return null;
|
||||||
|
|
||||||
const docInfo = DOCUMENT_INFO[doc.type] || { label: doc.title, hasTemplate: false, color: 'text-gray-600' };
|
const docInfo = DOCUMENT_INFO[doc.type] || { label: doc.title, hasTemplate: false, color: 'text-gray-600' };
|
||||||
@@ -407,33 +502,84 @@ export const InspectionModal = ({ isOpen, onClose, document: doc, documentItem }
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center justify-between px-4 py-2 bg-white border-b border-gray-100 shrink-0">
|
<div className="flex items-center justify-between px-4 py-2 bg-white border-b border-gray-100 shrink-0 flex-wrap gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs">
|
<Button
|
||||||
<ZoomOut size={14} /> 축소
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1 text-xs px-2 sm:px-3"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
disabled={zoom <= MIN_ZOOM}
|
||||||
|
>
|
||||||
|
<ZoomOut size={14} />
|
||||||
|
<span className="hidden sm:inline">축소</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs">
|
<Button
|
||||||
<ZoomIn size={14} /> 확대
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1 text-xs px-2 sm:px-3"
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
disabled={zoom >= MAX_ZOOM}
|
||||||
|
>
|
||||||
|
<ZoomIn size={14} />
|
||||||
|
<span className="hidden sm:inline">확대</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs">
|
<Button
|
||||||
<RotateCw size={14} /> 회전
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1 text-xs px-2 sm:px-3"
|
||||||
|
onClick={handleZoomReset}
|
||||||
|
>
|
||||||
|
<Maximize2 size={14} />
|
||||||
|
<span className="hidden sm:inline">맞춤</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<span className="text-xs font-mono text-gray-600 bg-gray-100 px-2 py-1 rounded min-w-[48px] text-center">
|
||||||
|
{zoom}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
<span className="text-xs font-mono text-gray-600">100%</span>
|
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs px-2 sm:px-3" onClick={handlePrint}>
|
||||||
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs" onClick={handlePrint}>
|
<Printer size={14} />
|
||||||
<Printer size={14} /> 인쇄
|
<span className="hidden sm:inline">인쇄</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" className="h-8 bg-green-600 hover:bg-green-700 text-white gap-1 text-xs">
|
<Button size="sm" className="h-8 bg-green-600 hover:bg-green-700 text-white gap-1 text-xs px-2 sm:px-3">
|
||||||
<Download size={14} /> 다운로드
|
<Download size={14} />
|
||||||
|
<span className="hidden sm:inline">다운로드</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Area - 남은 공간 모두 사용 */}
|
{/* Content Area - 줌/드래그 가능한 영역 */}
|
||||||
<div className="flex-1 p-4 overflow-auto bg-gray-100">
|
<div
|
||||||
{renderDocumentContent()}
|
ref={containerRef}
|
||||||
|
className="flex-1 overflow-auto bg-gray-100 relative"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
style={{ cursor: zoom > 100 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="p-4 origin-top-left transition-transform duration-150 ease-out"
|
||||||
|
style={{
|
||||||
|
transform: `scale(${zoom / 100}) translate(${position.x / (zoom / 100)}px, ${position.y / (zoom / 100)}px)`,
|
||||||
|
minWidth: '800px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderDocumentContent()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일 줌 힌트 */}
|
||||||
|
{zoom === 100 && (
|
||||||
|
<div className="sm:hidden absolute bottom-20 left-1/2 -translate-x-1/2 bg-black/70 text-white text-xs px-3 py-1.5 rounded-full">
|
||||||
|
확대 후 드래그로 이동
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export async function POST(request: NextRequest) {
|
|||||||
'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility)
|
'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility)
|
||||||
'Path=/',
|
'Path=/',
|
||||||
`Max-Age=${data.expires_in || 7200}`,
|
`Max-Age=${data.expires_in || 7200}`,
|
||||||
|
// `Max-Age=10` // 여기서만 10초 하면 최초 1회만 10 초 후에 액세스 끊어짐 구동테스트 완벽하게 하기 위해서는 refresh 쪽도 10초로 수정해야 함
|
||||||
].join('; ');
|
].join('; ');
|
||||||
|
|
||||||
const refreshTokenCookie = [
|
const refreshTokenCookie = [
|
||||||
@@ -166,7 +167,7 @@ export async function POST(request: NextRequest) {
|
|||||||
...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production (Safari fix)
|
...(isProduction ? ['Secure'] : []), // ✅ HTTPS only in production (Safari fix)
|
||||||
'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility)
|
'SameSite=Lax', // ✅ CSRF protection (Lax for better compatibility)
|
||||||
'Path=/',
|
'Path=/',
|
||||||
'Max-Age=604800', // 7 days (longer for refresh token)
|
'Max-Age=604800', // TODO: 테스트용 10초, 원래 604800 (7 days)
|
||||||
].join('; ');
|
].join('; ');
|
||||||
|
|
||||||
console.log('✅ Login successful - Access & Refresh tokens stored in HttpOnly cookies');
|
console.log('✅ Login successful - Access & Refresh tokens stored in HttpOnly cookies');
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ export function LoginPage() {
|
|||||||
const t = useTranslations('auth');
|
const t = useTranslations('auth');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const tValidation = useTranslations('validation');
|
const tValidation = useTranslations('validation');
|
||||||
const [userId, setUserId] = useState("");
|
const [userId, setUserId] = useState("TestUser5");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("password123!");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -130,6 +130,9 @@ export function LoginPage() {
|
|||||||
console.log('💾 localStorage에 저장할 데이터:', userData);
|
console.log('💾 localStorage에 저장할 데이터:', userData);
|
||||||
localStorage.setItem('user', JSON.stringify(userData));
|
localStorage.setItem('user', JSON.stringify(userData));
|
||||||
|
|
||||||
|
// 메뉴 폴링 재시작 플래그 설정 (세션 만료 후 재로그인 시)
|
||||||
|
sessionStorage.setItem('auth_just_logged_in', 'true');
|
||||||
|
|
||||||
// 대시보드로 이동
|
// 대시보드로 이동
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -34,15 +34,96 @@ const formatAmount = (amount: number): string => {
|
|||||||
return new Intl.NumberFormat('ko-KR').format(amount) + '원';
|
return new Intl.NumberFormat('ko-KR').format(amount) + '원';
|
||||||
};
|
};
|
||||||
|
|
||||||
// 기본 데이터
|
// 기본 데이터 (목데이터)
|
||||||
const defaultData: ComprehensiveAnalysisData = {
|
const defaultData: ComprehensiveAnalysisData = {
|
||||||
todayIssue: { filterOptions: ['전체필터'], items: [] },
|
todayIssue: {
|
||||||
monthlyExpense: { cards: [], checkPoints: [] },
|
filterOptions: ['전체필터', '결재', '매출', '매입', '자금'],
|
||||||
cardManagement: { cards: [], checkPoints: [] },
|
items: [
|
||||||
entertainment: { cards: [], checkPoints: [] },
|
{ id: '1', category: '결재', description: '지출결의서 #2024-001 승인 대기 중', requiresApproval: true, time: '09:30' },
|
||||||
welfare: { cards: [], checkPoints: [] },
|
{ id: '2', category: '매출', description: '(주)대한전자 외상매출금 90일 초과', requiresApproval: false, time: '10:15' },
|
||||||
receivable: { cards: [], checkPoints: [], hasDetailButton: true, detailButtonLabel: '거래처별 미수금 현황', detailButtonPath: '/accounting/receivables-status' },
|
{ id: '3', category: '자금', description: '법인카드 한도 초과 경고', requiresApproval: false, time: '11:00' },
|
||||||
debtCollection: { cards: [], checkPoints: [] },
|
],
|
||||||
|
},
|
||||||
|
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 인터페이스
|
// Props 인터페이스
|
||||||
@@ -199,25 +280,27 @@ export default function ComprehensiveAnalysis({ initialData }: ComprehensiveAnal
|
|||||||
const [rejectTargetId, setRejectTargetId] = useState<string | null>(null);
|
const [rejectTargetId, setRejectTargetId] = useState<string | null>(null);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
|
||||||
// 데이터 로드
|
// 데이터 로드 (API 연동 시 활성화)
|
||||||
const loadData = useCallback(async () => {
|
// const loadData = useCallback(async () => {
|
||||||
setIsLoading(true);
|
// setIsLoading(true);
|
||||||
try {
|
// try {
|
||||||
const result = await getComprehensiveAnalysis();
|
// const result = await getComprehensiveAnalysis();
|
||||||
if (result.success && result.data) {
|
// if (result.success && result.data) {
|
||||||
setData(result.data);
|
// setData(result.data);
|
||||||
}
|
// }
|
||||||
} catch (error) {
|
// } catch (error) {
|
||||||
console.error('Failed to load comprehensive analysis:', error);
|
// console.error('Failed to load comprehensive analysis:', error);
|
||||||
} finally {
|
// } finally {
|
||||||
setIsLoading(false);
|
// setIsLoading(false);
|
||||||
}
|
// }
|
||||||
}, []);
|
// }, []);
|
||||||
|
|
||||||
// 초기 로드
|
// 초기 로드 - 현재 목데이터 사용 중 (API 연동 시 활성화)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
// API 연동 시 아래 주석 해제
|
||||||
}, [loadData]);
|
// loadData();
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleReceivableDetail = () => {
|
const handleReceivableDetail = () => {
|
||||||
router.push('/ko/accounting/receivables-status');
|
router.push('/ko/accounting/receivables-status');
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* 메뉴 폴링 훅
|
* 메뉴 폴링 훅
|
||||||
*
|
*
|
||||||
* 일정 간격으로 메뉴 변경사항을 확인하고 자동 갱신합니다.
|
* 일정 간격으로 메뉴 변경사항을 확인하고 자동 갱신합니다.
|
||||||
|
* 401 응답이 3회 연속 발생하면 세션 만료로 판단하고 폴링을 중지합니다.
|
||||||
|
* 토큰 갱신 시 자동으로 폴링을 재시작합니다.
|
||||||
*
|
*
|
||||||
* @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md
|
* @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md
|
||||||
*/
|
*/
|
||||||
@@ -9,6 +11,26 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
import { refreshMenus } from '@/lib/utils/menuRefresh';
|
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초
|
// 기본 폴링 간격: 30초
|
||||||
const DEFAULT_POLLING_INTERVAL = 30 * 1000;
|
const DEFAULT_POLLING_INTERVAL = 30 * 1000;
|
||||||
|
|
||||||
@@ -18,6 +40,9 @@ const MIN_POLLING_INTERVAL = 10 * 1000;
|
|||||||
// 최대 폴링 간격: 5분
|
// 최대 폴링 간격: 5분
|
||||||
const MAX_POLLING_INTERVAL = 5 * 60 * 1000;
|
const MAX_POLLING_INTERVAL = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// 세션 만료 판단 기준: 연속 401 횟수
|
||||||
|
const MAX_SESSION_EXPIRED_COUNT = 3;
|
||||||
|
|
||||||
interface UseMenuPollingOptions {
|
interface UseMenuPollingOptions {
|
||||||
/** 폴링 활성화 여부 (기본: true) */
|
/** 폴링 활성화 여부 (기본: true) */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -27,6 +52,8 @@ interface UseMenuPollingOptions {
|
|||||||
onMenuUpdated?: () => void;
|
onMenuUpdated?: () => void;
|
||||||
/** 에러 발생 시 콜백 */
|
/** 에러 발생 시 콜백 */
|
||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
|
/** 세션 만료로 폴링 중지 시 콜백 */
|
||||||
|
onSessionExpired?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseMenuPollingReturn {
|
interface UseMenuPollingReturn {
|
||||||
@@ -36,8 +63,12 @@ interface UseMenuPollingReturn {
|
|||||||
pause: () => void;
|
pause: () => void;
|
||||||
/** 폴링 재개 */
|
/** 폴링 재개 */
|
||||||
resume: () => void;
|
resume: () => void;
|
||||||
|
/** 인증 성공 후 폴링 재시작 (401 카운트 리셋) */
|
||||||
|
restartAfterAuth: () => void;
|
||||||
/** 현재 폴링 상태 */
|
/** 현재 폴링 상태 */
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
|
/** 세션 만료로 중지된 상태 */
|
||||||
|
isSessionExpired: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,6 +95,7 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
|
|||||||
interval = DEFAULT_POLLING_INTERVAL,
|
interval = DEFAULT_POLLING_INTERVAL,
|
||||||
onMenuUpdated,
|
onMenuUpdated,
|
||||||
onError,
|
onError,
|
||||||
|
onSessionExpired,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
// 폴링 간격 유효성 검사
|
// 폴링 간격 유효성 검사
|
||||||
@@ -71,21 +103,53 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
|
|||||||
|
|
||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const isPausedRef = useRef(false);
|
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 () => {
|
const executeRefresh = useCallback(async () => {
|
||||||
if (isPausedRef.current) return;
|
if (isPausedRef.current || isSessionExpiredRef.current) return;
|
||||||
|
|
||||||
const result = await refreshMenus();
|
const result = await refreshMenus();
|
||||||
|
|
||||||
if (result.success && result.updated) {
|
// 성공 시 401 카운트 리셋
|
||||||
onMenuUpdated?.();
|
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);
|
onError?.(result.error);
|
||||||
}
|
}
|
||||||
}, [onMenuUpdated, onError]);
|
}, [onMenuUpdated, onError, onSessionExpired, stopPolling]);
|
||||||
|
|
||||||
// 수동 갱신 함수
|
// 수동 갱신 함수
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
@@ -95,27 +159,41 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
|
|||||||
// 폴링 일시 중지
|
// 폴링 일시 중지
|
||||||
const pause = useCallback(() => {
|
const pause = useCallback(() => {
|
||||||
isPausedRef.current = true;
|
isPausedRef.current = true;
|
||||||
if (intervalRef.current) {
|
stopPolling();
|
||||||
clearInterval(intervalRef.current);
|
}, [stopPolling]);
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 폴링 재개
|
// 폴링 재개
|
||||||
const resume = useCallback(() => {
|
const resume = useCallback(() => {
|
||||||
|
// 세션 만료 상태면 재개 불가 (restartAfterAuth 사용)
|
||||||
|
if (isSessionExpiredRef.current) {
|
||||||
|
console.log('[Menu] 세션 만료 상태 - resume 불가, restartAfterAuth 사용 필요');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
isPausedRef.current = false;
|
isPausedRef.current = false;
|
||||||
if (enabled && !intervalRef.current) {
|
if (enabled && !intervalRef.current) {
|
||||||
intervalRef.current = setInterval(executeRefresh, safeInterval);
|
intervalRef.current = setInterval(executeRefresh, safeInterval);
|
||||||
}
|
}
|
||||||
}, [enabled, safeInterval, executeRefresh]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled || isSessionExpiredRef.current) {
|
||||||
if (intervalRef.current) {
|
stopPolling();
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,24 +202,21 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
|
|||||||
intervalRef.current = setInterval(executeRefresh, safeInterval);
|
intervalRef.current = setInterval(executeRefresh, safeInterval);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) {
|
stopPolling();
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [enabled, safeInterval, executeRefresh]);
|
}, [enabled, safeInterval, executeRefresh, stopPolling]);
|
||||||
|
|
||||||
// 탭 가시성 변경 시 처리
|
// 탭 가시성 변경 시 처리
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
|
// 세션 만료 상태면 무시
|
||||||
|
if (isSessionExpiredRef.current) return;
|
||||||
|
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
// 탭이 숨겨지면 폴링 중지 (리소스 절약)
|
// 탭이 숨겨지면 폴링 중지 (리소스 절약)
|
||||||
if (intervalRef.current) {
|
stopPolling();
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 탭이 다시 보이면 즉시 갱신 후 폴링 재개
|
// 탭이 다시 보이면 즉시 갱신 후 폴링 재개
|
||||||
if (!isPausedRef.current) {
|
if (!isPausedRef.current) {
|
||||||
@@ -156,13 +231,57 @@ export function useMenuPolling(options: UseMenuPollingOptions = {}): UseMenuPoll
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
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]);
|
}, [enabled, safeInterval, executeRefresh]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
refresh,
|
refresh,
|
||||||
pause,
|
pause,
|
||||||
resume,
|
resume,
|
||||||
|
restartAfterAuth,
|
||||||
isPaused: isPausedRef.current,
|
isPaused: isPausedRef.current,
|
||||||
|
isSessionExpired: isSessionExpiredRef.current,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Home,
|
Home,
|
||||||
X,
|
X,
|
||||||
|
BarChart3,
|
||||||
|
Award,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -63,14 +65,27 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
|||||||
|
|
||||||
// 메뉴 폴링 (30초마다 메뉴 변경 확인)
|
// 메뉴 폴링 (30초마다 메뉴 변경 확인)
|
||||||
// 백엔드 GET /api/v1/menus API 준비되면 자동 동작
|
// 백엔드 GET /api/v1/menus API 준비되면 자동 동작
|
||||||
useMenuPolling({
|
const { restartAfterAuth } = useMenuPolling({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
interval: 30000, // 30초
|
interval: 30000, // 30초
|
||||||
onMenuUpdated: () => {
|
onMenuUpdated: () => {
|
||||||
console.log('[Menu] 메뉴가 업데이트되었습니다');
|
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(() => {
|
useEffect(() => {
|
||||||
const checkScreenSize = () => {
|
const checkScreenSize = () => {
|
||||||
@@ -283,8 +298,30 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측 영역: 검색, 테마, 유저, 메뉴 */}
|
{/* 우측 영역: 종합분석, 품질인정심사, 검색, 테마, 유저, 메뉴 */}
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
|
{/* 종합분석 바로가기 */}
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push('/reports/comprehensive-analysis')}
|
||||||
|
className="min-w-[44px] min-h-[44px] p-0 rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center"
|
||||||
|
title="종합분석"
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 품질인정심사 바로가기 */}
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push('/quality/qms')}
|
||||||
|
className="min-w-[44px] min-h-[44px] p-0 rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white flex items-center justify-center"
|
||||||
|
title="품질인정심사"
|
||||||
|
>
|
||||||
|
<Award className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* 검색 아이콘 */}
|
{/* 검색 아이콘 */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -409,6 +446,28 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
|
{/* 종합분석 바로가기 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push('/reports/comprehensive-analysis')}
|
||||||
|
className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 flex items-center gap-2 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
<span className="hidden xl:inline">종합분석</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 품질인정심사 바로가기 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push('/quality/qms')}
|
||||||
|
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 flex items-center gap-2 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<Award className="h-4 w-4" />
|
||||||
|
<span className="hidden xl:inline">품질인정심사</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* 테마 선택 - React 프로젝트 스타일 */}
|
{/* 테마 선택 - React 프로젝트 스타일 */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|||||||
@@ -31,6 +31,17 @@ async function setNewTokenCookies(tokens: {
|
|||||||
path: '/',
|
path: '/',
|
||||||
maxAge: tokens.expiresIn || 7200,
|
maxAge: tokens.expiresIn || 7200,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔔 토큰 갱신 신호 쿠키 설정 (클라이언트에서 감지용)
|
||||||
|
// HttpOnly: false로 설정하여 클라이언트에서 읽을 수 있게 함
|
||||||
|
cookieStore.set('token_refreshed_at', Date.now().toString(), {
|
||||||
|
httpOnly: false,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60, // 1분 후 자동 삭제
|
||||||
|
});
|
||||||
|
console.log('🔔 [setNewTokenCookies] token_refreshed_at 신호 쿠키 설정');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokens.refreshToken) {
|
if (tokens.refreshToken) {
|
||||||
@@ -164,8 +175,15 @@ export async function serverFetch(
|
|||||||
|
|
||||||
return { response, error: null };
|
return { response, error: null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// redirect()는 NEXT_REDIRECT 에러를 throw하므로 다시 throw
|
// Next.js 15: redirect()는 digest 프로퍼티를 가진 에러를 throw
|
||||||
if (error instanceof Error && error.message === 'NEXT_REDIRECT') {
|
// 이 에러는 다시 throw해서 Next.js가 처리하도록 해야 함
|
||||||
|
if (
|
||||||
|
error &&
|
||||||
|
typeof error === 'object' &&
|
||||||
|
'digest' in error &&
|
||||||
|
typeof (error as { digest: unknown }).digest === 'string' &&
|
||||||
|
(error as { digest: string }).digest.startsWith('NEXT_REDIRECT')
|
||||||
|
) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
console.error(`[serverFetch] Network error: ${url}`, error);
|
console.error(`[serverFetch] Network error: ${url}`, error);
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export function getCurrentMenuHash(): string {
|
|||||||
interface RefreshMenuResult {
|
interface RefreshMenuResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
updated: boolean; // 실제로 메뉴가 변경되었는지
|
updated: boolean; // 실제로 메뉴가 변경되었는지
|
||||||
|
sessionExpired?: boolean; // 세션 만료 여부 (401 응답)
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,9 +77,10 @@ export async function refreshMenus(): Promise<RefreshMenuResult> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// 401 등 인증 오류는 조용히 실패 (로그아웃 상태일 수 있음)
|
// 401 인증 오류 → 세션 만료로 판단
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
return { success: false, updated: false };
|
console.log('[Menu] 401 응답 - 세션 만료');
|
||||||
|
return { success: false, updated: false, sessionExpired: true };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user