Files
sam-react-prod/src/components/business/CEODashboard/modals/DetailModalSections.tsx
유병철 4e179d2eca refactor: [CEO대시보드] 컴포넌트 분리 및 모달/섹션 리팩토링
- DashboardSettingsSections, DetailModalSections 분리
- 모달 설정(카드/접대비/복리후생/부가세/월비용) 개선
- 섹션 컴포넌트 최적화 (매출/매입/카드/미출고 등)
- mockData, types 정리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:20:05 +09:00

713 lines
24 KiB
TypeScript

'use client';
import { useState, useCallback, useMemo } from 'react';
import { Search } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from 'recharts';
import { cn } from '@/lib/utils';
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
import type {
DateFilterConfig,
PeriodSelectConfig,
SummaryCardData,
BarChartConfig,
PieChartConfig,
HorizontalBarChartConfig,
TableConfig,
ComparisonSectionConfig,
ReferenceTableConfig,
CalculationCardsConfig,
QuarterlyTableConfig,
ReviewCardsConfig,
} from '../types';
// ============================================
// 공통 유틸리티
// ============================================
// 필터 섹션
// ============================================
export const DateFilterSection = ({ config }: { config: DateFilterConfig }) => {
const today = new Date();
const [startDate, setStartDate] = useState(() => {
const d = new Date(today.getFullYear(), today.getMonth(), 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
const [endDate, setEndDate] = useState(() => {
const d = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
const [searchText, setSearchText] = useState('');
return (
<div className="pb-4 border-b">
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
extraActions={
config.showSearch !== false ? (
<div className="relative ml-auto">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="검색"
className="h-8 pl-7 pr-3 text-xs w-[140px]"
/>
</div>
) : undefined
}
/>
</div>
);
};
export const PeriodSelectSection = ({ config }: { config: PeriodSelectConfig }) => {
const [selected, setSelected] = useState(config.defaultValue || config.options[0]?.value || '');
return (
<div className="flex items-center gap-2 pb-4 border-b">
<span className="text-sm text-gray-600 font-medium"></span>
<Select value={selected} onValueChange={setSelected}>
<SelectTrigger className="h-8 w-auto min-w-[200px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{config.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
// ============================================
// 카드 섹션
// ============================================
export 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-3 sm:p-4">
<p className="text-xs sm:text-sm text-gray-500 mb-1">{data.label}</p>
<p className={cn(
"text-lg sm:text-2xl font-bold break-all",
data.isComparison && (data.isPositive ? "text-blue-600" : "text-red-600")
)}>
{data.isComparison && !data.isPositive && typeof data.value === 'string' && !data.value.startsWith('-') ? '-' : ''}
{displayValue}
</p>
</div>
);
};
export const ReviewCardsSection = ({ config }: { config: ReviewCardsConfig }) => {
return (
<div>
<h4 className="font-medium text-gray-800 mb-3">{config.title}</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
{config.cards.map((card, index) => (
<div
key={index}
className="bg-orange-50 border border-orange-200 rounded-lg p-3 sm:p-4"
>
<p className="text-xs sm:text-sm text-orange-700 font-medium mb-1">{card.label}</p>
<p className="text-lg sm:text-xl font-bold text-orange-900">
{formatCurrency(card.amount)}
</p>
<p className="text-xs text-orange-600 mt-1">{card.subLabel}</p>
</div>
))}
</div>
</div>
);
};
export 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>
);
};
// ============================================
// 차트 섹션
// ============================================
export const BarChartSection = ({ config }: { config: BarChartConfig }) => {
return (
<div className="bg-gray-50 rounded-lg p-3 sm: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: -25, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E5E7EB" />
<XAxis
dataKey={config.xAxisKey}
axisLine={false}
tickLine={false}
tick={{ fontSize: 10, fill: '#6B7280' }}
interval={0}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 9, fill: '#6B7280' }}
tickFormatter={(value) => value >= 10000 ? `${value / 10000}` : value}
width={35}
/>
<Tooltip
formatter={(value) => [formatCurrency(value as number) + '원', '']}
contentStyle={{ fontSize: 12 }}
/>
<Bar
dataKey={config.dataKey}
fill={config.color || '#60A5FA'}
radius={[4, 4, 0, 0]}
maxBarSize={30}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};
export const PieChartSection = ({ config }: { config: PieChartConfig }) => {
return (
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 overflow-hidden">
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
<div className="flex justify-center mb-4">
<PieChart width={100} height={100}>
<Pie
data={config.data as unknown as Array<Record<string, unknown>>}
cx={50}
cy={50}
innerRadius={28}
outerRadius={45}
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-xs sm:text-sm gap-2">
<div className="flex items-center gap-1.5 sm:gap-2 min-w-0 flex-shrink">
<div
className="w-2.5 h-2.5 sm:w-3 sm:h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: item.color }}
/>
<span className="text-gray-600 truncate">{item.name}</span>
<span className="text-gray-400 flex-shrink-0">{item.percentage}%</span>
</div>
<span className="font-medium text-gray-900 flex-shrink-0">
{formatCurrency(item.value)}
</span>
</div>
))}
</div>
</div>
);
};
export 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>
);
};
// ============================================
// 비교 섹션
// ============================================
export 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>
)}
{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>
);
};
// ============================================
// 테이블 섹션
// ============================================
export 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-auto">
<table className="w-full min-w-[500px]">
<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>
);
};
export 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-auto">
<table className="w-full min-w-[400px]">
<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>
);
};
export 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];
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);
}
});
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':
case 'number':
return typeof value === 'number' ? formatCurrency(value) : 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] w-auto 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-auto">
<table className="w-full min-w-[600px]">
<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;
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>
);
};