feat(WEB): 폴더블 기기(Galaxy Fold) 레이아웃 대응 및 CEO 대시보드 개선
- AuthenticatedLayout: visualViewport API 추가로 폴더블 기기 화면 전환 감지 - globals.css: CSS 변수(--app-width, --app-height) 및 dvw/dvh fallback 추가 - 모바일 레이아웃: h-screen → var(--app-height)로 변경 - CEO 대시보드 및 API 클라이언트 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import ComprehensiveAnalysis from '@/components/reports/ComprehensiveAnalysis';
|
||||
'use client';
|
||||
|
||||
import { MainDashboard } from '@/components/business/MainDashboard';
|
||||
|
||||
export default function ComprehensiveAnalysisPage() {
|
||||
return <ComprehensiveAnalysis />;
|
||||
return <MainDashboard />;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
|
||||
<div className="space-y-3">
|
||||
{config.data.map((item, index) => (
|
||||
<div key={index} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">{item.name}</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{formatCurrency(item.value)}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-4 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${(item.value / maxValue) * 100}%`,
|
||||
backgroundColor: config.color || '#60A5FA',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 테이블 컴포넌트
|
||||
*/
|
||||
@@ -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 }) => {
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="border rounded-lg max-h-[400px] overflow-y-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-gray-100">
|
||||
{config.columns.map((column) => (
|
||||
<th
|
||||
@@ -292,20 +335,25 @@ const TableSection = ({ config }: { config: TableConfig }) => {
|
||||
key={rowIndex}
|
||||
className="border-t border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
{config.columns.map((column) => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={cn(
|
||||
"px-4 py-3 text-sm",
|
||||
getAlignClass(column.align)
|
||||
)}
|
||||
>
|
||||
{column.key === 'no'
|
||||
? rowIndex + 1
|
||||
: formatCellValue(row[column.key], column.format)
|
||||
}
|
||||
</td>
|
||||
))}
|
||||
{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 (
|
||||
<td
|
||||
key={column.key}
|
||||
className={cn(
|
||||
"px-4 py-3 text-sm",
|
||||
getAlignClass(column.align),
|
||||
isHighlighted && "text-orange-500 font-medium"
|
||||
)}
|
||||
>
|
||||
{cellValue}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -360,7 +408,12 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 요약 카드 영역 */}
|
||||
{config.summaryCards.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className={cn(
|
||||
"grid gap-4",
|
||||
config.summaryCards.length === 2 && "grid-cols-2",
|
||||
config.summaryCards.length === 3 && "grid-cols-3",
|
||||
config.summaryCards.length >= 4 && "grid-cols-2 md:grid-cols-4"
|
||||
)}>
|
||||
{config.summaryCards.map((card, index) => (
|
||||
<SummaryCard key={index} data={card} />
|
||||
))}
|
||||
@@ -368,10 +421,11 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
|
||||
)}
|
||||
|
||||
{/* 차트 영역 */}
|
||||
{(config.barChart || config.pieChart) && (
|
||||
{(config.barChart || config.pieChart || config.horizontalBarChart) && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{config.barChart && <BarChartSection config={config.barChart} />}
|
||||
{config.pieChart && <PieChartSection config={config.pieChart} />}
|
||||
{config.horizontalBarChart && <HorizontalBarChartSection config={config.horizontalBarChart} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1035,7 +1035,7 @@ export function MainDashboard() {
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-6 h-6 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
|
||||
<Calendar className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<CalendarIcon className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<CardTitle className="text-base">일별 매출</CardTitle>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
<div className="flex flex-col overflow-hidden" style={{ height: 'var(--app-height)' }}>
|
||||
{/* 모바일 헤더 - sam-design 스타일 */}
|
||||
<header className="clean-glass sticky top-0 z-40 px-4 py-4 m-3 rounded-2xl clean-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -199,9 +199,15 @@ export async function withTokenRefresh<T>(
|
||||
// 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user