269 lines
11 KiB
TypeScript
269 lines
11 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useState } from 'react';
|
||
|
|
import {
|
||
|
|
TrendingDown,
|
||
|
|
ArrowDownRight,
|
||
|
|
ArrowUpRight,
|
||
|
|
ShoppingCart,
|
||
|
|
DollarSign,
|
||
|
|
AlertCircle,
|
||
|
|
} from 'lucide-react';
|
||
|
|
import { Badge } from '@/components/ui/badge';
|
||
|
|
import {
|
||
|
|
Select,
|
||
|
|
SelectContent,
|
||
|
|
SelectItem,
|
||
|
|
SelectTrigger,
|
||
|
|
SelectValue,
|
||
|
|
} from '@/components/ui/select';
|
||
|
|
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
|
||
|
|
import {
|
||
|
|
BarChart,
|
||
|
|
Bar,
|
||
|
|
XAxis,
|
||
|
|
YAxis,
|
||
|
|
CartesianGrid,
|
||
|
|
Tooltip,
|
||
|
|
ResponsiveContainer,
|
||
|
|
PieChart,
|
||
|
|
Pie,
|
||
|
|
Cell,
|
||
|
|
Legend,
|
||
|
|
} from 'recharts';
|
||
|
|
import { formatKoreanAmount } from '@/lib/utils/amount';
|
||
|
|
import type { PurchaseStatusData } from '../types';
|
||
|
|
|
||
|
|
interface PurchaseStatusSectionProps {
|
||
|
|
data: PurchaseStatusData;
|
||
|
|
showDailyDetail?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
const formatAmount = (value: number) => {
|
||
|
|
if (value >= 100000000) return `${(value / 100000000).toFixed(1)}억`;
|
||
|
|
if (value >= 10000) return `${(value / 10000).toFixed(0)}만`;
|
||
|
|
return value.toLocaleString();
|
||
|
|
};
|
||
|
|
|
||
|
|
export function PurchaseStatusSection({ data, showDailyDetail = true }: PurchaseStatusSectionProps) {
|
||
|
|
const [supplierFilter, setSupplierFilter] = useState<string[]>([]);
|
||
|
|
const [sortOrder, setSortOrder] = useState('date-desc');
|
||
|
|
|
||
|
|
const filteredItems = data.dailyItems
|
||
|
|
.filter((item) => supplierFilter.length === 0 || supplierFilter.includes(item.supplier))
|
||
|
|
.sort((a, b) => {
|
||
|
|
if (sortOrder === 'date-desc') return b.date.localeCompare(a.date);
|
||
|
|
if (sortOrder === 'date-asc') return a.date.localeCompare(b.date);
|
||
|
|
if (sortOrder === 'amount-desc') return b.amount - a.amount;
|
||
|
|
return a.amount - b.amount;
|
||
|
|
});
|
||
|
|
|
||
|
|
const suppliers = [...new Set(data.dailyItems.map((item) => item.supplier))];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="rounded-xl border overflow-hidden">
|
||
|
|
{/* 다크 헤더 */}
|
||
|
|
<div style={{ backgroundColor: '#1e293b' }} className="px-6 py-4">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<div style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} className="p-2 rounded-lg">
|
||
|
|
<ShoppingCart style={{ color: '#ffffff' }} className="h-5 w-5" />
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<h3 style={{ color: '#ffffff' }} className="text-lg font-semibold">매입 현황</h3>
|
||
|
|
<p style={{ color: '#cbd5e1' }} className="text-sm">당월 매입 실적</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<Badge
|
||
|
|
style={{ backgroundColor: '#f59e0b', color: '#ffffff', border: 'none' }}
|
||
|
|
className="hover:opacity-90"
|
||
|
|
>
|
||
|
|
당월
|
||
|
|
</Badge>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div style={{ backgroundColor: '#ffffff' }} className="p-6">
|
||
|
|
{/* 통계카드 3개 */}
|
||
|
|
<div className="grid grid-cols-1 xs:grid-cols-3 gap-4 mb-6">
|
||
|
|
{/* 누적 매입 */}
|
||
|
|
<div
|
||
|
|
style={{ backgroundColor: '#fff7ed', borderColor: '#fed7aa' }}
|
||
|
|
className="rounded-xl p-4 border"
|
||
|
|
>
|
||
|
|
<div className="flex items-center gap-2 mb-2">
|
||
|
|
<div style={{ backgroundColor: '#f59e0b' }} className="p-1.5 rounded-lg">
|
||
|
|
<DollarSign style={{ color: '#ffffff' }} className="h-4 w-4" />
|
||
|
|
</div>
|
||
|
|
<span style={{ color: '#b45309' }} className="text-sm font-medium">누적 매입</span>
|
||
|
|
</div>
|
||
|
|
<span style={{ color: '#0f172a' }} className="text-xl font-bold">
|
||
|
|
{formatKoreanAmount(data.cumulativePurchase)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 미결제 금액 */}
|
||
|
|
<div
|
||
|
|
style={{ backgroundColor: '#fef2f2', borderColor: '#fecaca' }}
|
||
|
|
className="rounded-xl p-4 border"
|
||
|
|
>
|
||
|
|
<div className="flex items-center gap-2 mb-2">
|
||
|
|
<div style={{ backgroundColor: '#ef4444' }} className="p-1.5 rounded-lg">
|
||
|
|
<AlertCircle style={{ color: '#ffffff' }} className="h-4 w-4" />
|
||
|
|
</div>
|
||
|
|
<span style={{ color: '#dc2626' }} className="text-sm font-medium">미결제 금액</span>
|
||
|
|
</div>
|
||
|
|
<span style={{ color: '#0f172a' }} className="text-xl font-bold">
|
||
|
|
{formatKoreanAmount(data.unpaidAmount)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 전년 동기 대비 */}
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
backgroundColor: data.yoyChange >= 0 ? '#fef2f2' : '#eff6ff',
|
||
|
|
borderColor: data.yoyChange >= 0 ? '#fecaca' : '#bfdbfe',
|
||
|
|
}}
|
||
|
|
className="rounded-xl p-4 border"
|
||
|
|
>
|
||
|
|
<div className="flex items-center gap-2 mb-2">
|
||
|
|
<div style={{ backgroundColor: data.yoyChange >= 0 ? '#ef4444' : '#3b82f6' }} className="p-1.5 rounded-lg">
|
||
|
|
<TrendingDown style={{ color: '#ffffff' }} className="h-4 w-4" />
|
||
|
|
</div>
|
||
|
|
<span style={{ color: data.yoyChange >= 0 ? '#dc2626' : '#1d4ed8' }} className="text-sm font-medium">전년 동기 대비</span>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-1">
|
||
|
|
<span style={{ color: '#0f172a' }} className="text-xl font-bold">
|
||
|
|
{data.yoyChange >= 0 ? '+' : ''}{data.yoyChange}%
|
||
|
|
</span>
|
||
|
|
{data.yoyChange >= 0
|
||
|
|
? <ArrowUpRight className="h-4 w-4 text-red-500" />
|
||
|
|
: <ArrowDownRight className="h-4 w-4 text-blue-500" />}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 차트 2열 */}
|
||
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||
|
|
{/* 월별 매입 추이 */}
|
||
|
|
<div className="border rounded-lg p-4">
|
||
|
|
<h4 className="text-sm font-semibold text-gray-700 mb-3">월별 매입 추이</h4>
|
||
|
|
<ResponsiveContainer width="100%" height={200}>
|
||
|
|
<BarChart data={data.monthlyTrend}>
|
||
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||
|
|
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
|
||
|
|
<YAxis tickFormatter={formatAmount} tick={{ fontSize: 11 }} />
|
||
|
|
<Tooltip
|
||
|
|
formatter={(value: number | undefined) => [formatKoreanAmount(value ?? 0), '매입']}
|
||
|
|
/>
|
||
|
|
<Bar dataKey="amount" fill="#f59e0b" radius={[4, 4, 0, 0]} />
|
||
|
|
</BarChart>
|
||
|
|
</ResponsiveContainer>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 자재 유형별 비율 (Donut) */}
|
||
|
|
<div className="border rounded-lg p-4">
|
||
|
|
<h4 className="text-sm font-semibold text-gray-700 mb-3">자재 유형별 비율</h4>
|
||
|
|
<ResponsiveContainer width="100%" height={200}>
|
||
|
|
<PieChart>
|
||
|
|
<Pie
|
||
|
|
data={data.materialRatio.map((r) => ({ name: r.name, value: r.value, percentage: r.percentage, color: r.color }) as Record<string, unknown>)}
|
||
|
|
cx="50%"
|
||
|
|
cy="50%"
|
||
|
|
innerRadius={50}
|
||
|
|
outerRadius={80}
|
||
|
|
dataKey="value"
|
||
|
|
nameKey="name"
|
||
|
|
label={({ name, payload }) => `${name} ${(payload as { percentage?: number })?.percentage ?? 0}%`}
|
||
|
|
>
|
||
|
|
{data.materialRatio.map((entry, index) => (
|
||
|
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||
|
|
))}
|
||
|
|
</Pie>
|
||
|
|
<Legend />
|
||
|
|
</PieChart>
|
||
|
|
</ResponsiveContainer>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 당월 매입 내역 테이블 */}
|
||
|
|
{showDailyDetail && (
|
||
|
|
<div className="border rounded-lg overflow-hidden">
|
||
|
|
<div className="flex items-center justify-between p-4 bg-gray-50 border-b">
|
||
|
|
<h4 className="text-sm font-semibold text-gray-700">당월 매입 내역</h4>
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<MultiSelectCombobox
|
||
|
|
options={suppliers.map((s) => ({ value: s, label: s }))}
|
||
|
|
value={supplierFilter}
|
||
|
|
onChange={setSupplierFilter}
|
||
|
|
placeholder="전체 공급처"
|
||
|
|
className="w-[160px] h-8 text-xs"
|
||
|
|
/>
|
||
|
|
<Select value={sortOrder} onValueChange={setSortOrder}>
|
||
|
|
<SelectTrigger className="w-[120px] h-8 text-xs">
|
||
|
|
<SelectValue placeholder="정렬" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="date-desc">최신순</SelectItem>
|
||
|
|
<SelectItem value="date-asc">오래된순</SelectItem>
|
||
|
|
<SelectItem value="amount-desc">금액 높은순</SelectItem>
|
||
|
|
<SelectItem value="amount-asc">금액 낮은순</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="overflow-x-auto">
|
||
|
|
<table className="w-full text-sm">
|
||
|
|
<thead>
|
||
|
|
<tr className="bg-gray-50 border-b">
|
||
|
|
<th className="px-4 py-2 text-left text-gray-600 font-medium">날짜</th>
|
||
|
|
<th className="px-4 py-2 text-left text-gray-600 font-medium">공급처</th>
|
||
|
|
<th className="px-4 py-2 text-left text-gray-600 font-medium">품목</th>
|
||
|
|
<th className="px-4 py-2 text-right text-gray-600 font-medium">금액</th>
|
||
|
|
<th className="px-4 py-2 text-center text-gray-600 font-medium">상태</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{filteredItems.map((item, idx) => (
|
||
|
|
<tr key={idx} className="border-b last:border-b-0 hover:bg-gray-50">
|
||
|
|
<td className="px-4 py-2 text-gray-700">{item.date}</td>
|
||
|
|
<td className="px-4 py-2 text-gray-700">{item.supplier}</td>
|
||
|
|
<td className="px-4 py-2 text-gray-700">{item.item}</td>
|
||
|
|
<td className="px-4 py-2 text-right text-gray-900 font-medium">
|
||
|
|
{item.amount.toLocaleString()}원
|
||
|
|
</td>
|
||
|
|
<td className="px-4 py-2 text-center">
|
||
|
|
<Badge
|
||
|
|
variant="outline"
|
||
|
|
className={
|
||
|
|
item.status === '결제완료'
|
||
|
|
? 'text-green-600 border-green-200 bg-green-50'
|
||
|
|
: item.status === '미결제'
|
||
|
|
? 'text-red-600 border-red-200 bg-red-50'
|
||
|
|
: 'text-orange-600 border-orange-200 bg-orange-50'
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{item.status}
|
||
|
|
</Badge>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
<tfoot>
|
||
|
|
<tr className="bg-gray-100 font-semibold">
|
||
|
|
<td className="px-4 py-2 text-gray-700" colSpan={3}>합계</td>
|
||
|
|
<td className="px-4 py-2 text-right text-gray-900">
|
||
|
|
{filteredItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString()}원
|
||
|
|
</td>
|
||
|
|
<td />
|
||
|
|
</tr>
|
||
|
|
</tfoot>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|