From c4412295fa5d2d3e4ee9e0a2ef44910b67d7cc16 Mon Sep 17 00:00:00 2001 From: byeongcheolryu Date: Fri, 9 Jan 2026 11:00:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=ED=8F=B4=EB=8D=94=EB=B8=94=20?= =?UTF-8?q?=EA=B8=B0=EA=B8=B0(Galaxy=20Fold)=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EB=8C=80=EC=9D=91=20=EB=B0=8F=20CEO=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../reports/comprehensive-analysis/page.tsx | 6 +- src/app/[locale]/globals.css | 7 + .../business/CEODashboard/CEODashboard.tsx | 147 ++++++++++++------ .../CEODashboard/modals/DetailModal.tsx | 90 ++++++++--- src/components/business/CEODashboard/types.ts | 15 ++ src/components/business/MainDashboard.tsx | 2 +- src/layouts/AuthenticatedLayout.tsx | 32 +++- src/lib/api/client.ts | 10 +- src/lib/api/fetch-wrapper.ts | 21 +++ 9 files changed, 255 insertions(+), 75 deletions(-) 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) => ( - ))} + {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 ( + + ); + })} ))} @@ -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'); }
{ 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) - } - + {cellValue} +