Files
sam-react-prod/src/components/business/CEODashboard/modals/DetailModal.tsx
byeongcheolryu e4af3232dd chore(WEB): CEO 대시보드 개선 및 모바일 테스트 계획 추가
- CEO 대시보드: 일일보고, 접대비, 복리후생 섹션 개선
- CEO 대시보드: 상세 모달 기능 확장
- 카드거래조회: 기능 및 타입 확장
- 알림설정: 항목 설정 다이얼로그 추가
- 회사정보관리: 컴포넌트 개선
- 모바일 오버플로우 테스트 계획서 추가 (Galaxy Fold 대응)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:02:04 +09:00

761 lines
25 KiB
TypeScript

'use client';
import { useState, useCallback, useMemo } from 'react';
import { X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from 'recharts';
import { cn } from '@/lib/utils';
import type {
DetailModalConfig,
SummaryCardData,
BarChartConfig,
PieChartConfig,
HorizontalBarChartConfig,
TableConfig,
TableFilterConfig,
ComparisonSectionConfig,
ReferenceTableConfig,
CalculationCardsConfig,
QuarterlyTableConfig,
} from '../types';
interface DetailModalProps {
isOpen: boolean;
onClose: () => void;
config: DetailModalConfig;
}
/**
* 금액 포맷 함수
*/
const formatCurrency = (value: number): string => {
return new Intl.NumberFormat('ko-KR').format(value);
};
/**
* 요약 카드 컴포넌트
*/
const SummaryCard = ({ data }: { data: SummaryCardData }) => {
const displayValue = typeof data.value === 'number'
? formatCurrency(data.value) + (data.unit || '원')
: data.value;
return (
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-500 mb-1">{data.label}</p>
<p className={cn(
"text-2xl font-bold",
data.isComparison && (data.isPositive ? "text-blue-600" : "text-red-600")
)}>
{data.isComparison && !data.isPositive && typeof data.value === 'string' && !data.value.startsWith('-') ? '-' : ''}
{displayValue}
</p>
</div>
);
};
/**
* 막대 차트 컴포넌트
*/
const BarChartSection = ({ config }: { config: BarChartConfig }) => {
return (
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
<div className="h-[150px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={config.data} margin={{ top: 5, right: 5, left: -20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E5E7EB" />
<XAxis
dataKey={config.xAxisKey}
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#6B7280' }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 10, fill: '#6B7280' }}
tickFormatter={(value) => value >= 10000 ? `${value / 10000}` : value}
/>
<Tooltip
formatter={(value: number) => [formatCurrency(value) + '원', '']}
contentStyle={{ fontSize: 12 }}
/>
<Bar
dataKey={config.dataKey}
fill={config.color || '#60A5FA'}
radius={[4, 4, 0, 0]}
maxBarSize={40}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};
/**
* 도넛 차트 컴포넌트
*/
const PieChartSection = ({ config }: { config: PieChartConfig }) => {
return (
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
{/* 도넛 차트 - 중앙 정렬 */}
<div className="flex justify-center mb-4">
<PieChart width={120} height={120}>
<Pie
data={config.data}
cx={60}
cy={60}
innerRadius={35}
outerRadius={55}
paddingAngle={2}
dataKey="value"
>
{config.data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
</PieChart>
</div>
{/* 범례 - 차트 아래 배치 */}
<div className="space-y-2">
{config.data.map((item, index) => (
<div key={index} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: item.color }}
/>
<span className="text-gray-600">{item.name}</span>
<span className="text-gray-400">{item.percentage}%</span>
</div>
<span className="font-medium text-gray-900">
{formatCurrency(item.value)}
</span>
</div>
))}
</div>
</div>
);
};
/**
* 가로 막대 차트 컴포넌트
*/
const HorizontalBarChartSection = ({ config }: { config: HorizontalBarChartConfig }) => {
const maxValue = Math.max(...config.data.map(d => d.value));
return (
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
<div className="space-y-3">
{config.data.map((item, index) => (
<div key={index} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">{item.name}</span>
<span className="font-medium text-gray-900">
{formatCurrency(item.value)}
</span>
</div>
<div className="h-4 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${(item.value / maxValue) * 100}%`,
backgroundColor: config.color || '#60A5FA',
}}
/>
</div>
</div>
))}
</div>
</div>
);
};
/**
* 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-3 text-center min-w-[180px]">
<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>
)}
{/* VS 세부 항목 */}
{config.vsBreakdown && config.vsBreakdown.length > 0 && (
<div className="mt-2 pt-2 border-t border-red-200 space-y-1">
{config.vsBreakdown.map((item, index) => (
<div key={index} className="flex justify-between text-xs">
<span className="text-gray-600">{item.label}</span>
<span className="font-medium text-gray-700">
{typeof item.value === 'number'
? formatCurrency(item.value) + (item.unit || '원')
: item.value}
</span>
</div>
))}
</div>
)}
</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 CalculationCardsSection = ({ config }: { config: CalculationCardsConfig }) => {
const isResultCard = (index: number, operator?: string) => {
// '=' 연산자가 있는 카드는 결과 카드로 강조
return operator === '=';
};
return (
<div className="mt-6">
<div className="flex items-center gap-2 mb-3">
<h4 className="font-medium text-gray-800">{config.title}</h4>
{config.subtitle && (
<span className="text-sm text-gray-500">{config.subtitle}</span>
)}
</div>
<div className="flex items-center gap-3">
{config.cards.map((card, index) => (
<div key={index} className="flex items-center gap-3">
{/* 연산자 표시 (첫 번째 카드 제외) */}
{index > 0 && card.operator && (
<span className="text-3xl font-bold text-gray-400">
{card.operator}
</span>
)}
{/* 카드 */}
<div className={cn(
"rounded-lg p-5 min-w-[180px] text-center border",
isResultCard(index, card.operator)
? "bg-blue-50 border-blue-200"
: "bg-gray-50 border-gray-200"
)}>
<p className={cn(
"text-sm mb-2",
isResultCard(index, card.operator) ? "text-blue-600" : "text-gray-500"
)}>
{card.label}
</p>
<p className={cn(
"text-2xl font-bold",
isResultCard(index, card.operator) ? "text-blue-700" : "text-gray-900"
)}>
{formatCurrency(card.value)}{card.unit || '원'}
</p>
</div>
</div>
))}
</div>
</div>
);
};
/**
* 분기별 테이블 섹션 컴포넌트 (접대비 현황 등)
*/
const QuarterlyTableSection = ({ config }: { config: QuarterlyTableConfig }) => {
const formatValue = (value: number | string | undefined): string => {
if (value === undefined) return '-';
if (typeof value === 'number') return formatCurrency(value);
return value;
};
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">
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-left"></th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">1</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">2</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">3</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">4</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center"></th>
</tr>
</thead>
<tbody>
{config.rows.map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-t border-gray-100 hover:bg-gray-50"
>
<td className="px-4 py-3 text-sm text-gray-700 font-medium">{row.label}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q1)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q2)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q3)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q4)}</td>
<td className="px-4 py-3 text-sm text-gray-900 text-center font-medium">{formatValue(row.total)}</td>
</tr>
))}
</tbody>
</table>
</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>
);
};
/**
* 테이블 컴포넌트
*/
const TableSection = ({ config }: { config: TableConfig }) => {
const [filters, setFilters] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
config.filters?.forEach((filter) => {
initial[filter.key] = filter.defaultValue;
});
return initial;
});
const handleFilterChange = useCallback((key: string, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
// 필터링된 데이터
const filteredData = useMemo(() => {
// 데이터가 없는 경우 빈 배열 반환
if (!config.data || !Array.isArray(config.data)) {
return [];
}
let result = [...config.data];
// 각 필터 적용 (sortOrder는 정렬용이므로 제외)
config.filters?.forEach((filter) => {
if (filter.key === 'sortOrder') return; // 정렬 필터는 값 필터링에서 제외
const filterValue = filters[filter.key];
if (filterValue && filterValue !== 'all') {
result = result.filter((row) => row[filter.key] === filterValue);
}
});
// 정렬 필터 적용 (sortOrder가 있는 경우)
if (filters['sortOrder']) {
const sortOrder = filters['sortOrder'];
result.sort((a, b) => {
// 금액 정렬
if (sortOrder === 'amountDesc') {
return (b['amount'] as number) - (a['amount'] as number);
}
if (sortOrder === 'amountAsc') {
return (a['amount'] as number) - (b['amount'] as number);
}
// 날짜 정렬
const dateA = new Date(a['date'] as string).getTime();
const dateB = new Date(b['date'] as string).getTime();
return sortOrder === 'latest' ? dateB - dateA : dateA - dateB;
});
}
return result;
}, [config.data, config.filters, filters]);
// 셀 값 포맷팅
const formatCellValue = (value: unknown, format?: string): string => {
if (value === null || value === undefined) return '-';
switch (format) {
case 'currency':
return typeof value === 'number' ? formatCurrency(value) : String(value);
case 'number':
return typeof value === 'number' ? formatCurrency(value) : String(value);
case 'date':
return String(value);
default:
return String(value);
}
};
// 셀 정렬 클래스
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">
{/* 테이블 헤더 */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-800">{config.title}</h4>
<span className="text-sm text-gray-500"> {filteredData.length}</span>
</div>
{/* 필터 영역 */}
{config.filters && config.filters.length > 0 && (
<div className="flex items-center gap-2">
{config.filters.map((filter) => (
<Select
key={filter.key}
value={filters[filter.key]}
onValueChange={(value) => handleFilterChange(filter.key, value)}
>
<SelectTrigger className="h-8 w-auto min-w-[80px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{filter.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
))}
</div>
)}
</div>
{/* 테이블 */}
<div className="border rounded-lg max-h-[400px] overflow-y-auto">
<table className="w-full">
<thead className="sticky top-0 z-10">
<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.width && `w-[${column.width}]`
)}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{filteredData.map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-t border-gray-100 hover:bg-gray-50"
>
{config.columns.map((column) => {
const cellValue = column.key === 'no'
? rowIndex + 1
: 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",
highlightColorClass
)}
>
{cellValue}
</td>
);
})}
</tr>
))}
{/* 합계 행 */}
{config.showTotal && (
<tr className="border-t-2 border-gray-200 bg-gray-50 font-medium">
{config.columns.map((column, colIndex) => (
<td
key={column.key}
className={cn(
"px-4 py-3 text-sm",
getAlignClass(column.align)
)}
>
{column.key === config.totalColumnKey
? (typeof config.totalValue === 'number'
? formatCurrency(config.totalValue)
: config.totalValue)
: (colIndex === 0 ? config.totalLabel || '합계' : '')}
</td>
))}
</tr>
)}
</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>
);
};
/**
* 상세 모달 공통 컴포넌트
*/
export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="!w-[85vw] !max-w-[1600px] max-h-[90vh] overflow-y-auto p-0">
{/* 헤더 */}
<DialogHeader className="sticky top-0 bg-white z-10 px-6 py-4 border-b">
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-bold">{config.title}</DialogTitle>
<button
type="button"
onClick={onClose}
className="p-1 hover:bg-gray-100 rounded-full transition-colors"
>
<X className="h-5 w-5 text-gray-500" />
</button>
</div>
</DialogHeader>
<div className="p-6 space-y-6">
{/* 요약 카드 영역 */}
{config.summaryCards.length > 0 && (
<div className={cn(
"grid gap-4",
config.summaryCards.length === 2 && "grid-cols-2",
config.summaryCards.length === 3 && "grid-cols-3",
config.summaryCards.length >= 4 && "grid-cols-2 md:grid-cols-4"
)}>
{config.summaryCards.map((card, index) => (
<SummaryCard key={index} data={card} />
))}
</div>
)}
{/* 차트 영역 */}
{(config.barChart || config.pieChart || config.horizontalBarChart) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{config.barChart && <BarChartSection config={config.barChart} />}
{config.pieChart && <PieChartSection config={config.pieChart} />}
{config.horizontalBarChart && <HorizontalBarChartSection config={config.horizontalBarChart} />}
</div>
)}
{/* VS 비교 섹션 영역 */}
{config.comparisonSection && (
<ComparisonSection config={config.comparisonSection} />
)}
{/* 참조 테이블 영역 (단일 - 테이블 위에 표시) */}
{config.referenceTable && (
<ReferenceTableSection config={config.referenceTable} />
)}
{/* 계산 카드 섹션 영역 (테이블 위에 표시) */}
{config.calculationCards && (
<CalculationCardsSection config={config.calculationCards} />
)}
{/* 메인 테이블 영역 */}
{config.table && <TableSection key={config.title} config={config.table} />}
{/* 참조 테이블 영역 (다중 - 테이블 아래 표시) */}
{config.referenceTables && config.referenceTables.length > 0 && (
<div className="space-y-4">
{config.referenceTables.map((tableConfig, index) => (
<ReferenceTableSection key={index} config={tableConfig} />
))}
</div>
)}
{/* 분기별 테이블 영역 */}
{config.quarterlyTable && (
<QuarterlyTableSection config={config.quarterlyTable} />
)}
</div>
</DialogContent>
</Dialog>
);
}