diff --git a/src/app/(main)/m/admin/statistics/daily/page.tsx b/src/app/(main)/m/admin/statistics/daily/page.tsx index e6ddce6..4c6c8a0 100644 --- a/src/app/(main)/m/admin/statistics/daily/page.tsx +++ b/src/app/(main)/m/admin/statistics/daily/page.tsx @@ -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([]); + 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 (
-

통계 — 일자별 매출

+
+

통계 — 일자별 매출

+ +
+
- setRange([e.target.value, to])} className="h-10 px-3 rounded-lg border border-slate-200" /> - setRange([from, e.target.value])} className="h-10 px-3 rounded-lg border border-slate-200" /> + setRange([e.target.value, to])} className="h-10 px-3 rounded-lg border border-slate-200 text-sm" /> + setRange([from, e.target.value])} className="h-10 px-3 rounded-lg border border-slate-200 text-sm" />
-
-
면세 합계
₩{fmt(totalFree)}
-
과세 공급가
₩{fmt(totalTaxable)}
-
총 매출 (VAT)
₩{fmt(total)}
+
+ + + +
-
-

일별 매출 그래프

-
- {rows.length === 0 ?
데이터가 없습니다.
: rows.map((r, i) => ( -
-
-
{r.DAY.slice(5)}
-
- ))} +
+

일별 매출 추이

+
+ {loading ? ( +
불러오는 중...
+ ) : chartData.length === 0 ? ( +
데이터가 없습니다.
+ ) : ( + + + + + `${(v / 10000).toFixed(0)}만`} /> + + name === "건수" ? `${Number(v)}건` : `₩${fmt(Number(v))}`} + /> + + + + + + + )}
-
- +
+
- + + + + + + + - {rows.map((r) => ( + {rows.length === 0 ? ( + + ) : rows.map((r) => ( @@ -77,3 +144,18 @@ export default function DailyStatsPage() { ); } + +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 ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/app/(main)/m/admin/statistics/margin/page.tsx b/src/app/(main)/m/admin/statistics/margin/page.tsx index f3044a7..ca7cbbc 100644 --- a/src/app/(main)/m/admin/statistics/margin/page.tsx +++ b/src/app/(main)/m/admin/statistics/margin/page.tsx @@ -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([]); + 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 (
-

통계 — 원가 / 마진 (월간 품목별)

-
+
+

통계 — 원가 / 마진

+ +
+ +
@@ -37,15 +85,41 @@ export default function MarginPage() {
-
-
매출(공급가)
₩{fmt(totalRev)}
-
매입원가
₩{fmt(totalCost)}
-
마진
₩{fmt(totalMargin)}
-
마진율
{marginPct}%
+
+ + + +
-
-
일자건수면세과세합계
일자건수면세과세합계
선택한 기간의 매출 데이터가 없습니다.
{r.DAY} {r.ORDER_CNT}건
+
+

마진 TOP 10 품목

+
+ {loading ? ( +
불러오는 중...
+ ) : chartData.length === 0 ? ( +
데이터가 없습니다.
+ ) : ( + + + + + `${(v / 10000).toFixed(0)}만`} /> + `₩${fmt(Number(v))}`} + labelFormatter={(_, payload) => (payload?.[0]?.payload as { fullName: string })?.fullName ?? ""} + /> + + + + + + )} +
+
+ +
+
@@ -78,3 +152,18 @@ export default function MarginPage() { ); } + +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 ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/app/(main)/m/admin/statistics/page.tsx b/src/app/(main)/m/admin/statistics/page.tsx index 8a90e31..fc293a8 100644 --- a/src/app/(main)/m/admin/statistics/page.tsx +++ b/src/app/(main)/m/admin/statistics/page.tsx @@ -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([]); + 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 (
-

통계 — 업체별 월간 매출

-
+
+

통계 — 업체별 월간 매출

+ +
+ +
@@ -36,14 +88,44 @@ export default function StatisticsPage() {
-
+
-
-
품목
+ {/* 차트 */} +
+

업체별 매출 (TOP 15)

+
+ {loading ? ( +
불러오는 중...
+ ) : chartData.length === 0 ? ( +
데이터가 없습니다.
+ ) : ( + + + + + `${(v / 10000).toFixed(0)}만`} /> + `₩${fmt(Number(v))}`} + labelFormatter={(_, payload) => (payload?.[0]?.payload as { fullName: string })?.fullName ?? ""} + cursor={{ fill: "rgba(16, 185, 129, 0.05)" }} + /> + + + + {chartData.map((_, i) => )} + + + + )} +
+
+ +
+
@@ -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 ( -
+
{label}
-
₩{value}
+
₩{value}
); } diff --git a/src/app/(main)/m/orders/page.tsx b/src/app/(main)/m/orders/page.tsx index 3240947..bea0049 100644 --- a/src/app/(main)/m/orders/page.tsx +++ b/src/app/(main)/m/orders/page.tsx @@ -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 (
-
+
-

내 발주 이력

-

전체 {orders.length}건

+

내 발주 이력

+

전체 {orders.length}건

+
+
+ + + 새 발주 +
- - 새 발주 요청 -
-
+
업체명
+
+
diff --git a/src/lib/xlsx-export.ts b/src/lib/xlsx-export.ts new file mode 100644 index 0000000..1204ff9 --- /dev/null +++ b/src/lib/xlsx-export.ts @@ -0,0 +1,34 @@ +// 엑셀 다운로드 헬퍼 (xlsx) +import * as XLSX from "xlsx"; + +export interface XlsxColumn { + header: string; + key: keyof T | ((row: T) => string | number); + width?: number; +} + +export function downloadXlsx( + filename: string, + rows: T[], + columns: XlsxColumn[], + sheetName = "Sheet1" +): void { + const data = rows.map((r) => { + const o: Record = {}; + for (const c of columns) { + const v = typeof c.key === "function" + ? c.key(r) + : ((r as Record)[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`); +}
발주번호