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

@@ -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>