Merge remote-tracking branch 'origin/master'
This commit is contained in:
154
claudedocs/guides/[GUIDE] foldable-device-layout-fix.md
Normal file
154
claudedocs/guides/[GUIDE] foldable-device-layout-fix.md
Normal 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/)
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 접대비 현황 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user