diff --git a/src/app/[locale]/(protected)/reports/comprehensive-analysis/page.tsx b/src/app/[locale]/(protected)/reports/comprehensive-analysis/page.tsx
index 6b4339f1..ca97e647 100644
--- a/src/app/[locale]/(protected)/reports/comprehensive-analysis/page.tsx
+++ b/src/app/[locale]/(protected)/reports/comprehensive-analysis/page.tsx
@@ -1,5 +1,7 @@
-import ComprehensiveAnalysis from '@/components/reports/ComprehensiveAnalysis';
+'use client';
+
+import { MainDashboard } from '@/components/business/MainDashboard';
export default function ComprehensiveAnalysisPage() {
- return ;
+ return ;
}
\ No newline at end of file
diff --git a/src/app/[locale]/globals.css b/src/app/[locale]/globals.css
index a2dc41ec..543b45cc 100644
--- a/src/app/[locale]/globals.css
+++ b/src/app/[locale]/globals.css
@@ -3,6 +3,13 @@
:root {
--background: #ffffff;
--foreground: #171717;
+
+ /* 폴더블 기기 대응 - JS에서 동적으로 업데이트됨 */
+ --app-width: 100vw;
+ --app-height: 100vh;
+ /* dvh/dvw fallback (브라우저 지원 시 자동 적용) */
+ --app-height: 100dvh;
+ --app-width: 100dvw;
}
@theme inline {
diff --git a/src/components/business/CEODashboard/CEODashboard.tsx b/src/components/business/CEODashboard/CEODashboard.tsx
index ab4d871c..881aaf8c 100644
--- a/src/components/business/CEODashboard/CEODashboard.tsx
+++ b/src/components/business/CEODashboard/CEODashboard.tsx
@@ -507,54 +507,72 @@ export function CEODashboard() {
me2: {
title: '당월 카드 상세',
summaryCards: [
- { label: '당월 카드', value: 30123000, unit: '원' },
- { label: '전월 대비', value: '+10.5%', isComparison: true, isPositive: true },
+ { label: '당월 카드 사용', value: 6000000, unit: '원' },
+ { label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false },
+ { label: '이용건', value: '10건' },
],
barChart: {
title: '월별 카드 사용 추이',
data: [
- { name: '1월', value: 25000000 },
- { name: '2월', value: 28000000 },
- { name: '3월', value: 22000000 },
- { name: '4월', value: 30000000 },
- { name: '5월', value: 27000000 },
- { name: '6월', value: 29000000 },
- { name: '7월', value: 30000000 },
+ { name: '1월', value: 4500000 },
+ { name: '2월', value: 5200000 },
+ { name: '3월', value: 4800000 },
+ { name: '4월', value: 6100000 },
+ { name: '5월', value: 5500000 },
+ { name: '6월', value: 5800000 },
+ { name: '7월', value: 6000000 },
],
dataKey: 'value',
xAxisKey: 'name',
- color: '#34D399',
+ color: '#60A5FA',
},
pieChart: {
- title: '카드 사용 유형별 비율',
+ title: '사용자별 카드 사용 비율',
data: [
- { name: '접대비', value: 12000000, percentage: 40, color: '#60A5FA' },
- { name: '복리후생비', value: 9000000, percentage: 30, color: '#34D399' },
- { name: '소모품비', value: 9123000, percentage: 30, color: '#FBBF24' },
+ { name: '홍길동', value: 55000000, percentage: 55, color: '#60A5FA' },
+ { name: '김길동', value: 35000000, percentage: 35, color: '#34D399' },
+ { name: '이길동', value: 10000000, percentage: 10, color: '#FBBF24' },
],
},
table: {
title: '일별 카드 사용 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
- { key: 'date', label: '사용일', align: 'center', format: 'date' },
- { key: 'store', label: '가맹점', align: 'left' },
+ { key: 'cardName', label: '카드명', align: 'left' },
+ { key: 'user', label: '사용자', align: 'center' },
+ { key: 'date', label: '사용일시', align: 'center', format: 'date' },
+ { key: 'store', label: '가맹점명', align: 'left' },
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
- { key: 'category', label: '분류', align: 'center' },
+ { key: 'usageType', label: '사용유형', align: 'center', highlightValue: '미설정' },
],
data: [
- { date: '2025-12-12', store: '가맹점명', amount: 5000000, category: '접대비' },
- { date: '2025-12-11', store: '가맹점명', amount: 3000000, category: '복리후생비' },
- { date: '2025-12-10', store: '가맹점명', amount: 4000000, category: '소모품비' },
+ { cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, usageType: '복리후생비' },
+ { cardName: '카드명', user: '홍길동', date: '2025-12-11 14:30', store: '가맹점명', amount: 1000000, usageType: '접대비' },
+ { cardName: '카드명', user: '홍길동', date: '2025-12-10 09:45', store: '가맹점명', amount: 1000000, usageType: '미설정' },
+ { cardName: '카드명', user: '홍길동', date: '2025-12-09 18:20', store: '가맹점명', amount: 1000000, usageType: '미설정' },
+ { cardName: '카드명', user: '홍길동', date: '2025-12-08 11:15', store: '가맹점명', amount: 1000000, usageType: '미설정' },
+ { cardName: '카드명', user: '김길동', date: '2025-12-07 16:40', store: '가맹점명', amount: 5000000, usageType: '교통비' },
+ { cardName: '카드명', user: '이길동', date: '2025-12-06 10:30', store: '가맹점명', amount: 1000000, usageType: '소모품비' },
+ { cardName: '카드명', user: '홍길동', date: '2025-12-05 13:25', store: '스타벅스', amount: 45000, usageType: '복리후생비' },
+ { cardName: '카드명', user: '김길동', date: '2025-12-04 19:50', store: '주유소', amount: 80000, usageType: '교통비' },
+ { cardName: '카드명', user: '이길동', date: '2025-12-03 08:10', store: '편의점', amount: 15000, usageType: '미설정' },
+ { cardName: '카드명', user: '홍길동', date: '2025-12-02 12:00', store: '식당', amount: 250000, usageType: '접대비' },
+ { cardName: '카드명', user: '김길동', date: '2025-12-01 15:35', store: '문구점', amount: 35000, usageType: '소모품비' },
+ { cardName: '카드명', user: '홍길동', date: '2025-11-30 17:20', store: '호텔', amount: 350000, usageType: '미설정' },
+ { cardName: '카드명', user: '이길동', date: '2025-11-29 09:00', store: '택시', amount: 25000, usageType: '교통비' },
+ { cardName: '카드명', user: '김길동', date: '2025-11-28 14:15', store: '커피숍', amount: 32000, usageType: '복리후생비' },
+ { cardName: '카드명', user: '홍길동', date: '2025-11-27 11:45', store: '마트', amount: 180000, usageType: '소모품비' },
+ { cardName: '카드명', user: '이길동', date: '2025-11-26 16:30', store: '서점', amount: 45000, usageType: '미설정' },
+ { cardName: '카드명', user: '김길동', date: '2025-11-25 10:20', store: '식당', amount: 120000, usageType: '접대비' },
],
filters: [
{
- key: 'category',
+ key: 'user',
options: [
{ value: 'all', label: '전체' },
- { value: '접대비', label: '접대비' },
- { value: '복리후생비', label: '복리후생비' },
- { value: '소모품비', label: '소모품비' },
+ { value: '홍길동', label: '홍길동' },
+ { value: '김길동', label: '김길동' },
+ { value: '이길동', label: '이길동' },
],
defaultValue: 'all',
},
@@ -562,65 +580,104 @@ export function CEODashboard() {
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
- { value: 'oldest', label: '오래된순' },
+ { value: 'oldest', label: '등록순' },
+ { value: 'amountDesc', label: '금액 높은순' },
+ { value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
- totalValue: 30123000,
+ totalValue: 11000000,
totalColumnKey: 'amount',
},
},
me3: {
title: '당월 발행어음 상세',
summaryCards: [
- { label: '당월 발행어음', value: 30123000, unit: '원' },
- { label: '전월 대비', value: '+10.5%', isComparison: true, isPositive: true },
+ { label: '당월 발행어음 사용', value: 3123000, unit: '원' },
+ { label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false },
],
barChart: {
title: '월별 발행어음 추이',
data: [
- { name: '1월', value: 20000000 },
- { name: '2월', value: 25000000 },
- { name: '3월', value: 22000000 },
- { name: '4월', value: 28000000 },
- { name: '5월', value: 26000000 },
- { name: '6월', value: 30000000 },
- { name: '7월', value: 30000000 },
+ { name: '1월', value: 2000000 },
+ { name: '2월', value: 2500000 },
+ { name: '3월', value: 2200000 },
+ { name: '4월', value: 2800000 },
+ { name: '5월', value: 2600000 },
+ { name: '6월', value: 3000000 },
+ { name: '7월', value: 3123000 },
],
dataKey: 'value',
xAxisKey: 'name',
- color: '#F472B6',
+ color: '#60A5FA',
+ },
+ horizontalBarChart: {
+ title: '당월 거래처별 발행어음',
+ data: [
+ { name: '거래처1', value: 50000000 },
+ { name: '거래처2', value: 35000000 },
+ { name: '거래처3', value: 20000000 },
+ { name: '거래처4', value: 6000000 },
+ ],
+ color: '#60A5FA',
},
table: {
- title: '발행어음 내역',
+ title: '일별 발행어음 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
- { key: 'date', label: '발행일', align: 'center', format: 'date' },
- { key: 'vendor', label: '수취인', align: 'left' },
- { key: 'amount', label: '금액', align: 'right', format: 'currency' },
+ { key: 'vendor', label: '거래처', align: 'left' },
+ { key: 'issueDate', label: '발행일', align: 'center', format: 'date' },
{ key: 'dueDate', label: '만기일', align: 'center', format: 'date' },
+ { key: 'amount', label: '금액', align: 'right', format: 'currency' },
+ { key: 'status', label: '상태', align: 'center' },
],
data: [
- { date: '2025-12-12', vendor: '회사명', amount: 10000000, dueDate: '2026-03-12' },
- { date: '2025-12-10', vendor: '회사명', amount: 10123000, dueDate: '2026-03-10' },
- { date: '2025-12-08', vendor: '회사명', amount: 10000000, dueDate: '2026-03-08' },
+ { vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' },
+ { vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
+ { vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' },
+ { vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
+ { vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' },
+ { vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
+ { vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 105000000, status: '보관중' },
],
filters: [
+ {
+ key: 'vendor',
+ options: [
+ { value: 'all', label: '전체' },
+ { value: '회사명', label: '회사명' },
+ ],
+ defaultValue: 'all',
+ },
+ {
+ key: 'status',
+ options: [
+ { value: 'all', label: '전체' },
+ { value: '보관중', label: '보관중' },
+ { value: '만기임박', label: '만기임박' },
+ { value: '만기경과', label: '만기경과' },
+ { value: '결제완료', label: '결제완료' },
+ { value: '부도', label: '부도' },
+ ],
+ defaultValue: 'all',
+ },
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
- { value: 'oldest', label: '오래된순' },
+ { value: 'oldest', label: '등록순' },
+ { value: 'amountDesc', label: '금액 높은순' },
+ { value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
- totalValue: 30123000,
+ totalValue: 111000000,
totalColumnKey: 'amount',
},
},
diff --git a/src/components/business/CEODashboard/modals/DetailModal.tsx b/src/components/business/CEODashboard/modals/DetailModal.tsx
index 217e60f3..5dab4390 100644
--- a/src/components/business/CEODashboard/modals/DetailModal.tsx
+++ b/src/components/business/CEODashboard/modals/DetailModal.tsx
@@ -33,6 +33,7 @@ import type {
SummaryCardData,
BarChartConfig,
PieChartConfig,
+ HorizontalBarChartConfig,
TableConfig,
TableFilterConfig,
} from '../types';
@@ -159,6 +160,40 @@ const PieChartSection = ({ config }: { config: PieChartConfig }) => {
);
};
+/**
+ * 가로 막대 차트 컴포넌트
+ */
+const HorizontalBarChartSection = ({ config }: { config: HorizontalBarChartConfig }) => {
+ const maxValue = Math.max(...config.data.map(d => d.value));
+
+ return (
+
+
{config.title}
+
+ {config.data.map((item, index) => (
+
+
+ {item.name}
+
+ {formatCurrency(item.value)}원
+
+
+
+
+ ))}
+
+
+ );
+};
+
/**
* 테이블 컴포넌트
*/
@@ -196,6 +231,14 @@ const TableSection = ({ config }: { config: TableConfig }) => {
if (filters['sortOrder']) {
const sortOrder = filters['sortOrder'];
result.sort((a, b) => {
+ // 금액 정렬
+ if (sortOrder === 'amountDesc') {
+ return (b['amount'] as number) - (a['amount'] as number);
+ }
+ if (sortOrder === 'amountAsc') {
+ return (a['amount'] as number) - (b['amount'] as number);
+ }
+ // 날짜 정렬
const dateA = new Date(a['date'] as string).getTime();
const dateB = new Date(b['date'] as string).getTime();
return sortOrder === 'latest' ? dateB - dateA : dateA - dateB;
@@ -268,9 +311,9 @@ const TableSection = ({ config }: { config: TableConfig }) => {
{/* 테이블 */}
-
+
-
+
{config.columns.map((column) => (
| {
key={rowIndex}
className="border-t border-gray-100 hover:bg-gray-50"
>
- {config.columns.map((column) => (
- |
- {column.key === 'no'
- ? rowIndex + 1
- : formatCellValue(row[column.key], column.format)
- }
- |
- ))}
+ {config.columns.map((column) => {
+ const cellValue = column.key === 'no'
+ ? rowIndex + 1
+ : formatCellValue(row[column.key], column.format);
+ const isHighlighted = column.highlightValue && String(row[column.key]) === column.highlightValue;
+
+ return (
+
+ {cellValue}
+ |
+ );
+ })}
))}
@@ -360,7 +408,12 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
{/* 요약 카드 영역 */}
{config.summaryCards.length > 0 && (
-
+
= 4 && "grid-cols-2 md:grid-cols-4"
+ )}>
{config.summaryCards.map((card, index) => (
))}
@@ -368,10 +421,11 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
)}
{/* 차트 영역 */}
- {(config.barChart || config.pieChart) && (
+ {(config.barChart || config.pieChart || config.horizontalBarChart) && (
{config.barChart &&
}
{config.pieChart &&
}
+ {config.horizontalBarChart &&
}
)}
diff --git a/src/components/business/CEODashboard/types.ts b/src/components/business/CEODashboard/types.ts
index 085a8cfb..a16e28a3 100644
--- a/src/components/business/CEODashboard/types.ts
+++ b/src/components/business/CEODashboard/types.ts
@@ -255,6 +255,19 @@ export interface PieChartConfig {
data: PieChartDataItem[];
}
+// 가로 막대 차트 데이터 타입
+export interface HorizontalBarChartDataItem {
+ name: string;
+ value: number;
+}
+
+// 가로 막대 차트 설정 타입
+export interface HorizontalBarChartConfig {
+ title: string;
+ data: HorizontalBarChartDataItem[];
+ color?: string;
+}
+
// 테이블 컬럼 타입
export interface TableColumnConfig {
key: string;
@@ -262,6 +275,7 @@ export interface TableColumnConfig {
align?: 'left' | 'center' | 'right';
format?: 'number' | 'currency' | 'date' | 'text';
width?: string;
+ highlightValue?: string; // 이 값일 때 주황색으로 강조 표시
}
// 테이블 필터 옵션 타입
@@ -295,6 +309,7 @@ export interface DetailModalConfig {
summaryCards: SummaryCardData[];
barChart?: BarChartConfig;
pieChart?: PieChartConfig;
+ horizontalBarChart?: HorizontalBarChartConfig; // 가로 막대 차트 (도넛 차트 대신 사용)
table?: TableConfig;
}
diff --git a/src/components/business/MainDashboard.tsx b/src/components/business/MainDashboard.tsx
index 909af8a4..d8baf9cb 100644
--- a/src/components/business/MainDashboard.tsx
+++ b/src/components/business/MainDashboard.tsx
@@ -1035,7 +1035,7 @@ export function MainDashboard() {
diff --git a/src/layouts/AuthenticatedLayout.tsx b/src/layouts/AuthenticatedLayout.tsx
index 21010cd5..051a2a2a 100644
--- a/src/layouts/AuthenticatedLayout.tsx
+++ b/src/layouts/AuthenticatedLayout.tsx
@@ -116,14 +116,32 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
}
}, [restartAfterAuth]);
- // 모바일 감지
+ // 모바일 감지 + 폴더블 기기 대응 (Galaxy Fold 등)
useEffect(() => {
- const checkScreenSize = () => {
- setIsMobile(window.innerWidth < 768);
+ const updateViewport = () => {
+ // visualViewport API 우선 사용 (폴더블 기기에서 더 정확)
+ const width = window.visualViewport?.width ?? window.innerWidth;
+ const height = window.visualViewport?.height ?? window.innerHeight;
+
+ setIsMobile(width < 768);
+
+ // CSS 변수로 실제 viewport 크기 설정 (폴더블 기기 대응)
+ document.documentElement.style.setProperty('--app-width', `${width}px`);
+ document.documentElement.style.setProperty('--app-height', `${height}px`);
+ };
+
+ updateViewport();
+
+ // resize 이벤트
+ window.addEventListener('resize', updateViewport);
+
+ // visualViewport resize 이벤트 (폴더블 기기 화면 전환 감지)
+ window.visualViewport?.addEventListener('resize', updateViewport);
+
+ return () => {
+ window.removeEventListener('resize', updateViewport);
+ window.visualViewport?.removeEventListener('resize', updateViewport);
};
- checkScreenSize();
- window.addEventListener('resize', checkScreenSize);
- return () => window.removeEventListener('resize', checkScreenSize);
}, []);
// 서버에서 받은 사용자 정보로 초기화
@@ -293,7 +311,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
// 모바일 레이아웃
if (isMobile) {
return (
-
+
{/* 모바일 헤더 - sam-design 스타일 */}
diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts
index 432e7e11..9920adcf 100644
--- a/src/lib/api/client.ts
+++ b/src/lib/api/client.ts
@@ -199,9 +199,15 @@ export async function withTokenRefresh(
// Retry the original API call
return withTokenRefresh(apiCall, maxRetries - 1);
} else {
- console.error('❌ Token refresh failed - redirecting to login');
- // Refresh failed - redirect to login
+ console.error('❌ Token refresh failed - clearing cookies and redirecting to login');
+ // ⚠️ 무한 루프 방지: 쿠키 삭제 API 호출 후 redirect
+ // 쿠키가 남아있으면 미들웨어가 "인증됨"으로 판단하여 무한 루프 발생
if (typeof window !== 'undefined') {
+ try {
+ await fetch('/api/auth/logout', { method: 'POST' });
+ } catch {
+ // 로그아웃 API 실패해도 redirect 진행
+ }
window.location.href = '/login';
}
}
diff --git a/src/lib/api/fetch-wrapper.ts b/src/lib/api/fetch-wrapper.ts
index 34f1170b..bf01cd4d 100644
--- a/src/lib/api/fetch-wrapper.ts
+++ b/src/lib/api/fetch-wrapper.ts
@@ -13,6 +13,24 @@ import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { createErrorResponse, type ApiErrorResponse } from './errors';
import { refreshAccessToken } from './refresh-token';
+/**
+ * 토큰 쿠키 삭제 (리프레시 실패 또는 인증 만료 시)
+ *
+ * ⚠️ 중요: redirect('/login') 호출 전에 반드시 실행해야 함
+ * 쿠키가 남아있으면 미들웨어가 "인증됨"으로 판단하여 무한 루프 발생
+ */
+async function clearTokenCookies() {
+ const cookieStore = await cookies();
+
+ // 토큰 쿠키 삭제
+ cookieStore.delete('access_token');
+ cookieStore.delete('refresh_token');
+ cookieStore.delete('token_refreshed_at');
+ cookieStore.delete('is_authenticated');
+
+ console.log('🗑️ [serverFetch] 토큰 쿠키 삭제 완료 (무한 루프 방지)');
+}
+
/**
* 새 토큰을 쿠키에 저장
*/
@@ -152,16 +170,19 @@ export async function serverFetch(
// 재시도도 401이면 로그인으로
if (response.status === 401) {
console.warn('🔴 [serverFetch] Retry failed with 401, redirecting to login...');
+ await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
redirect('/login');
}
} else {
// 리프레시 실패 → 로그인 페이지로
console.warn('🔴 [serverFetch] Token refresh failed, redirecting to login...');
+ await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
redirect('/login');
}
} else if (response.status === 401 && !options?.skipAuthCheck) {
// refresh_token이 없는 경우
console.warn(`[serverFetch] 401 Unauthorized (no refresh token): ${url} → 로그인 페이지로 이동`);
+ await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
redirect('/login');
}