Merge remote-tracking branch 'origin/master'

This commit is contained in:
2026-01-09 15:04:13 +09:00
8 changed files with 997 additions and 191 deletions

View File

@@ -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 고정)
<div className="h-screen flex flex-col">
// 변경: CSS 변수 사용 (동적 업데이트)
<div className="flex flex-col" style={{ height: 'var(--app-height)' }}>
```
---
## 작동 원리
```
┌─────────────────────────────────────────────────────┐
│ 폴드 전환 발생 │
│ ↓ │
│ 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/)

View File

@@ -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<string, DetailModalConfig> = {
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 && (
<CardManagementSection data={data.cardManagement} />
<CardManagementSection
data={data.cardManagement}
onCardClick={handleCardManagementCardClick}
/>
)}
{/* 접대비 현황 */}

View File

@@ -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 (
<div className="flex items-stretch gap-4">
{/* 왼쪽 박스 */}
<div className={cn(
"flex-1 border-2 rounded-lg overflow-hidden",
borderColorClass[config.leftBox.borderColor]
)}>
<div className={cn(
"px-4 py-2 text-sm font-medium text-gray-700",
titleBgClass[config.leftBox.borderColor]
)}>
{config.leftBox.title}
</div>
<div className="p-4 space-y-3">
{config.leftBox.items.map((item, index) => (
<div key={index}>
<p className="text-xs text-gray-500 mb-1">{item.label}</p>
<p className="text-lg font-bold text-gray-900">
{formatValue(item.value, item.unit)}
</p>
</div>
))}
</div>
</div>
{/* VS 영역 */}
<div className="flex flex-col items-center justify-center px-4">
<span className="text-2xl font-bold text-gray-400 mb-2">VS</span>
<div className="bg-red-50 rounded-lg px-4 py-2 text-center">
<p className="text-xs text-gray-600 mb-1">{config.vsLabel}</p>
<p className="text-xl font-bold text-red-500">
{typeof config.vsValue === 'number'
? formatCurrency(config.vsValue) + '원'
: config.vsValue}
</p>
{config.vsSubLabel && (
<p className="text-xs text-gray-500 mt-1">{config.vsSubLabel}</p>
)}
</div>
</div>
{/* 오른쪽 박스 */}
<div className={cn(
"flex-1 border-2 rounded-lg overflow-hidden",
borderColorClass[config.rightBox.borderColor]
)}>
<div className={cn(
"px-4 py-2 text-sm font-medium text-gray-700",
titleBgClass[config.rightBox.borderColor]
)}>
{config.rightBox.title}
</div>
<div className="p-4 space-y-3">
{config.rightBox.items.map((item, index) => (
<div key={index}>
<p className="text-xs text-gray-500 mb-1">{item.label}</p>
<p className="text-lg font-bold text-gray-900">
{formatValue(item.value, item.unit)}
</p>
</div>
))}
</div>
</div>
</div>
);
};
/**
* 참조 테이블 컴포넌트 (필터 없는 정보성 테이블)
*/
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 (
<div className="mt-6">
<h4 className="font-medium text-gray-800 mb-3">{config.title}</h4>
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-gray-100">
{config.columns.map((column) => (
<th
key={column.key}
className={cn(
"px-4 py-3 text-xs font-medium text-gray-600",
getAlignClass(column.align)
)}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{config.data.map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-t border-gray-100 hover:bg-gray-50"
>
{config.columns.map((column) => (
<td
key={column.key}
className={cn(
"px-4 py-3 text-sm text-gray-700",
getAlignClass(column.align)
)}
>
{String(row[column.key] ?? '-')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
/**
* 테이블 컴포넌트
*/
@@ -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 (
<td
key={column.key}
className={cn(
"px-4 py-3 text-sm",
getAlignClass(column.align),
isHighlighted && "text-orange-500 font-medium"
isHighlighted && "text-orange-500 font-medium",
highlightColorClass
)}
>
{cellValue}
@@ -380,6 +540,24 @@ const TableSection = ({ config }: { config: TableConfig }) => {
</tbody>
</table>
</div>
{/* 하단 다중 합계 섹션 */}
{config.footerSummary && config.footerSummary.length > 0 && (
<div className="mt-4 border rounded-lg bg-gray-50 p-4">
<div className="space-y-2">
{config.footerSummary.map((item, index) => (
<div key={index} className="flex items-center justify-between">
<span className="text-sm text-gray-600">{item.label}</span>
<span className="font-medium text-gray-900">
{typeof item.value === 'number'
? formatCurrency(item.value)
: item.value}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
};
@@ -429,6 +607,16 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
</div>
)}
{/* VS 비교 섹션 영역 */}
{config.comparisonSection && (
<ComparisonSection config={config.comparisonSection} />
)}
{/* 참조 테이블 영역 */}
{config.referenceTable && (
<ReferenceTableSection config={config.referenceTable} />
)}
{/* 테이블 영역 */}
{config.table && <TableSection key={config.title} config={config.table} />}
</div>

View File

@@ -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) {
<AmountCardItem
key={card.id}
card={card}
onClick={handleClick}
onClick={() => handleClick(card.id)}
/>
))}
</div>

View File

@@ -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<string, unknown>[];
}
// 상세 모달 전체 설정 타입
@@ -310,6 +349,8 @@ export interface DetailModalConfig {
barChart?: BarChartConfig;
pieChart?: PieChartConfig;
horizontalBarChart?: HorizontalBarChartConfig; // 가로 막대 차트 (도넛 차트 대신 사용)
comparisonSection?: ComparisonSectionConfig; // VS 비교 섹션
referenceTable?: ReferenceTableConfig; // 참조 테이블 (필터 없음)
table?: TableConfig;
}

View File

@@ -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<HTMLDivElement | null>;
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 (
<div
className="relative"
ref={isActive ? activeMenuRef : null}
>
{/* 메인 메뉴 버튼 */}
<button
onClick={handleClick}
className={`w-full flex items-center rounded-xl transition-all duration-200 ease-out touch-manipulation group relative overflow-hidden sidebar-menu-item ${
sidebarCollapsed ? 'p-3 justify-center' : 'space-x-2.5 p-3 md:p-3.5'
} ${
isActive
? "text-white clean-shadow scale-[0.98]"
: "text-foreground hover:bg-accent hover:scale-[1.02] active:scale-[0.98]"
}`}
style={isActive ? { backgroundColor: '#3B82F6' } : {}}
title={sidebarCollapsed ? item.label : undefined}
>
<div className={`rounded-lg flex items-center justify-center transition-all duration-200 sidebar-menu-icon aspect-square ${
sidebarCollapsed ? 'w-7' : 'w-8'
} ${
isActive
? "bg-white/20"
: "bg-primary/10 group-hover:bg-primary/20"
}`}>
{IconComponent && <IconComponent className={`transition-all duration-200 ${
sidebarCollapsed ? 'h-4 w-4' : 'h-5 w-5'
} ${
isActive ? "text-white" : "text-primary"
}`} />}
</div>
{!sidebarCollapsed && (
<>
<span className="flex-1 font-medium transition-all duration-200 opacity-100 text-left text-sm">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-4 w-4" />
</div>
)}
</>
)}
{isActive && !sidebarCollapsed && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
)}
</button>
{/* 자식 메뉴 (재귀) */}
{hasChildren && isExpanded && !sidebarCollapsed && (
<div className="mt-1.5 ml-3 space-y-1 border-l-2 border-primary/20 pl-3">
{item.children?.map((child) => (
<MenuItemComponent
key={child.id}
item={child}
depth={depth + 1}
activeMenu={activeMenu}
expandedMenus={expandedMenus}
sidebarCollapsed={sidebarCollapsed}
isMobile={isMobile}
activeMenuRef={activeMenuRef}
onMenuClick={onMenuClick}
onToggleSubmenu={onToggleSubmenu}
onCloseMobileSidebar={onCloseMobileSidebar}
/>
))}
</div>
)}
</div>
);
}
// 2depth 메뉴 렌더링
if (is2Depth) {
return (
<div ref={isActive ? activeMenuRef : null}>
<button
onClick={handleClick}
className={`w-full flex items-center rounded-lg transition-all duration-200 p-2.5 space-x-2.5 group ${
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
{IconComponent && <IconComponent className="h-4 w-4 flex-shrink-0" />}
<span className="flex-1 text-sm font-medium text-left">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-3.5 w-3.5" />
</div>
)}
</button>
{/* 자식 메뉴 (3depth) */}
{hasChildren && isExpanded && (
<div className="mt-1 ml-2 space-y-0.5 border-l border-border/50 pl-2">
{item.children?.map((child) => (
<MenuItemComponent
key={child.id}
item={child}
depth={depth + 1}
activeMenu={activeMenu}
expandedMenus={expandedMenus}
sidebarCollapsed={sidebarCollapsed}
isMobile={isMobile}
activeMenuRef={activeMenuRef}
onMenuClick={onMenuClick}
onToggleSubmenu={onToggleSubmenu}
onCloseMobileSidebar={onCloseMobileSidebar}
/>
))}
</div>
)}
</div>
);
}
// 3depth 이상 메뉴 렌더링 (점 아이콘 + 작은 텍스트)
if (is3DepthOrMore) {
return (
<div ref={isActive ? activeMenuRef : null}>
<button
onClick={handleClick}
className={`w-full flex items-center rounded-md transition-all duration-200 p-2 space-x-2 group ${
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
}`}
>
<Circle className={`h-1.5 w-1.5 flex-shrink-0 ${
isActive ? 'fill-primary text-primary' : 'fill-muted-foreground/50 text-muted-foreground/50'
}`} />
<span className="flex-1 text-xs text-left">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-3 w-3" />
</div>
)}
</button>
{/* 자식 메뉴 (4depth 이상 - 재귀) */}
{hasChildren && isExpanded && (
<div className="mt-0.5 ml-1.5 space-y-0.5 border-l border-border/30 pl-1.5">
{item.children?.map((child) => (
<MenuItemComponent
key={child.id}
item={child}
depth={depth + 1}
activeMenu={activeMenu}
expandedMenus={expandedMenus}
sidebarCollapsed={sidebarCollapsed}
isMobile={isMobile}
activeMenuRef={activeMenuRef}
onMenuClick={onMenuClick}
onToggleSubmenu={onToggleSubmenu}
onCloseMobileSidebar={onCloseMobileSidebar}
/>
))}
</div>
)}
</div>
);
}
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<HTMLDivElement | null>(null);
// eslint-disable-next-line no-undef
const menuContainerRef = useRef<HTMLDivElement | null>(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 (
<div className={`h-full flex flex-col clean-glass rounded-2xl overflow-hidden transition-all duration-300 ${
@@ -66,91 +277,21 @@ export default function Sidebar({
<div className={`transition-all duration-300 ${
sidebarCollapsed ? 'space-y-1.5 mt-4' : 'space-y-1.5 mt-3'
}`}>
{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 (
<div
key={item.id}
className="relative"
ref={isActive ? activeMenuRef : null}
>
{/* 메인 메뉴 버튼 */}
<button
onClick={() => handleMenuClick(item.id, item.path, !!hasChildren)}
className={`w-full flex items-center rounded-xl transition-all duration-200 ease-out touch-manipulation group relative overflow-hidden sidebar-menu-item ${
sidebarCollapsed ? 'p-3 justify-center' : 'space-x-2.5 p-3 md:p-3.5'
} ${
isActive
? "text-white clean-shadow scale-[0.98]"
: "text-foreground hover:bg-accent hover:scale-[1.02] active:scale-[0.98]"
}`}
style={isActive ? { backgroundColor: '#3B82F6' } : {}}
title={sidebarCollapsed ? item.label : undefined}
>
<div className={`rounded-lg flex items-center justify-center transition-all duration-200 sidebar-menu-icon aspect-square ${
sidebarCollapsed ? 'w-7' : 'w-8'
} ${
isActive
? "bg-white/20"
: "bg-primary/10 group-hover:bg-primary/20"
}`}>
{IconComponent && <IconComponent className={`transition-all duration-200 ${
sidebarCollapsed ? 'h-4 w-4' : 'h-5 w-5'
} ${
isActive ? "text-white" : "text-primary"
}`} />}
</div>
{!sidebarCollapsed && (
<>
<span className="flex-1 font-medium transition-all duration-200 opacity-100 text-left text-sm">{item.label}</span>
{hasChildren && (
<div className={`transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}>
<ChevronRight className="h-4 w-4" />
</div>
)}
</>
)}
{isActive && !sidebarCollapsed && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
)}
</button>
{/* 서브메뉴 */}
{hasChildren && isExpanded && !sidebarCollapsed && (
<div className="mt-1.5 ml-3 space-y-1.5 border-l-2 border-primary/20 pl-3">
{item.children?.map((subItem) => {
const SubIcon = subItem.icon;
const isSubActive = activeMenu === subItem.id;
return (
<div
key={subItem.id}
ref={isSubActive ? activeMenuRef : null}
>
<button
onClick={() => handleMenuClick(subItem.id, subItem.path, false)}
className={`w-full flex items-center rounded-lg transition-all duration-200 p-2.5 space-x-2.5 group ${
isSubActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
<SubIcon className="h-4 w-4" />
<span className="text-sm font-medium">{subItem.label}</span>
</button>
</div>
);
})}
</div>
)}
</div>
);
})}
{menuItems.map((item) => (
<MenuItemComponent
key={item.id}
item={item}
depth={0}
activeMenu={activeMenu}
expandedMenus={expandedMenus}
sidebarCollapsed={sidebarCollapsed}
isMobile={isMobile}
activeMenuRef={activeMenuRef}
onMenuClick={onMenuClick}
onToggleSubmenu={onToggleSubmenu}
onCloseMobileSidebar={onCloseMobileSidebar}
/>
))}
</div>
</div>
</div>

View File

@@ -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]);

View File

@@ -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,
};
});