feat(momo v0.4): 통계 그래프(recharts) + 엑셀 다운로드(xlsx) + 모바일 반응형 정리
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:
chpark
2026-05-02 00:37:20 +09:00
parent 1f9b017617
commit 3d5b020456
5 changed files with 376 additions and 63 deletions
+108 -26
View File
@@ -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>
);
}
+103 -14
View File
@@ -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>
);
}
+95 -13
View File
@@ -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>
);
}
+36 -10
View File
@@ -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>
+34
View File
@@ -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`);
}