+```
+
+---
+
+## 작동 원리
+
+```
+┌─────────────────────────────────────────────────────┐
+│ 폴드 전환 발생 │
+│ ↓ │
+│ 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) => (
+ |
+ {column.label}
+ |
+ ))}
+
+
+
+ {config.data.map((row, rowIndex) => (
+
+ {config.columns.map((column) => (
+ |
+ {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,
};
});
|