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:
byeongcheolryu
2025-12-31 18:40:50 +09:00
parent d4e64c290c
commit 4b1a3abf05
9 changed files with 560 additions and 128 deletions

View File

@@ -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>
); );
}; };

View File

@@ -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>
); );

View File

@@ -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');

View File

@@ -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) {

View File

@@ -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');

View File

@@ -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,
}; };
} }

View File

@@ -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>

View File

@@ -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);

View File

@@ -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,