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() {
|
export default function ComprehensiveAnalysisPage() {
|
||||||
return <ComprehensiveAnalysis />;
|
return <MainDashboard />;
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,13 @@
|
|||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
|
|
||||||
|
/* 폴더블 기기 대응 - JS에서 동적으로 업데이트됨 */
|
||||||
|
--app-width: 100vw;
|
||||||
|
--app-height: 100vh;
|
||||||
|
/* dvh/dvw fallback (브라우저 지원 시 자동 적용) */
|
||||||
|
--app-height: 100dvh;
|
||||||
|
--app-width: 100dvw;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|||||||
@@ -507,54 +507,72 @@ export function CEODashboard() {
|
|||||||
me2: {
|
me2: {
|
||||||
title: '당월 카드 상세',
|
title: '당월 카드 상세',
|
||||||
summaryCards: [
|
summaryCards: [
|
||||||
{ label: '당월 카드', value: 30123000, unit: '원' },
|
{ label: '당월 카드 사용', value: 6000000, unit: '원' },
|
||||||
{ label: '전월 대비', value: '+10.5%', isComparison: true, isPositive: true },
|
{ label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||||
|
{ label: '이용건', value: '10건' },
|
||||||
],
|
],
|
||||||
barChart: {
|
barChart: {
|
||||||
title: '월별 카드 사용 추이',
|
title: '월별 카드 사용 추이',
|
||||||
data: [
|
data: [
|
||||||
{ name: '1월', value: 25000000 },
|
{ name: '1월', value: 4500000 },
|
||||||
{ name: '2월', value: 28000000 },
|
{ name: '2월', value: 5200000 },
|
||||||
{ name: '3월', value: 22000000 },
|
{ name: '3월', value: 4800000 },
|
||||||
{ name: '4월', value: 30000000 },
|
{ name: '4월', value: 6100000 },
|
||||||
{ name: '5월', value: 27000000 },
|
{ name: '5월', value: 5500000 },
|
||||||
{ name: '6월', value: 29000000 },
|
{ name: '6월', value: 5800000 },
|
||||||
{ name: '7월', value: 30000000 },
|
{ name: '7월', value: 6000000 },
|
||||||
],
|
],
|
||||||
dataKey: 'value',
|
dataKey: 'value',
|
||||||
xAxisKey: 'name',
|
xAxisKey: 'name',
|
||||||
color: '#34D399',
|
color: '#60A5FA',
|
||||||
},
|
},
|
||||||
pieChart: {
|
pieChart: {
|
||||||
title: '카드 사용 유형별 비율',
|
title: '사용자별 카드 사용 비율',
|
||||||
data: [
|
data: [
|
||||||
{ name: '접대비', value: 12000000, percentage: 40, color: '#60A5FA' },
|
{ name: '홍길동', value: 55000000, percentage: 55, color: '#60A5FA' },
|
||||||
{ name: '복리후생비', value: 9000000, percentage: 30, color: '#34D399' },
|
{ name: '김길동', value: 35000000, percentage: 35, color: '#34D399' },
|
||||||
{ name: '소모품비', value: 9123000, percentage: 30, color: '#FBBF24' },
|
{ name: '이길동', value: 10000000, percentage: 10, color: '#FBBF24' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
table: {
|
table: {
|
||||||
title: '일별 카드 사용 내역',
|
title: '일별 카드 사용 내역',
|
||||||
columns: [
|
columns: [
|
||||||
{ key: 'no', label: 'No.', align: 'center' },
|
{ key: 'no', label: 'No.', align: 'center' },
|
||||||
{ key: 'date', label: '사용일', align: 'center', format: 'date' },
|
{ key: 'cardName', label: '카드명', align: 'left' },
|
||||||
{ key: 'store', 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: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
||||||
{ key: 'category', label: '분류', align: 'center' },
|
{ key: 'usageType', label: '사용유형', align: 'center', highlightValue: '미설정' },
|
||||||
],
|
],
|
||||||
data: [
|
data: [
|
||||||
{ date: '2025-12-12', store: '가맹점명', amount: 5000000, category: '접대비' },
|
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, usageType: '복리후생비' },
|
||||||
{ date: '2025-12-11', store: '가맹점명', amount: 3000000, category: '복리후생비' },
|
{ cardName: '카드명', user: '홍길동', date: '2025-12-11 14:30', store: '가맹점명', amount: 1000000, usageType: '접대비' },
|
||||||
{ date: '2025-12-10', store: '가맹점명', amount: 4000000, category: '소모품비' },
|
{ 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: [
|
filters: [
|
||||||
{
|
{
|
||||||
key: 'category',
|
key: 'user',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'all', label: '전체' },
|
{ value: 'all', label: '전체' },
|
||||||
{ value: '접대비', label: '접대비' },
|
{ value: '홍길동', label: '홍길동' },
|
||||||
{ value: '복리후생비', label: '복리후생비' },
|
{ value: '김길동', label: '김길동' },
|
||||||
{ value: '소모품비', label: '소모품비' },
|
{ value: '이길동', label: '이길동' },
|
||||||
],
|
],
|
||||||
defaultValue: 'all',
|
defaultValue: 'all',
|
||||||
},
|
},
|
||||||
@@ -562,65 +580,104 @@ export function CEODashboard() {
|
|||||||
key: 'sortOrder',
|
key: 'sortOrder',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'latest', label: '최신순' },
|
{ value: 'latest', label: '최신순' },
|
||||||
{ value: 'oldest', label: '오래된순' },
|
{ value: 'oldest', label: '등록순' },
|
||||||
|
{ value: 'amountDesc', label: '금액 높은순' },
|
||||||
|
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||||
],
|
],
|
||||||
defaultValue: 'latest',
|
defaultValue: 'latest',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
showTotal: true,
|
showTotal: true,
|
||||||
totalLabel: '합계',
|
totalLabel: '합계',
|
||||||
totalValue: 30123000,
|
totalValue: 11000000,
|
||||||
totalColumnKey: 'amount',
|
totalColumnKey: 'amount',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
me3: {
|
me3: {
|
||||||
title: '당월 발행어음 상세',
|
title: '당월 발행어음 상세',
|
||||||
summaryCards: [
|
summaryCards: [
|
||||||
{ label: '당월 발행어음', value: 30123000, unit: '원' },
|
{ label: '당월 발행어음 사용', value: 3123000, unit: '원' },
|
||||||
{ label: '전월 대비', value: '+10.5%', isComparison: true, isPositive: true },
|
{ label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||||
],
|
],
|
||||||
barChart: {
|
barChart: {
|
||||||
title: '월별 발행어음 추이',
|
title: '월별 발행어음 추이',
|
||||||
data: [
|
data: [
|
||||||
{ name: '1월', value: 20000000 },
|
{ name: '1월', value: 2000000 },
|
||||||
{ name: '2월', value: 25000000 },
|
{ name: '2월', value: 2500000 },
|
||||||
{ name: '3월', value: 22000000 },
|
{ name: '3월', value: 2200000 },
|
||||||
{ name: '4월', value: 28000000 },
|
{ name: '4월', value: 2800000 },
|
||||||
{ name: '5월', value: 26000000 },
|
{ name: '5월', value: 2600000 },
|
||||||
{ name: '6월', value: 30000000 },
|
{ name: '6월', value: 3000000 },
|
||||||
{ name: '7월', value: 30000000 },
|
{ name: '7월', value: 3123000 },
|
||||||
],
|
],
|
||||||
dataKey: 'value',
|
dataKey: 'value',
|
||||||
xAxisKey: 'name',
|
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: {
|
table: {
|
||||||
title: '발행어음 내역',
|
title: '일별 발행어음 내역',
|
||||||
columns: [
|
columns: [
|
||||||
{ key: 'no', label: 'No.', align: 'center' },
|
{ key: 'no', label: 'No.', align: 'center' },
|
||||||
{ key: 'date', label: '발행일', align: 'center', format: 'date' },
|
{ key: 'vendor', label: '거래처', align: 'left' },
|
||||||
{ key: 'vendor', label: '수취인', align: 'left' },
|
{ key: 'issueDate', label: '발행일', align: 'center', format: 'date' },
|
||||||
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
|
|
||||||
{ key: 'dueDate', 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: [
|
data: [
|
||||||
{ date: '2025-12-12', vendor: '회사명', amount: 10000000, dueDate: '2026-03-12' },
|
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' },
|
||||||
{ date: '2025-12-10', vendor: '회사명', amount: 10123000, dueDate: '2026-03-10' },
|
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
|
||||||
{ 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: 105000000, status: '보관중' },
|
||||||
],
|
],
|
||||||
filters: [
|
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',
|
key: 'sortOrder',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'latest', label: '최신순' },
|
{ value: 'latest', label: '최신순' },
|
||||||
{ value: 'oldest', label: '오래된순' },
|
{ value: 'oldest', label: '등록순' },
|
||||||
|
{ value: 'amountDesc', label: '금액 높은순' },
|
||||||
|
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||||
],
|
],
|
||||||
defaultValue: 'latest',
|
defaultValue: 'latest',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
showTotal: true,
|
showTotal: true,
|
||||||
totalLabel: '합계',
|
totalLabel: '합계',
|
||||||
totalValue: 30123000,
|
totalValue: 111000000,
|
||||||
totalColumnKey: 'amount',
|
totalColumnKey: 'amount',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import type {
|
|||||||
SummaryCardData,
|
SummaryCardData,
|
||||||
BarChartConfig,
|
BarChartConfig,
|
||||||
PieChartConfig,
|
PieChartConfig,
|
||||||
|
HorizontalBarChartConfig,
|
||||||
TableConfig,
|
TableConfig,
|
||||||
TableFilterConfig,
|
TableFilterConfig,
|
||||||
} from '../types';
|
} 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']) {
|
if (filters['sortOrder']) {
|
||||||
const sortOrder = filters['sortOrder'];
|
const sortOrder = filters['sortOrder'];
|
||||||
result.sort((a, b) => {
|
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 dateA = new Date(a['date'] as string).getTime();
|
||||||
const dateB = new Date(b['date'] as string).getTime();
|
const dateB = new Date(b['date'] as string).getTime();
|
||||||
return sortOrder === 'latest' ? dateB - dateA : dateA - dateB;
|
return sortOrder === 'latest' ? dateB - dateA : dateA - dateB;
|
||||||
@@ -268,9 +311,9 @@ const TableSection = ({ config }: { config: TableConfig }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg max-h-[400px] overflow-y-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead className="sticky top-0 z-10">
|
||||||
<tr className="bg-gray-100">
|
<tr className="bg-gray-100">
|
||||||
{config.columns.map((column) => (
|
{config.columns.map((column) => (
|
||||||
<th
|
<th
|
||||||
@@ -292,20 +335,25 @@ const TableSection = ({ config }: { config: TableConfig }) => {
|
|||||||
key={rowIndex}
|
key={rowIndex}
|
||||||
className="border-t border-gray-100 hover:bg-gray-50"
|
className="border-t border-gray-100 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
{config.columns.map((column) => (
|
{config.columns.map((column) => {
|
||||||
<td
|
const cellValue = column.key === 'no'
|
||||||
key={column.key}
|
? rowIndex + 1
|
||||||
className={cn(
|
: formatCellValue(row[column.key], column.format);
|
||||||
"px-4 py-3 text-sm",
|
const isHighlighted = column.highlightValue && String(row[column.key]) === column.highlightValue;
|
||||||
getAlignClass(column.align)
|
|
||||||
)}
|
return (
|
||||||
>
|
<td
|
||||||
{column.key === 'no'
|
key={column.key}
|
||||||
? rowIndex + 1
|
className={cn(
|
||||||
: formatCellValue(row[column.key], column.format)
|
"px-4 py-3 text-sm",
|
||||||
}
|
getAlignClass(column.align),
|
||||||
</td>
|
isHighlighted && "text-orange-500 font-medium"
|
||||||
))}
|
)}
|
||||||
|
>
|
||||||
|
{cellValue}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -360,7 +408,12 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
|
|||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* 요약 카드 영역 */}
|
{/* 요약 카드 영역 */}
|
||||||
{config.summaryCards.length > 0 && (
|
{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) => (
|
{config.summaryCards.map((card, index) => (
|
||||||
<SummaryCard key={index} data={card} />
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{config.barChart && <BarChartSection config={config.barChart} />}
|
{config.barChart && <BarChartSection config={config.barChart} />}
|
||||||
{config.pieChart && <PieChartSection config={config.pieChart} />}
|
{config.pieChart && <PieChartSection config={config.pieChart} />}
|
||||||
|
{config.horizontalBarChart && <HorizontalBarChartSection config={config.horizontalBarChart} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,19 @@ export interface PieChartConfig {
|
|||||||
data: PieChartDataItem[];
|
data: PieChartDataItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 가로 막대 차트 데이터 타입
|
||||||
|
export interface HorizontalBarChartDataItem {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 가로 막대 차트 설정 타입
|
||||||
|
export interface HorizontalBarChartConfig {
|
||||||
|
title: string;
|
||||||
|
data: HorizontalBarChartDataItem[];
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 테이블 컬럼 타입
|
// 테이블 컬럼 타입
|
||||||
export interface TableColumnConfig {
|
export interface TableColumnConfig {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -262,6 +275,7 @@ export interface TableColumnConfig {
|
|||||||
align?: 'left' | 'center' | 'right';
|
align?: 'left' | 'center' | 'right';
|
||||||
format?: 'number' | 'currency' | 'date' | 'text';
|
format?: 'number' | 'currency' | 'date' | 'text';
|
||||||
width?: string;
|
width?: string;
|
||||||
|
highlightValue?: string; // 이 값일 때 주황색으로 강조 표시
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블 필터 옵션 타입
|
// 테이블 필터 옵션 타입
|
||||||
@@ -295,6 +309,7 @@ export interface DetailModalConfig {
|
|||||||
summaryCards: SummaryCardData[];
|
summaryCards: SummaryCardData[];
|
||||||
barChart?: BarChartConfig;
|
barChart?: BarChartConfig;
|
||||||
pieChart?: PieChartConfig;
|
pieChart?: PieChartConfig;
|
||||||
|
horizontalBarChart?: HorizontalBarChartConfig; // 가로 막대 차트 (도넛 차트 대신 사용)
|
||||||
table?: TableConfig;
|
table?: TableConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1035,7 +1035,7 @@ export function MainDashboard() {
|
|||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center space-x-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">
|
<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>
|
</div>
|
||||||
<CardTitle className="text-base">일별 매출</CardTitle>
|
<CardTitle className="text-base">일별 매출</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,14 +116,32 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
|||||||
}
|
}
|
||||||
}, [restartAfterAuth]);
|
}, [restartAfterAuth]);
|
||||||
|
|
||||||
// 모바일 감지
|
// 모바일 감지 + 폴더블 기기 대응 (Galaxy Fold 등)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkScreenSize = () => {
|
const updateViewport = () => {
|
||||||
setIsMobile(window.innerWidth < 768);
|
// 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) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col overflow-hidden">
|
<div className="flex flex-col overflow-hidden" style={{ height: 'var(--app-height)' }}>
|
||||||
{/* 모바일 헤더 - sam-design 스타일 */}
|
{/* 모바일 헤더 - sam-design 스타일 */}
|
||||||
<header className="clean-glass sticky top-0 z-40 px-4 py-4 m-3 rounded-2xl clean-shadow">
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -199,9 +199,15 @@ export async function withTokenRefresh<T>(
|
|||||||
// Retry the original API call
|
// Retry the original API call
|
||||||
return withTokenRefresh(apiCall, maxRetries - 1);
|
return withTokenRefresh(apiCall, maxRetries - 1);
|
||||||
} else {
|
} else {
|
||||||
console.error('❌ Token refresh failed - redirecting to login');
|
console.error('❌ Token refresh failed - clearing cookies and redirecting to login');
|
||||||
// Refresh failed - redirect to login
|
// ⚠️ 무한 루프 방지: 쿠키 삭제 API 호출 후 redirect
|
||||||
|
// 쿠키가 남아있으면 미들웨어가 "인증됨"으로 판단하여 무한 루프 발생
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
} catch {
|
||||||
|
// 로그아웃 API 실패해도 redirect 진행
|
||||||
|
}
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,24 @@ import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
|||||||
import { createErrorResponse, type ApiErrorResponse } from './errors';
|
import { createErrorResponse, type ApiErrorResponse } from './errors';
|
||||||
import { refreshAccessToken } from './refresh-token';
|
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이면 로그인으로
|
// 재시도도 401이면 로그인으로
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
console.warn('🔴 [serverFetch] Retry failed with 401, redirecting to login...');
|
console.warn('🔴 [serverFetch] Retry failed with 401, redirecting to login...');
|
||||||
|
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
|
||||||
redirect('/login');
|
redirect('/login');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 리프레시 실패 → 로그인 페이지로
|
// 리프레시 실패 → 로그인 페이지로
|
||||||
console.warn('🔴 [serverFetch] Token refresh failed, redirecting to login...');
|
console.warn('🔴 [serverFetch] Token refresh failed, redirecting to login...');
|
||||||
|
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
|
||||||
redirect('/login');
|
redirect('/login');
|
||||||
}
|
}
|
||||||
} else if (response.status === 401 && !options?.skipAuthCheck) {
|
} else if (response.status === 401 && !options?.skipAuthCheck) {
|
||||||
// refresh_token이 없는 경우
|
// refresh_token이 없는 경우
|
||||||
console.warn(`[serverFetch] 401 Unauthorized (no refresh token): ${url} → 로그인 페이지로 이동`);
|
console.warn(`[serverFetch] 401 Unauthorized (no refresh token): ${url} → 로그인 페이지로 이동`);
|
||||||
|
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
|
||||||
redirect('/login');
|
redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user