diff --git a/claudedocs/guides/[GUIDE] foldable-device-layout-fix.md b/claudedocs/guides/[GUIDE] foldable-device-layout-fix.md new file mode 100644 index 00000000..2925ef40 --- /dev/null +++ b/claudedocs/guides/[GUIDE] foldable-device-layout-fix.md @@ -0,0 +1,154 @@ +# 폴더블 기기(Galaxy Fold) 레이아웃 대응 가이드 + +> 작성일: 2026-01-09 +> 적용 파일: `AuthenticatedLayout.tsx`, `globals.css` + +--- + +## 문제 현상 + +Galaxy Fold 같은 폴더블 기기에서 **넓은 화면 ↔ 좁은 화면** 전환 시: +- 사이트 너비가 정확히 계산되지 않음 +- 전체 레이아웃이 틀어짐 +- 화면 전환 후에도 이전 크기가 유지됨 + +--- + +## 원인 분석 + +### 1. `window.innerWidth`의 한계 +```javascript +// 기존 코드 +window.addEventListener('resize', () => { + setIsMobile(window.innerWidth < 768); +}); +``` +- 폴더블 기기에서 화면 전환 시 `window.innerWidth` 값이 **즉시 업데이트되지 않음** +- `resize` 이벤트가 불완전하게 발생 + +### 2. CSS `100vh` / `100vw` 문제 +```css +/* 기존 */ +height: 100vh; /* h-screen */ +``` +- Tailwind의 `h-screen`은 `100vh`로 계산됨 +- 폴더블 기기에서 viewport units가 **늦게 재계산**되어 레이아웃 깨짐 + +--- + +## 해결 방법 + +### 1. visualViewport API 사용 + +`window.visualViewport`는 실제 보이는 viewport 크기를 더 정확하게 반환합니다. + +```typescript +// src/layouts/AuthenticatedLayout.tsx + +useEffect(() => { + 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); + }; +}, []); +``` + +### 2. CSS 변수 + dvw/dvh fallback + +```css +/* src/app/[locale]/globals.css */ + +:root { + /* 폴더블 기기 대응 - JS에서 동적으로 업데이트됨 */ + --app-width: 100vw; + --app-height: 100vh; + + /* dvh/dvw fallback (브라우저 지원 시 자동 적용) */ + --app-height: 100dvh; + --app-width: 100dvw; +} +``` + +| 단위 | 설명 | +|------|------| +| `vh/vw` | 초기 viewport 기준 (고정) | +| `dvh/dvw` | Dynamic viewport - 동적으로 변함 | +| `svh/svw` | Small viewport - 최소 크기 기준 | +| `lvh/lvw` | Large viewport - 최대 크기 기준 | + +### 3. 레이아웃에서 CSS 변수 사용 + +```tsx +// 기존: h-screen (100vh 고정) +
+ +// 변경: CSS 변수 사용 (동적 업데이트) +
+``` + +--- + +## 작동 원리 + +``` +┌─────────────────────────────────────────────────────┐ +│ 폴드 전환 발생 │ +│ ↓ │ +│ visualViewport resize 이벤트 발생 │ +│ ↓ │ +│ updateViewport() 실행 │ +│ ↓ │ +│ CSS 변수 업데이트 (--app-width, --app-height) │ +│ ↓ │ +│ 레이아웃 즉시 재계산 │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## 브라우저 지원 + +| API/속성 | Chrome | Safari | Firefox | Samsung Internet | +|----------|--------|--------|---------|------------------| +| `visualViewport` | 61+ | 13+ | 91+ | 8.0+ | +| `dvh/dvw` | 108+ | 15.4+ | 101+ | 21+ | + +- `visualViewport` 미지원 시 → `window.innerWidth/Height` fallback +- `dvh/dvw` 미지원 시 → JS에서 계산한 값으로 대체 + +--- + +## 관련 파일 + +| 파일 | 역할 | +|------|------| +| `src/layouts/AuthenticatedLayout.tsx` | viewport 감지 및 CSS 변수 업데이트 | +| `src/app/[locale]/globals.css` | CSS 변수 선언 및 fallback | + +--- + +## 참고 자료 + +- [MDN - Visual Viewport API](https://developer.mozilla.org/en-US/docs/Web/API/Visual_Viewport_API) +- [MDN - Viewport Units](https://developer.mozilla.org/en-US/docs/Web/CSS/length#viewport-percentage_lengths) +- [web.dev - New Viewport Units](https://web.dev/viewport-units/) \ No newline at end of file diff --git a/src/components/business/CEODashboard/CEODashboard.tsx b/src/components/business/CEODashboard/CEODashboard.tsx index 881aaf8c..550f217a 100644 --- a/src/components/business/CEODashboard/CEODashboard.tsx +++ b/src/components/business/CEODashboard/CEODashboard.tsx @@ -113,8 +113,8 @@ const mockData: CEODashboardData = { cards: [ { id: 'cm1', label: '카드', amount: 30123000, previousLabel: '미정리 5건 (가지급금 예정)' }, { id: 'cm2', label: '가지급금', amount: 350000000, previousLabel: '전월 대비 +10.5%' }, - { id: 'cm3', label: '법인세 예상 가산', amount: 3123000, previousLabel: '전월 대비 +10.5%' }, - { id: 'cm4', label: '종합세 예상 가산', amount: 3123000, previousLabel: '추가 사안 +10.5%' }, + { id: 'cm3', label: '법인세 예상 가중', amount: 3123000, previousLabel: '추가 세금 +10.5%' }, + { id: 'cm4', label: '대표자 종합세 예상 가중', amount: 3123000, previousLabel: '추가 세금 +10.5%' }, ], checkPoints: [ { @@ -682,51 +682,59 @@ export function CEODashboard() { }, }, me4: { - title: '총 예상 지출 상세', + title: '당월 지출 예상 상세', summaryCards: [ - { label: '총 예상 지출', value: 350000000, unit: '원' }, - { label: '전월 대비', value: '+10.5%', isComparison: true, isPositive: true }, + { label: '당월 지출 예상', value: 6000000, unit: '원' }, + { label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false }, + { label: '총 계좌 잔액', value: 10000000, unit: '원' }, ], - barChart: { - title: '월별 총 지출 추이', - data: [ - { name: '1월', value: 280000000 }, - { name: '2월', value: 300000000 }, - { name: '3월', value: 290000000 }, - { name: '4월', value: 320000000 }, - { name: '5월', value: 310000000 }, - { name: '6월', value: 340000000 }, - { name: '7월', value: 350000000 }, - ], - dataKey: 'value', - xAxisKey: 'name', - color: '#A78BFA', - }, - pieChart: { - title: '지출 항목별 비율', - data: [ - { name: '매입', value: 305000000, percentage: 87, color: '#60A5FA' }, - { name: '카드', value: 30000000, percentage: 9, color: '#34D399' }, - { name: '발행어음', value: 15000000, percentage: 4, color: '#FBBF24' }, - ], - }, table: { - title: '지출 항목별 내역', + title: '당월 지출 승인 내역서', columns: [ - { key: 'no', label: 'No.', align: 'center' }, - { key: 'category', label: '항목', align: 'left' }, - { key: 'amount', label: '금액', align: 'right', format: 'currency' }, - { key: 'ratio', label: '비율', align: 'center' }, + { key: 'paymentDate', label: '예상 지급일', align: 'center' }, + { key: 'item', label: '항목', align: 'left' }, + { key: 'amount', label: '지출금액', align: 'right', format: 'currency', highlightColor: 'red' }, + { key: 'vendor', label: '거래처', align: 'center' }, + { key: 'account', label: '계좌', align: 'center' }, ], data: [ - { category: '매입', amount: 305000000, ratio: '87%' }, - { category: '카드', amount: 30000000, ratio: '9%' }, - { category: '발행어음', amount: 15000000, ratio: '4%' }, + { paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, + { paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, + { paymentDate: '2025-12-12', item: '(발행 어음) 123123123', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, + { paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, + { paymentDate: '2025-12-12', item: '거래처명 12월분', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, + { paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, + { paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, + { paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, + { paymentDate: '2025-12-12', item: '적요 내용', amount: 1000000, vendor: '회사명', account: '국민은행 1234' }, + ], + filters: [ + { + key: 'vendor', + options: [ + { value: 'all', label: '전체' }, + { value: '회사명', label: '회사명' }, + ], + defaultValue: 'all', + }, + { + key: 'sortOrder', + options: [ + { value: 'latest', label: '최신순' }, + { value: 'oldest', label: '등록순' }, + ], + defaultValue: 'latest', + }, ], showTotal: true, - totalLabel: '합계', - totalValue: 350000000, + totalLabel: '2025/12 계', + totalValue: 6000000, totalColumnKey: 'amount', + footerSummary: [ + { label: '지출 합계', value: 6000000 }, + { label: '계좌 잔액', value: 10000000 }, + { label: '최종 차액', value: 4000000 }, + ], }, }, }; @@ -743,6 +751,244 @@ export function CEODashboard() { console.log('당월 예상 지출 클릭'); }, []); + // 카드/가지급금 관리 카드 클릭 (개별 카드 클릭 시 상세 모달) + const handleCardManagementCardClick = useCallback((cardId: string) => { + const cardConfigs: Record = { + cm1: { + title: '카드 사용 상세', + summaryCards: [ + { label: '당월 카드 사용', value: 30123000, unit: '원' }, + { label: '전월 대비', value: '+10.5%', isComparison: true, isPositive: true }, + { label: '미정리 건수', value: '5건' }, + ], + barChart: { + title: '월별 카드 사용 추이', + data: [ + { name: '7월', value: 28000000 }, + { name: '8월', value: 32000000 }, + { name: '9월', value: 27000000 }, + { name: '10월', value: 35000000 }, + { name: '11월', value: 29000000 }, + { name: '12월', value: 30123000 }, + ], + dataKey: 'value', + xAxisKey: 'name', + color: '#60A5FA', + }, + pieChart: { + title: '사용자별 카드 사용 비율', + data: [ + { name: '대표이사', value: 15000000, percentage: 50, color: '#60A5FA' }, + { name: '경영지원팀', value: 9000000, percentage: 30, color: '#34D399' }, + { name: '영업팀', value: 6123000, percentage: 20, color: '#FBBF24' }, + ], + }, + table: { + title: '카드 사용 내역', + columns: [ + { key: 'no', label: 'No.', align: 'center' }, + { 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: 'usageType', label: '사용유형', align: 'center', highlightValue: '미설정' }, + ], + data: [ + { cardName: '법인카드1', user: '대표이사', date: '2026-01-05 18:30', store: '스타벅스 강남점', amount: 45000, usageType: '복리후생비' }, + { cardName: '법인카드1', user: '대표이사', date: '2026-01-04 12:15', store: '한식당', amount: 350000, usageType: '접대비' }, + { cardName: '법인카드2', user: '경영지원팀', date: '2026-01-03 14:20', store: '오피스디포', amount: 125000, usageType: '소모품비' }, + { cardName: '법인카드1', user: '대표이사', date: '2026-01-02 19:45', store: '골프장', amount: 850000, usageType: '미설정' }, + { cardName: '법인카드3', user: '영업팀', date: '2026-01-02 11:30', store: 'GS칼텍스', amount: 80000, usageType: '교통비' }, + { cardName: '법인카드2', user: '경영지원팀', date: '2026-01-01 16:00', store: '이마트', amount: 230000, usageType: '미설정' }, + { cardName: '법인카드1', user: '대표이사', date: '2025-12-30 20:30', store: '백화점', amount: 1500000, usageType: '미설정' }, + { cardName: '법인카드3', user: '영업팀', date: '2025-12-29 09:15', store: '커피빈', amount: 32000, usageType: '복리후생비' }, + { cardName: '법인카드2', user: '경영지원팀', date: '2025-12-28 13:45', store: '문구점', amount: 55000, usageType: '소모품비' }, + { cardName: '법인카드1', user: '대표이사', date: '2025-12-27 21:00', store: '호텔', amount: 450000, usageType: '미설정' }, + ], + filters: [ + { + key: 'user', + options: [ + { value: 'all', label: '전체' }, + { value: '대표이사', label: '대표이사' }, + { value: '경영지원팀', label: '경영지원팀' }, + { value: '영업팀', label: '영업팀' }, + ], + defaultValue: 'all', + }, + { + key: 'usageType', + 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: 'amountDesc', label: '금액 높은순' }, + { value: 'amountAsc', label: '금액 낮은순' }, + ], + defaultValue: 'latest', + }, + ], + showTotal: true, + totalLabel: '합계', + totalValue: 30123000, + totalColumnKey: 'amount', + }, + }, + cm2: { + title: '가지급금 상세', + summaryCards: [ + { label: '가지급금', value: '4.5억원' }, + { label: '인정이자 4.6%', value: 6000000, unit: '원' }, + { label: '미정정', value: '10건' }, + ], + table: { + title: '가지급금 관련 내역', + columns: [ + { key: 'no', label: 'No.', align: 'center' }, + { key: 'date', label: '발생일시', align: 'center' }, + { key: 'target', label: '대상', align: 'center' }, + { key: 'category', label: '구분', align: 'center' }, + { key: 'amount', label: '금액', align: 'right', format: 'currency' }, + { key: 'status', label: '상태', align: 'center', highlightValue: '미정정' }, + { key: 'content', label: '내용', align: 'left' }, + ], + data: [ + { date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '미정정', content: '미정정' }, + { date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '접비(미정리)', content: '접대비 불인정' }, + { date: '2025-12-12 12:12', target: '홍길동', category: '계좌명', amount: 1000000, status: '미정정', content: '접대비 불인정' }, + { date: '2025-12-12 12:12', target: '홍길동', category: '계좌명', amount: 1000000, status: '미정정', content: '미정정' }, + { date: '2025-12-12 12:12', target: '홍길동', category: '-', amount: 1000000, status: '미정정', content: '미정정' }, + { date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '접대비', content: '접대비 불인정' }, + { date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '-', content: '복리후생비, 주말/심야 카드 사용' }, + ], + filters: [ + { + key: 'target', + options: [ + { value: 'all', label: '전체' }, + { value: '홍길동', label: '홍길동' }, + ], + defaultValue: 'all', + }, + { + key: 'category', + options: [ + { value: 'all', label: '전체' }, + { value: '카드명', label: '카드명' }, + { value: '계좌명', label: '계좌명' }, + ], + defaultValue: 'all', + }, + { + key: 'sortOrder', + options: [ + { value: 'latest', label: '최신순' }, + { value: 'oldest', label: '등록순' }, + { value: 'amountDesc', label: '금액 높은순' }, + { value: 'amountAsc', label: '금액 낮은순' }, + ], + defaultValue: 'latest', + }, + ], + showTotal: true, + totalLabel: '합계', + totalValue: 111000000, + totalColumnKey: 'amount', + }, + }, + cm3: { + title: '법인세 예상 가중 상세', + summaryCards: [ + { label: '법인세 예상 증가', value: 3123000, unit: '원' }, + { label: '인정 이자', value: 6000000, unit: '원' }, + { label: '가지급금', value: '4.5억원' }, + { label: '인정이자', value: 6000000, unit: '원' }, + ], + comparisonSection: { + leftBox: { + title: '없을때 법인세', + items: [ + { label: '과세표준', value: '3억원' }, + { label: '법인세', value: 50970000, unit: '원' }, + ], + borderColor: 'orange', + }, + rightBox: { + title: '있을때 법인세', + items: [ + { label: '과세표준', value: '3.06억원' }, + { label: '법인세', value: 54093000, unit: '원' }, + ], + borderColor: 'blue', + }, + vsLabel: '법인세 예상 증가', + vsValue: 3123000, + vsSubLabel: '법인 세율 -12.5%', + }, + referenceTable: { + title: '법인세 과세표준 (2024년 기준)', + columns: [ + { key: 'bracket', label: '과세표준', align: 'left' }, + { key: 'rate', label: '세율', align: 'center' }, + { key: 'formula', label: '계산식', align: 'left' }, + ], + data: [ + { bracket: '2억원 이하', rate: '9%', formula: '과세표준 × 9%' }, + { bracket: '2억원 초과 ~ 200억원 이하', rate: '19%', formula: '1,800만원 + (2억원 초과분 × 19%)' }, + { bracket: '200억원 초과 ~ 3,000억원 이하', rate: '21%', formula: '37.62억원 + (200억원 초과분 × 21%)' }, + { bracket: '3,000억원 초과', rate: '24%', formula: '625.62억원 + (3,000억원 초과분 × 24%)' }, + ], + }, + }, + cm4: { + title: '대표자 종합소득세 예상 가중 상세', + summaryCards: [ + { label: '합계', value: 3123000, unit: '원' }, + { label: '전월 대비', value: '+12.5%', isComparison: true, isPositive: false }, + { label: '가지급금', value: '4.5억원' }, + { label: '인정 이자', value: 6000000, unit: '원' }, + ], + table: { + title: '종합소득세 과세표준 (2024년 기준)', + columns: [ + { key: 'bracket', label: '과세표준', align: 'left' }, + { key: 'rate', label: '세율', align: 'center' }, + { key: 'deduction', label: '누진공제', align: 'right' }, + { key: 'formula', label: '계산식', align: 'left' }, + ], + data: [ + { bracket: '1,400만원 이하', rate: '6%', deduction: '-', formula: '과세표준 × 6%' }, + { bracket: '1,400만원 초과 ~ 5,000만원 이하', rate: '15%', deduction: '126만원', formula: '과세표준 × 15% - 126만원' }, + { bracket: '5,000만원 초과 ~ 8,800만원 이하', rate: '24%', deduction: '576만원', formula: '과세표준 × 24% - 576만원' }, + { bracket: '8,800만원 초과 ~ 1.5억원 이하', rate: '35%', deduction: '1,544만원', formula: '과세표준 × 35% - 1,544만원' }, + { bracket: '1.5억원 초과 ~ 3억원 이하', rate: '38%', deduction: '1,994만원', formula: '과세표준 × 38% - 1,994만원' }, + { bracket: '3억원 초과 ~ 5억원 이하', rate: '40%', deduction: '2,594만원', formula: '과세표준 × 40% - 2,594만원' }, + { bracket: '5억원 초과 ~ 10억원 이하', rate: '42%', deduction: '3,594만원', formula: '과세표준 × 42% - 3,594만원' }, + { bracket: '10억원 초과', rate: '45%', deduction: '6,594만원', formula: '과세표준 × 45% - 6,594만원' }, + ], + }, + }, + }; + + const config = cardConfigs[cardId]; + if (config) { + setDetailModalConfig(config); + setIsDetailModalOpen(true); + } + }, []); + // 접대비 클릭 const handleEntertainmentClick = useCallback(() => { // TODO: 접대비 상세 팝업 열기 @@ -863,7 +1109,10 @@ export function CEODashboard() { {/* 카드/가지급금 관리 */} {dashboardSettings.cardManagement && ( - + )} {/* 접대비 현황 */} diff --git a/src/components/business/CEODashboard/modals/DetailModal.tsx b/src/components/business/CEODashboard/modals/DetailModal.tsx index 5dab4390..beac2968 100644 --- a/src/components/business/CEODashboard/modals/DetailModal.tsx +++ b/src/components/business/CEODashboard/modals/DetailModal.tsx @@ -36,6 +36,8 @@ import type { HorizontalBarChartConfig, TableConfig, TableFilterConfig, + ComparisonSectionConfig, + ReferenceTableConfig, } from '../types'; interface DetailModalProps { @@ -194,6 +196,155 @@ const HorizontalBarChartSection = ({ config }: { config: HorizontalBarChartConfi ); }; +/** + * VS 비교 섹션 컴포넌트 + */ +const ComparisonSection = ({ config }: { config: ComparisonSectionConfig }) => { + const formatValue = (value: string | number, unit?: string): string => { + if (typeof value === 'number') { + return formatCurrency(value) + (unit || '원'); + } + return value; + }; + + const borderColorClass = { + orange: 'border-orange-400', + blue: 'border-blue-400', + }; + + const titleBgClass = { + orange: 'bg-orange-50', + blue: 'bg-blue-50', + }; + + return ( +
+ {/* 왼쪽 박스 */} +
+
+ {config.leftBox.title} +
+
+ {config.leftBox.items.map((item, index) => ( +
+

{item.label}

+

+ {formatValue(item.value, item.unit)} +

+
+ ))} +
+
+ + {/* VS 영역 */} +
+ VS +
+

{config.vsLabel}

+

+ {typeof config.vsValue === 'number' + ? formatCurrency(config.vsValue) + '원' + : config.vsValue} +

+ {config.vsSubLabel && ( +

{config.vsSubLabel}

+ )} +
+
+ + {/* 오른쪽 박스 */} +
+
+ {config.rightBox.title} +
+
+ {config.rightBox.items.map((item, index) => ( +
+

{item.label}

+

+ {formatValue(item.value, item.unit)} +

+
+ ))} +
+
+
+ ); +}; + +/** + * 참조 테이블 컴포넌트 (필터 없는 정보성 테이블) + */ +const ReferenceTableSection = ({ config }: { config: ReferenceTableConfig }) => { + const getAlignClass = (align?: string): string => { + switch (align) { + case 'center': + return 'text-center'; + case 'right': + return 'text-right'; + default: + return 'text-left'; + } + }; + + return ( +
+

{config.title}

+
+ + + + {config.columns.map((column) => ( + + ))} + + + + {config.data.map((row, rowIndex) => ( + + {config.columns.map((column) => ( + + ))} + + ))} + +
+ {column.label} +
+ {String(row[column.key] ?? '-')} +
+
+
+ ); +}; + /** * 테이블 컴포넌트 */ @@ -341,13 +492,22 @@ const TableSection = ({ config }: { config: TableConfig }) => { : formatCellValue(row[column.key], column.format); const isHighlighted = column.highlightValue && String(row[column.key]) === column.highlightValue; + // highlightColor 클래스 매핑 + const highlightColorClass = column.highlightColor ? { + red: 'text-red-500', + orange: 'text-orange-500', + blue: 'text-blue-500', + green: 'text-green-500', + }[column.highlightColor] : ''; + return ( {cellValue} @@ -380,6 +540,24 @@ const TableSection = ({ config }: { config: TableConfig }) => {
+ + {/* 하단 다중 합계 섹션 */} + {config.footerSummary && config.footerSummary.length > 0 && ( +
+
+ {config.footerSummary.map((item, index) => ( +
+ {item.label} + + {typeof item.value === 'number' + ? formatCurrency(item.value) + : item.value} + +
+ ))} +
+
+ )}
); }; @@ -429,6 +607,16 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) { )} + {/* VS 비교 섹션 영역 */} + {config.comparisonSection && ( + + )} + + {/* 참조 테이블 영역 */} + {config.referenceTable && ( + + )} + {/* 테이블 영역 */} {config.table && } diff --git a/src/components/business/CEODashboard/sections/CardManagementSection.tsx b/src/components/business/CEODashboard/sections/CardManagementSection.tsx index dd2cfb98..74e685bc 100644 --- a/src/components/business/CEODashboard/sections/CardManagementSection.tsx +++ b/src/components/business/CEODashboard/sections/CardManagementSection.tsx @@ -7,13 +7,18 @@ import type { CardManagementData } from '../types'; interface CardManagementSectionProps { data: CardManagementData; + onCardClick?: (cardId: string) => void; } -export function CardManagementSection({ data }: CardManagementSectionProps) { +export function CardManagementSection({ data, onCardClick }: CardManagementSectionProps) { const router = useRouter(); - const handleClick = () => { - router.push('/ko/accounting/card-management'); + const handleClick = (cardId: string) => { + if (onCardClick) { + onCardClick(cardId); + } else { + router.push('/ko/accounting/card-management'); + } }; return ( @@ -32,7 +37,7 @@ export function CardManagementSection({ data }: CardManagementSectionProps) { handleClick(card.id)} /> ))} diff --git a/src/components/business/CEODashboard/types.ts b/src/components/business/CEODashboard/types.ts index a16e28a3..0a79b5cf 100644 --- a/src/components/business/CEODashboard/types.ts +++ b/src/components/business/CEODashboard/types.ts @@ -276,6 +276,7 @@ export interface TableColumnConfig { format?: 'number' | 'currency' | 'date' | 'text'; width?: string; highlightValue?: string; // 이 값일 때 주황색으로 강조 표시 + highlightColor?: 'red' | 'orange' | 'blue' | 'green'; // 컬럼 전체에 적용할 색상 } // 테이블 필터 옵션 타입 @@ -291,6 +292,13 @@ export interface TableFilterConfig { defaultValue: string; } +// 테이블 하단 요약 항목 타입 +export interface FooterSummaryItem { + label: string; + value: string | number; + format?: 'number' | 'currency'; +} + // 테이블 설정 타입 export interface TableConfig { title: string; @@ -301,6 +309,37 @@ export interface TableConfig { totalLabel?: string; totalValue?: string | number; totalColumnKey?: string; // 합계가 들어갈 컬럼 키 + footerSummary?: FooterSummaryItem[]; // 하단 다중 합계 섹션 +} + +// 비교 박스 아이템 타입 +export interface ComparisonBoxItem { + label: string; + value: string | number; + unit?: string; +} + +// 비교 박스 설정 타입 +export interface ComparisonBoxConfig { + title: string; + items: ComparisonBoxItem[]; + borderColor: 'orange' | 'blue'; +} + +// VS 비교 섹션 설정 타입 +export interface ComparisonSectionConfig { + leftBox: ComparisonBoxConfig; + rightBox: ComparisonBoxConfig; + vsLabel: string; + vsValue: string | number; + vsSubLabel?: string; +} + +// 참조 테이블 설정 타입 (필터 없는 정보성 테이블) +export interface ReferenceTableConfig { + title: string; + columns: TableColumnConfig[]; + data: Record[]; } // 상세 모달 전체 설정 타입 @@ -310,6 +349,8 @@ export interface DetailModalConfig { barChart?: BarChartConfig; pieChart?: PieChartConfig; horizontalBarChart?: HorizontalBarChartConfig; // 가로 막대 차트 (도넛 차트 대신 사용) + comparisonSection?: ComparisonSectionConfig; // VS 비교 섹션 + referenceTable?: ReferenceTableConfig; // 참조 테이블 (필터 없음) table?: TableConfig; } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index e871c601..8e7b4719 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,4 +1,4 @@ -import { ChevronRight } from 'lucide-react'; +import { ChevronRight, Circle } from 'lucide-react'; import type { MenuItem } from '@/store/menuStore'; import { useEffect, useRef } from 'react'; @@ -13,6 +13,230 @@ interface SidebarProps { onCloseMobileSidebar?: () => void; } +// 재귀적 메뉴 아이템 컴포넌트 Props +interface MenuItemComponentProps { + item: MenuItem; + depth: number; // 0: 1depth, 1: 2depth, 2: 3depth + activeMenu: string; + expandedMenus: string[]; + sidebarCollapsed: boolean; + isMobile: boolean; + activeMenuRef: React.RefObject; + onMenuClick: (menuId: string, path: string) => void; + onToggleSubmenu: (menuId: string) => void; + onCloseMobileSidebar?: () => void; +} + +// 재귀적 메뉴 아이템 컴포넌트 (3depth 이상 지원) +function MenuItemComponent({ + item, + depth, + activeMenu, + expandedMenus, + sidebarCollapsed, + isMobile, + activeMenuRef, + onMenuClick, + onToggleSubmenu, + onCloseMobileSidebar, +}: MenuItemComponentProps) { + const IconComponent = item.icon; + const hasChildren = item.children && item.children.length > 0; + const isExpanded = expandedMenus.includes(item.id); + const isActive = activeMenu === item.id; + + const handleClick = () => { + if (hasChildren) { + onToggleSubmenu(item.id); + } else { + onMenuClick(item.id, item.path); + if (isMobile && onCloseMobileSidebar) { + onCloseMobileSidebar(); + } + } + }; + + // depth별 스타일 설정 + // 1depth (depth=0): 아이콘 + 굵은 텍스트 + 배경색 + // 2depth (depth=1): 작은 아이콘 + 일반 텍스트 + 왼쪽 보더 + // 3depth (depth=2+): 점(dot) 아이콘 + 작은 텍스트 + 더 깊은 들여쓰기 + const is1Depth = depth === 0; + const is2Depth = depth === 1; + const is3DepthOrMore = depth >= 2; + + // 1depth 메뉴 렌더링 + if (is1Depth) { + return ( +
+ {/* 메인 메뉴 버튼 */} + + + {/* 자식 메뉴 (재귀) */} + {hasChildren && isExpanded && !sidebarCollapsed && ( +
+ {item.children?.map((child) => ( + + ))} +
+ )} +
+ ); + } + + // 2depth 메뉴 렌더링 + if (is2Depth) { + return ( +
+ + + {/* 자식 메뉴 (3depth) */} + {hasChildren && isExpanded && ( +
+ {item.children?.map((child) => ( + + ))} +
+ )} +
+ ); + } + + // 3depth 이상 메뉴 렌더링 (점 아이콘 + 작은 텍스트) + if (is3DepthOrMore) { + return ( +
+ + + {/* 자식 메뉴 (4depth 이상 - 재귀) */} + {hasChildren && isExpanded && ( +
+ {item.children?.map((child) => ( + + ))} +
+ )} +
+ ); + } + + return null; +} + export default function Sidebar({ menuItems, activeMenu, @@ -24,9 +248,7 @@ export default function Sidebar({ onCloseMobileSidebar, }: SidebarProps) { // 활성 메뉴 자동 스크롤을 위한 ref - // eslint-disable-next-line no-undef const activeMenuRef = useRef(null); - // eslint-disable-next-line no-undef const menuContainerRef = useRef(null); // 활성 메뉴가 변경될 때 자동 스크롤 @@ -39,18 +261,7 @@ export default function Sidebar({ inline: 'nearest', }); } - }, [activeMenu]); // activeMenu 변경 시에만 스크롤 (메뉴 클릭 시) - - const handleMenuClick = (menuId: string, path: string, hasChildren: boolean) => { - if (hasChildren) { - onToggleSubmenu(menuId); - } else { - onMenuClick(menuId, path); - if (isMobile && onCloseMobileSidebar) { - onCloseMobileSidebar(); - } - } - }; + }, [activeMenu]); return (
- {menuItems.map((item) => { - const IconComponent = item.icon; - const hasChildren = item.children && item.children.length > 0; - const isExpanded = expandedMenus.includes(item.id); - const isActive = activeMenu === item.id; - - return ( -
- {/* 메인 메뉴 버튼 */} - - - {/* 서브메뉴 */} - {hasChildren && isExpanded && !sidebarCollapsed && ( -
- {item.children?.map((subItem) => { - const SubIcon = subItem.icon; - const isSubActive = activeMenu === subItem.id; - return ( -
- -
- ); - })} -
- )} -
- ); - })} + {menuItems.map((item) => ( + + ))}
diff --git a/src/layouts/AuthenticatedLayout.tsx b/src/layouts/AuthenticatedLayout.tsx index 051a2a2a..2e3a8d4c 100644 --- a/src/layouts/AuthenticatedLayout.tsx +++ b/src/layouts/AuthenticatedLayout.tsx @@ -197,6 +197,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro }, [_hasHydrated, setMenuItems]); // 현재 경로에 맞는 메뉴 자동 활성화 (URL 직접 입력, 뒤로가기 대응) + // 3depth 이상 메뉴 구조 지원 useEffect(() => { if (!pathname || menuItems.length === 0) return; @@ -204,56 +205,69 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro const normalizedPath = pathname.replace(/^\/(ko|en|ja)/, ''); // 메뉴 탐색 함수: 메인 메뉴와 서브메뉴 모두 탐색 - // 경로 매칭: 정확히 일치하거나 하위 경로인 경우만 매칭 (예: /hr/attendance는 /hr/attendance-management와 매칭되면 안됨) + // 경로 매칭: 정확히 일치하거나 하위 경로인 경우만 매칭 const isPathMatch = (menuPath: string, currentPath: string): boolean => { if (currentPath === menuPath) return true; - // 하위 경로 확인: /menu/path/subpath 형태만 매칭 (슬래시로 구분) return currentPath.startsWith(menuPath + '/'); }; - const findActiveMenu = (items: MenuItem[]): { menuId: string; parentId?: string } | null => { - // 모든 매칭 가능한 메뉴 수집 (가장 긴 경로가 가장 구체적) - const matches: { menuId: string; parentId?: string; pathLength: number }[] = []; + // 재귀적으로 모든 depth의 메뉴를 탐색 (3depth 이상 지원) + type MenuMatch = { menuId: string; ancestorIds: string[]; pathLength: number }; + + const findMenuRecursive = ( + items: MenuItem[], + currentPath: string, + ancestors: string[] = [] + ): MenuMatch[] => { + const matches: MenuMatch[] = []; for (const item of items) { - // 서브메뉴 확인 + // 현재 메뉴의 경로 매칭 확인 + if (item.path && item.path !== '#' && isPathMatch(item.path, currentPath)) { + matches.push({ + menuId: item.id, + ancestorIds: ancestors, + pathLength: item.path.length, + }); + } + + // 자식 메뉴가 있으면 재귀적으로 탐색 if (item.children && item.children.length > 0) { - for (const child of item.children) { - if (child.path && isPathMatch(child.path, normalizedPath)) { - matches.push({ menuId: child.id, parentId: item.id, pathLength: child.path.length }); - } - } - } - - // 메인 메뉴 확인 - if (item.path && item.path !== '#' && isPathMatch(item.path, normalizedPath)) { - matches.push({ menuId: item.id, pathLength: item.path.length }); + const childMatches = findMenuRecursive( + item.children, + currentPath, + [...ancestors, item.id] // 현재 메뉴를 조상 목록에 추가 + ); + matches.push(...childMatches); } } - // 가장 긴 경로(가장 구체적인 매칭) 반환 - if (matches.length > 0) { - matches.sort((a, b) => b.pathLength - a.pathLength); - return { menuId: matches[0].menuId, parentId: matches[0].parentId }; - } - - return null; + return matches; }; - const result = findActiveMenu(menuItems); + const matches = findMenuRecursive(menuItems, normalizedPath); + + if (matches.length > 0) { + // 가장 긴 경로(가장 구체적인 매칭) 선택 + matches.sort((a, b) => b.pathLength - a.pathLength); + const result = matches[0]; - if (result) { // 활성 메뉴 설정 setActiveMenu(result.menuId); - // 부모 메뉴가 있으면 자동으로 확장 - if (result.parentId) { - setExpandedMenus(prev => - prev.includes(result.parentId!) ? prev : [...prev, result.parentId!] - ); + // 모든 조상 메뉴를 확장 (3depth 이상 지원) + if (result.ancestorIds.length > 0) { + setExpandedMenus(prev => { + const newExpanded = [...prev]; + result.ancestorIds.forEach(id => { + if (!newExpanded.includes(id)) { + newExpanded.push(id); + } + }); + return newExpanded; + }); } } - // 대시보드 등 메뉴에 매칭되지 않아도 expandedMenus 유지 // eslint-disable-next-line react-hooks/exhaustive-deps }, [pathname, menuItems, setActiveMenu]); diff --git a/src/lib/utils/menuTransform.ts b/src/lib/utils/menuTransform.ts index 1ecd41b5..617419a3 100644 --- a/src/lib/utils/menuTransform.ts +++ b/src/lib/utils/menuTransform.ts @@ -192,8 +192,30 @@ interface ApiMenu { external_url: string | null; } +/** + * 재귀적으로 자식 메뉴를 찾아서 트리 구조로 변환 (3depth 이상 지원) + */ +function buildChildrenRecursive(parentId: number, allMenus: ApiMenu[]): SerializableMenuItem[] { + const children = allMenus + .filter((menu) => menu.parent_id === parentId) + .sort((a, b) => a.sort_order - b.sort_order) + .map((menu) => { + const grandChildren = buildChildrenRecursive(menu.id, allMenus); + return { + id: menu.id.toString(), + label: menu.name, + iconName: menu.icon || 'folder', + path: menu.url || '#', + children: grandChildren.length > 0 ? grandChildren : undefined, + }; + }); + + return children; +} + /** * API 메뉴 데이터를 SerializableMenuItem 구조로 변환 (localStorage 저장용) + * 3depth 이상의 메뉴 구조 지원 */ export function transformApiMenusToMenuItems(apiMenus: ApiMenu[]): SerializableMenuItem[] { if (!apiMenus || !Array.isArray(apiMenus)) { @@ -201,27 +223,19 @@ export function transformApiMenusToMenuItems(apiMenus: ApiMenu[]): SerializableM } // parent_id가 null인 최상위 메뉴만 추출 - const parentMenus = apiMenus + const rootMenus = apiMenus .filter((menu) => menu.parent_id === null) .sort((a, b) => a.sort_order - b.sort_order); - // 각 부모 메뉴에 대해 자식 메뉴 찾기 - const menuItems: SerializableMenuItem[] = parentMenus.map((parentMenu) => { - const children = apiMenus - .filter((menu) => menu.parent_id === parentMenu.id) - .sort((a, b) => a.sort_order - b.sort_order) - .map((childMenu) => ({ - id: childMenu.id.toString(), - label: childMenu.name, - iconName: childMenu.icon || 'folder', // 문자열로 저장 - path: childMenu.url || '#', - })); + // 각 루트 메뉴에 대해 재귀적으로 자식 메뉴 찾기 + const menuItems: SerializableMenuItem[] = rootMenus.map((rootMenu) => { + const children = buildChildrenRecursive(rootMenu.id, apiMenus); return { - id: parentMenu.id.toString(), - label: parentMenu.name, - iconName: parentMenu.icon || 'folder', // 문자열로 저장 - path: parentMenu.url || '#', + id: rootMenu.id.toString(), + label: rootMenu.name, + iconName: rootMenu.icon || 'folder', + path: rootMenu.url || '#', children: children.length > 0 ? children : undefined, }; });