- DashboardSettingsSections, DetailModalSections 분리 - 모달 설정(카드/접대비/복리후생/부가세/월비용) 개선 - 섹션 컴포넌트 최적화 (매출/매입/카드/미출고 등) - mockData, types 정리 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
713 lines
24 KiB
TypeScript
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>
|
|
);
|
|
};
|