feat(momo v0.4): 통계 그래프(recharts) + 엑셀 다운로드(xlsx) + 모바일 반응형 정리
Deploy momo-erp / deploy (push) Successful in 52s
Deploy momo-erp / deploy (push) Successful in 52s
[통계 페이지 3종 — recharts + xlsx 다운로드] - /m/admin/statistics (월간 업체별): 누적 막대그래프 TOP15 + 엑셀 - /m/admin/statistics/daily (일자별): ComposedChart (스택 막대 + 건수 라인) + 엑셀 - /m/admin/statistics/margin (원가/마진): 매출/원가/마진 막대 TOP10 + 엑셀 [발주 이력 (/m/orders)] - 엑셀 다운로드 버튼 추가 - 모바일 폭 대응 (헤더 wrap, 테이블 가로 스크롤, 버튼 사이즈 sm) [공통] - src/lib/xlsx-export.ts: downloadXlsx 헬퍼 신설 (컬럼 정의 + 시트명 + 자동 타임스탬프) - 타입 폭 넓힘 (T 제약 제거, recharts Tooltip formatter 시그니처 호환) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Download } from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer, ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, Legend, CartesianGrid,
|
||||
} from "recharts";
|
||||
import { downloadXlsx } from "@/lib/xlsx-export";
|
||||
|
||||
interface Row { DAY: string; ORDER_CNT: number; TOTAL: number; TAX_FREE: number; TAXABLE: number }
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
@@ -14,55 +19,117 @@ function defaultRange() {
|
||||
export default function DailyStatsPage() {
|
||||
const [[from, to], setRange] = useState(defaultRange());
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/statistics/daily", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dateFrom: from, dateTo: to }),
|
||||
});
|
||||
setRows((await res.json()).RESULTLIST ?? []);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/m/statistics/daily", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dateFrom: from, dateTo: to }),
|
||||
});
|
||||
setRows((await res.json()).RESULTLIST ?? []);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line
|
||||
|
||||
const max = Math.max(1, ...rows.map((r) => Number(r.TOTAL)));
|
||||
const total = rows.reduce((a, r) => a + Number(r.TOTAL), 0);
|
||||
const totalFree = rows.reduce((a, r) => a + Number(r.TAX_FREE), 0);
|
||||
const totalTaxable = rows.reduce((a, r) => a + Number(r.TAXABLE), 0);
|
||||
const totalCnt = rows.reduce((a, r) => a + Number(r.ORDER_CNT), 0);
|
||||
|
||||
const chartData = rows.map((r) => ({
|
||||
day: r.DAY.slice(5),
|
||||
면세: Number(r.TAX_FREE),
|
||||
과세: Number(r.TAXABLE),
|
||||
합계: Number(r.TOTAL),
|
||||
건수: Number(r.ORDER_CNT),
|
||||
}));
|
||||
|
||||
const onExport = () => {
|
||||
if (rows.length === 0) return;
|
||||
downloadXlsx(
|
||||
`일별매출_${from}_${to}`,
|
||||
rows,
|
||||
[
|
||||
{ header: "일자", key: "DAY", width: 12 },
|
||||
{ header: "주문건수", key: (r) => Number(r.ORDER_CNT), width: 10 },
|
||||
{ header: "면세 합계", key: (r) => Number(r.TAX_FREE), width: 14 },
|
||||
{ header: "과세 공급가", key: (r) => Number(r.TAXABLE), width: 14 },
|
||||
{ header: "총 매출", key: (r) => Number(r.TOTAL), width: 14 },
|
||||
],
|
||||
"일별매출"
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">통계 — 일자별 매출</h1>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-xl sm:text-2xl font-bold">통계 — 일자별 매출</h1>
|
||||
<button
|
||||
onClick={onExport}
|
||||
disabled={rows.length === 0}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-lg bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50"
|
||||
>
|
||||
<Download size={14} /> 엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap items-end">
|
||||
<input type="date" value={from} onChange={(e) => setRange([e.target.value, to])} className="h-10 px-3 rounded-lg border border-slate-200" />
|
||||
<input type="date" value={to} onChange={(e) => setRange([from, e.target.value])} className="h-10 px-3 rounded-lg border border-slate-200" />
|
||||
<input type="date" value={from} onChange={(e) => setRange([e.target.value, to])} className="h-10 px-3 rounded-lg border border-slate-200 text-sm" />
|
||||
<input type="date" value={to} onChange={(e) => setRange([from, e.target.value])} className="h-10 px-3 rounded-lg border border-slate-200 text-sm" />
|
||||
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-xl bg-violet-50 border border-violet-200 p-5"><div className="text-xs font-semibold text-violet-700">면세 합계</div><div className="text-2xl font-bold text-violet-900">₩{fmt(totalFree)}</div></div>
|
||||
<div className="rounded-xl bg-rose-50 border border-rose-200 p-5"><div className="text-xs font-semibold text-rose-700">과세 공급가</div><div className="text-2xl font-bold text-rose-900">₩{fmt(totalTaxable)}</div></div>
|
||||
<div className="rounded-xl bg-emerald-50 border border-emerald-200 p-5"><div className="text-xs font-semibold text-emerald-700">총 매출 (VAT)</div><div className="text-2xl font-bold text-emerald-900">₩{fmt(total)}</div></div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<Card label="주문 건수" value={`${fmt(totalCnt)}건`} color="slate" />
|
||||
<Card label="면세 합계" value={`₩${fmt(totalFree)}`} color="violet" />
|
||||
<Card label="과세 공급가" value={`₩${fmt(totalTaxable)}`} color="rose" />
|
||||
<Card label="총 매출 (VAT)" value={`₩${fmt(total)}`} color="emerald" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl p-5">
|
||||
<h3 className="font-bold mb-3">일별 매출 그래프</h3>
|
||||
<div className="flex items-end gap-1 h-48 px-2 overflow-x-auto">
|
||||
{rows.length === 0 ? <div className="m-auto text-slate-400">데이터가 없습니다.</div> : rows.map((r, i) => (
|
||||
<div key={i} className="flex flex-col items-center gap-1 min-w-[35px]">
|
||||
<div className="w-full bg-emerald-500/80 rounded-t hover:bg-emerald-700 transition" style={{ height: `${(Number(r.TOTAL) / max) * 100}%` }} title={`${r.DAY}: ₩${fmt(r.TOTAL)}`} />
|
||||
<div className="text-[9px] text-slate-500 -rotate-45 origin-top-left whitespace-nowrap">{r.DAY.slice(5)}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="bg-white border rounded-xl p-4">
|
||||
<h3 className="font-bold text-slate-700 mb-3 text-sm">일별 매출 추이</h3>
|
||||
<div className="w-full h-72 sm:h-80">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">불러오는 중...</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">데이터가 없습니다.</div>
|
||||
) : (
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={chartData} margin={{ top: 10, right: 20, bottom: 0, left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="day" tick={{ fontSize: 10 }} />
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}만`} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10 }} />
|
||||
<Tooltip
|
||||
formatter={(v, name) => name === "건수" ? `${Number(v)}건` : `₩${fmt(Number(v))}`}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar yAxisId="left" dataKey="면세" stackId="a" fill="#8b5cf6" />
|
||||
<Bar yAxisId="left" dataKey="과세" stackId="a" fill="#f43f5e" />
|
||||
<Line yAxisId="right" type="monotone" dataKey="건수" stroke="#0ea5e9" strokeWidth={2} dot={{ r: 3 }} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<div className="bg-white border rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[600px]">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr><th className="text-left px-4 py-3">일자</th><th className="text-right px-4 py-3">건수</th><th className="text-right px-4 py-3">면세</th><th className="text-right px-4 py-3">과세</th><th className="text-right px-4 py-3">합계</th></tr>
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">일자</th>
|
||||
<th className="text-right px-4 py-3">건수</th>
|
||||
<th className="text-right px-4 py-3">면세</th>
|
||||
<th className="text-right px-4 py-3">과세</th>
|
||||
<th className="text-right px-4 py-3">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={5} className="text-center py-12 text-slate-400">선택한 기간의 매출 데이터가 없습니다.</td></tr>
|
||||
) : rows.map((r) => (
|
||||
<tr key={r.DAY} className="border-t border-slate-100">
|
||||
<td className="px-4 py-2.5 font-semibold">{r.DAY}</td>
|
||||
<td className="px-4 py-2.5 text-right">{r.ORDER_CNT}건</td>
|
||||
@@ -77,3 +144,18 @@ export default function DailyStatsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ label, value, color }: { label: string; value: string; color: "slate" | "violet" | "rose" | "emerald" }) {
|
||||
const cls = {
|
||||
slate: "bg-slate-50 border-slate-200 text-slate-800",
|
||||
violet: "bg-violet-50 border-violet-200 text-violet-800",
|
||||
rose: "bg-rose-50 border-rose-200 text-rose-800",
|
||||
emerald: "bg-emerald-50 border-emerald-200 text-emerald-800",
|
||||
}[color];
|
||||
return (
|
||||
<div className={`rounded-xl border ${cls} p-4 sm:p-5`}>
|
||||
<div className="text-xs font-semibold opacity-80 mb-1">{label}</div>
|
||||
<div className="text-lg sm:text-2xl font-bold tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Download } from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, Legend, CartesianGrid,
|
||||
} from "recharts";
|
||||
import { downloadXlsx } from "@/lib/xlsx-export";
|
||||
|
||||
interface Row { ITEM_CODE: string; ITEM_NAME: string; QTY: number; REVENUE: number; COST: number; MARGIN: number }
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
@@ -9,13 +14,17 @@ export default function MarginPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [month, setMonth] = useState(new Date().getMonth() + 1);
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/statistics/margin", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year, month }),
|
||||
});
|
||||
setRows((await res.json()).RESULTLIST ?? []);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/m/statistics/margin", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year, month }),
|
||||
});
|
||||
setRows((await res.json()).RESULTLIST ?? []);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line
|
||||
|
||||
@@ -24,10 +33,49 @@ export default function MarginPage() {
|
||||
const totalMargin = totalRev - totalCost;
|
||||
const marginPct = totalRev ? ((totalMargin / totalRev) * 100).toFixed(1) : "0.0";
|
||||
|
||||
const chartData = [...rows]
|
||||
.sort((a, b) => Number(b.MARGIN) - Number(a.MARGIN))
|
||||
.slice(0, 10)
|
||||
.map((r) => ({
|
||||
name: r.ITEM_NAME?.length > 8 ? r.ITEM_NAME.slice(0, 8) + "…" : r.ITEM_NAME,
|
||||
fullName: r.ITEM_NAME,
|
||||
매출: Number(r.REVENUE),
|
||||
원가: Number(r.COST),
|
||||
마진: Number(r.MARGIN),
|
||||
}));
|
||||
|
||||
const onExport = () => {
|
||||
if (rows.length === 0) return;
|
||||
downloadXlsx(
|
||||
`원가마진_${year}년${month}월`,
|
||||
rows,
|
||||
[
|
||||
{ header: "품목코드", key: "ITEM_CODE", width: 16 },
|
||||
{ header: "품목명", key: "ITEM_NAME", width: 24 },
|
||||
{ header: "판매수량", key: (r) => Number(r.QTY), width: 10 },
|
||||
{ header: "매출(공급가)", key: (r) => Number(r.REVENUE), width: 14 },
|
||||
{ header: "매입원가", key: (r) => Number(r.COST), width: 14 },
|
||||
{ header: "마진", key: (r) => Number(r.MARGIN), width: 14 },
|
||||
{ header: "마진율(%)", key: (r) => Number(r.REVENUE) ? Number(((Number(r.MARGIN) / Number(r.REVENUE)) * 100).toFixed(2)) : 0, width: 10 },
|
||||
],
|
||||
`${year}_${month}`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">통계 — 원가 / 마진 (월간 품목별)</h1>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-xl sm:text-2xl font-bold">통계 — 원가 / 마진</h1>
|
||||
<button
|
||||
onClick={onExport}
|
||||
disabled={rows.length === 0}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-lg bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50"
|
||||
>
|
||||
<Download size={14} /> 엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<select value={year} onChange={(e) => setYear(Number(e.target.value))} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => <option key={y} value={y}>{y}년</option>)}
|
||||
</select>
|
||||
@@ -37,15 +85,41 @@ export default function MarginPage() {
|
||||
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="rounded-xl bg-emerald-50 border border-emerald-200 p-5"><div className="text-xs font-semibold text-emerald-700">매출(공급가)</div><div className="text-2xl font-bold text-emerald-900">₩{fmt(totalRev)}</div></div>
|
||||
<div className="rounded-xl bg-amber-50 border border-amber-200 p-5"><div className="text-xs font-semibold text-amber-700">매입원가</div><div className="text-2xl font-bold text-amber-900">₩{fmt(totalCost)}</div></div>
|
||||
<div className="rounded-xl bg-blue-50 border border-blue-200 p-5"><div className="text-xs font-semibold text-blue-700">마진</div><div className="text-2xl font-bold text-blue-900">₩{fmt(totalMargin)}</div></div>
|
||||
<div className="rounded-xl bg-violet-50 border border-violet-200 p-5"><div className="text-xs font-semibold text-violet-700">마진율</div><div className="text-2xl font-bold text-violet-900">{marginPct}%</div></div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<Card label="매출(공급가)" value={`₩${fmt(totalRev)}`} color="emerald" />
|
||||
<Card label="매입원가" value={`₩${fmt(totalCost)}`} color="amber" />
|
||||
<Card label="마진" value={`₩${fmt(totalMargin)}`} color="blue" />
|
||||
<Card label="마진율" value={`${marginPct}%`} color="violet" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<div className="bg-white border rounded-xl p-4">
|
||||
<h3 className="font-bold text-slate-700 mb-3 text-sm">마진 TOP 10 품목</h3>
|
||||
<div className="w-full h-72 sm:h-80">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">불러오는 중...</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">데이터가 없습니다.</div>
|
||||
) : (
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={chartData} margin={{ top: 10, right: 20, bottom: 30, left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} interval={0} angle={-25} textAnchor="end" height={50} />
|
||||
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}만`} />
|
||||
<Tooltip
|
||||
formatter={(v) => `₩${fmt(Number(v))}`}
|
||||
labelFormatter={(_, payload) => (payload?.[0]?.payload as { fullName: string })?.fullName ?? ""}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="원가" fill="#f59e0b" />
|
||||
<Bar dataKey="마진" fill="#10b981" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[700px]">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">품목</th>
|
||||
@@ -78,3 +152,18 @@ export default function MarginPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ label, value, color }: { label: string; value: string; color: "amber" | "blue" | "violet" | "emerald" }) {
|
||||
const cls = {
|
||||
amber: "bg-amber-50 border-amber-200 text-amber-900",
|
||||
blue: "bg-blue-50 border-blue-200 text-blue-900",
|
||||
violet: "bg-violet-50 border-violet-200 text-violet-900",
|
||||
emerald: "bg-emerald-50 border-emerald-200 text-emerald-900",
|
||||
}[color];
|
||||
return (
|
||||
<div className={`rounded-xl border ${cls} p-4 sm:p-5`}>
|
||||
<div className="text-xs font-semibold opacity-80 mb-1">{label}</div>
|
||||
<div className="text-lg sm:text-2xl font-bold tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Download } from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, Legend, CartesianGrid, Cell,
|
||||
} from "recharts";
|
||||
import { downloadXlsx } from "@/lib/xlsx-export";
|
||||
|
||||
interface MonthlyRow {
|
||||
COMPANY_NAME: string;
|
||||
TAX_FREE: number;
|
||||
TAXABLE: number;
|
||||
TOTAL: number;
|
||||
}
|
||||
|
||||
interface MonthlyRow { COMPANY_NAME: string; TAX_FREE: number; TAXABLE: number; TOTAL: number }
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const COLORS = ["#10b981", "#0ea5e9", "#8b5cf6", "#f59e0b", "#ef4444", "#14b8a6", "#6366f1", "#ec4899", "#84cc16"];
|
||||
|
||||
export default function StatisticsPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [month, setMonth] = useState(new Date().getMonth() + 1);
|
||||
const [rows, setRows] = useState<MonthlyRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/statistics/monthly", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year, month }),
|
||||
});
|
||||
setRows((await res.json()).RESULTLIST ?? []);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/m/statistics/monthly", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year, month }),
|
||||
});
|
||||
setRows((await res.json()).RESULTLIST ?? []);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line
|
||||
|
||||
@@ -23,10 +39,46 @@ export default function StatisticsPage() {
|
||||
const grandFree = rows.reduce((a, r) => a + Number(r.TAX_FREE), 0);
|
||||
const grandTaxable = rows.reduce((a, r) => a + Number(r.TAXABLE), 0);
|
||||
|
||||
const chartData = rows
|
||||
.map((r) => ({
|
||||
name: r.COMPANY_NAME?.length > 8 ? r.COMPANY_NAME.slice(0, 8) + "…" : r.COMPANY_NAME,
|
||||
fullName: r.COMPANY_NAME,
|
||||
면세: Number(r.TAX_FREE),
|
||||
과세: Number(r.TAXABLE),
|
||||
합계: Number(r.TOTAL),
|
||||
}))
|
||||
.sort((a, b) => b.합계 - a.합계)
|
||||
.slice(0, 15);
|
||||
|
||||
const onExport = () => {
|
||||
if (rows.length === 0) return;
|
||||
downloadXlsx(
|
||||
`업체별매출_${year}년${month}월`,
|
||||
rows,
|
||||
[
|
||||
{ header: "업체명", key: "COMPANY_NAME", width: 24 },
|
||||
{ header: "면세 합계", key: (r) => Number(r.TAX_FREE), width: 15 },
|
||||
{ header: "과세 공급가", key: (r) => Number(r.TAXABLE), width: 15 },
|
||||
{ header: "총 매출", key: (r) => Number(r.TOTAL), width: 15 },
|
||||
],
|
||||
`${year}_${month}`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">통계 — 업체별 월간 매출</h1>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-xl sm:text-2xl font-bold">통계 — 업체별 월간 매출</h1>
|
||||
<button
|
||||
onClick={onExport}
|
||||
disabled={rows.length === 0}
|
||||
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-lg bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50"
|
||||
>
|
||||
<Download size={14} /> 엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<select value={year} onChange={(e) => setYear(Number(e.target.value))} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => <option key={y} value={y}>{y}년</option>)}
|
||||
</select>
|
||||
@@ -36,14 +88,44 @@ export default function StatisticsPage() {
|
||||
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<Card label="면세 합계" value={fmt(grandFree)} color="violet" />
|
||||
<Card label="과세 공급가" value={fmt(grandTaxable)} color="rose" />
|
||||
<Card label="총 매출 (VAT포함)" value={fmt(grandTotal)} color="emerald" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
{/* 차트 */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<h3 className="font-bold text-slate-700 mb-3 text-sm">업체별 매출 (TOP 15)</h3>
|
||||
<div className="w-full h-72 sm:h-80">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">불러오는 중...</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">데이터가 없습니다.</div>
|
||||
) : (
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={chartData} margin={{ top: 10, right: 10, bottom: 30, left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} interval={0} angle={-25} textAnchor="end" height={50} />
|
||||
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}만`} />
|
||||
<Tooltip
|
||||
formatter={(v) => `₩${fmt(Number(v))}`}
|
||||
labelFormatter={(_, payload) => (payload?.[0]?.payload as { fullName: string })?.fullName ?? ""}
|
||||
cursor={{ fill: "rgba(16, 185, 129, 0.05)" }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="면세" stackId="a" fill="#8b5cf6" />
|
||||
<Bar dataKey="과세" stackId="a" fill="#f43f5e">
|
||||
{chartData.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} fillOpacity={0.7} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[600px]">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">업체명</th>
|
||||
@@ -77,9 +159,9 @@ function Card({ label, value, color }: { label: string; value: string; color: "v
|
||||
emerald: "from-emerald-50 to-emerald-100 text-emerald-800 border-emerald-200",
|
||||
}[color];
|
||||
return (
|
||||
<div className={`rounded-xl border bg-gradient-to-br ${cls} p-5`}>
|
||||
<div className={`rounded-xl border bg-gradient-to-br ${cls} p-4 sm:p-5`}>
|
||||
<div className="text-xs font-semibold opacity-80 mb-1">{label}</div>
|
||||
<div className="text-2xl font-bold tabular-nums">₩{value}</div>
|
||||
<div className="text-xl sm:text-2xl font-bold tabular-nums">₩{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Download, FileText } from "lucide-react";
|
||||
import { Download } from "lucide-react";
|
||||
import { downloadXlsx } from "@/lib/xlsx-export";
|
||||
|
||||
interface Order {
|
||||
OBJID: string;
|
||||
@@ -44,19 +45,44 @@ export default function MyOrdersPage() {
|
||||
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const onExport = () => {
|
||||
if (orders.length === 0) return;
|
||||
downloadXlsx(
|
||||
"발주이력",
|
||||
orders,
|
||||
[
|
||||
{ header: "발주번호", key: "ORDER_NO", width: 18 },
|
||||
{ header: "발주일", key: "ORDER_DATE", width: 12 },
|
||||
{ header: "면세", key: (r) => Number(r.TOTAL_TAXFREE), width: 14 },
|
||||
{ header: "과세", key: (r) => Number(r.TOTAL_TAXABLE), width: 14 },
|
||||
{ header: "합계", key: (r) => Number(r.TOTAL_AMOUNT), width: 14 },
|
||||
{ header: "상태", key: (r) => STATUS_LABEL[String(r.STATUS)] || String(r.STATUS), width: 10 },
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">내 발주 이력</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">전체 {orders.length}건</p>
|
||||
<h1 className="text-xl sm:text-2xl font-bold">내 발주 이력</h1>
|
||||
<p className="text-xs sm:text-sm text-slate-500 mt-1">전체 {orders.length}건</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onExport}
|
||||
disabled={orders.length === 0}
|
||||
className="inline-flex items-center gap-1.5 h-10 px-3 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold hover:bg-emerald-800 disabled:opacity-50"
|
||||
>
|
||||
<Download size={14} /> 엑셀
|
||||
</button>
|
||||
<Link href="/m/orders/new" className="px-3 sm:px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold">
|
||||
새 발주
|
||||
</Link>
|
||||
</div>
|
||||
<Link href="/m/orders/new" className="px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-sm font-bold">
|
||||
새 발주 요청
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
|
||||
<option value="">전체 상태</option>
|
||||
{Object.entries(STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
@@ -64,8 +90,8 @@ export default function MyOrdersPage() {
|
||||
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[700px]">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-semibold">발주번호</th>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// 엑셀 다운로드 헬퍼 (xlsx)
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
export interface XlsxColumn<T> {
|
||||
header: string;
|
||||
key: keyof T | ((row: T) => string | number);
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function downloadXlsx<T>(
|
||||
filename: string,
|
||||
rows: T[],
|
||||
columns: XlsxColumn<T>[],
|
||||
sheetName = "Sheet1"
|
||||
): void {
|
||||
const data = rows.map((r) => {
|
||||
const o: Record<string, string | number> = {};
|
||||
for (const c of columns) {
|
||||
const v = typeof c.key === "function"
|
||||
? c.key(r)
|
||||
: ((r as Record<string, unknown>)[c.key as string] as string | number);
|
||||
o[c.header] = v ?? "";
|
||||
}
|
||||
return o;
|
||||
});
|
||||
const ws = XLSX.utils.json_to_sheet(data);
|
||||
if (columns.some((c) => c.width)) {
|
||||
ws["!cols"] = columns.map((c) => ({ wch: c.width ?? 14 }));
|
||||
}
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, sheetName);
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 13);
|
||||
XLSX.writeFile(wb, `${filename}_${stamp}.xlsx`);
|
||||
}
|
||||
Reference in New Issue
Block a user