import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { DateRange } from "react-day-picker"; import { Search, Download, Printer, Calendar as CalendarIcon, BarChart3, LineChart, ArrowUpRight, RefreshCw, FileText } from "lucide-react"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../ui/card"; import { Calendar } from "../ui/calendar"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, LineChart as RechartsLineChart } from "recharts"; import { toast } from "sonner"; import { cn } from "../ui/utils"; import { skipCountForPage } from "../../lib/paginationQuery"; import { getLocations } from "../../services/locationService"; import { getPartners } from "../../services/partnerService"; import { getGroups } from "../../services/groupService"; import { exportPrintLogExcel, getLabelReport, getReportsPrintLogList, } from "../../services/reportsService"; import type { LocationDto } from "../../types/location"; import type { PartnerListItem } from "../../types/partner"; import type { GroupListItem } from "../../types/group"; import type { LabelReportData, ReportsPrintLogListItem } from "../../types/reports"; import { ApiError } from "../../lib/apiClient"; import { BACKEND_EMPTY_DISPLAY } from "../../lib/emptyDisplay"; import { useCategoryScopeAuth } from "../../hooks/useCategoryScopeAuth"; import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "../ui/pagination"; const ALL = "all" as const; function defaultDateRange(): { start: string; end: string } { const end = new Date(); const start = new Date(end); start.setDate(start.getDate() - 29); return { start: start.toISOString().slice(0, 10), end: end.toISOString().slice(0, 10), }; } function parseIsoDate(value: string): Date | undefined { const s = (value ?? "").trim(); if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return undefined; const d = new Date(`${s}T00:00:00`); if (Number.isNaN(d.getTime())) return undefined; return d; } function formatIsoDate(value: Date): string { const y = value.getFullYear(); const m = String(value.getMonth() + 1).padStart(2, "0"); const d = String(value.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } function formatPrintedAt(raw: string): string { const s = (raw ?? "").trim(); if (!s) return "None"; const d = new Date(s); if (Number.isNaN(d.getTime())) return s; return d.toLocaleString("en-US", { year: "numeric", month: "2-digit", day: "2-digit", hour: "numeric", minute: "2-digit", second: "2-digit", hour12: true, }); } function toDisplay(v: string | null | undefined): string { const s = (v ?? "").trim(); if (!s || s === BACKEND_EMPTY_DISPLAY) return "None"; return s; } function formatPct(n: number): string { if (!Number.isFinite(n)) return "0%"; const sign = n > 0 ? "+" : ""; return `${sign}${n.toFixed(1)}%`; } function formatTrendLabel(iso: string): string { const s = (iso ?? "").trim(); if (!s) return ""; const d = new Date(s); if (Number.isNaN(d.getTime())) return s.length > 10 ? s.slice(0, 10) : s; return d.toLocaleDateString("en-US", { month: "numeric", day: "numeric" }); } /** Reports 筛选:单弹层 + range 日历,避免双日历布局问题 */ function PeriodRangePicker({ startDate, endDate, onRangeChange, }: { startDate: string; endDate: string; onRangeChange: (start: string, end: string) => void; }) { const [open, setOpen] = useState(false); const selectedRange: DateRange | undefined = useMemo(() => { const from = parseIsoDate(startDate); const to = parseIsoDate(endDate); if (from && to) return { from, to }; if (from) return { from, to: undefined }; return undefined; }, [startDate, endDate]); const label = `${startDate || "YYYY-MM-DD"} — ${endDate || "YYYY-MM-DD"}`; return (
Period Search: { if (!range?.from) return; const s = formatIsoDate(range.from); const e = range.to ? formatIsoDate(range.to) : s; onRangeChange(s, e); if (range.from && range.to) setOpen(false); }} initialFocus />
); } /** Print Log 列表行统一字体 */ const PRINT_LOG_CELL = "border-r text-sm font-normal font-sans text-gray-900"; const PRINT_LOG_HEAD = "text-sm font-semibold font-sans text-gray-900 border-r"; function templateCell(template: string) { const t = (template ?? "").trim(); if (!t) return "None"; if (t.endsWith(" !!!")) { return ( <> {t.slice(0, -4)} !!! ); } return t; } const EMPTY_LABEL_REPORT: LabelReportData = { summary: { totalLabelsPrinted: 0, totalLabelsPrintedPrevPeriod: 0, totalLabelsPrintedChangeRate: 0, hottestCategoryName: "None", hottestCategoryCount: 0, topProductName: "None", topProductCount: 0, avgDailyPrints: 0, avgDailyPrintsChangeRate: 0, }, labelsByCategory: [], printVolumeTrend: [], mostUsedProducts: [], }; export type ReportsViewProps = { layoutReportsOpenKey?: number; layoutReportsTargetTab?: "print-log" | "label-report"; }; export function ReportsView({ layoutReportsOpenKey = 0, layoutReportsTargetTab = "label-report", }: ReportsViewProps = {}) { const scopeAuth = useCategoryScopeAuth(); const [activeTab, setActiveTab] = useState<"print-log" | "label-report">("print-log"); const lastAppliedLayoutKeyRef = useRef(0); const [partners, setPartners] = useState([]); const [groups, setGroups] = useState([]); const [locations, setLocations] = useState([]); const [partnerId, setPartnerId] = useState(ALL); const [groupId, setGroupId] = useState(ALL); const [locationId, setLocationId] = useState(ALL); const { start: defaultStart, end: defaultEnd } = defaultDateRange(); const [startDate, setStartDate] = useState(defaultStart); const [endDate, setEndDate] = useState(defaultEnd); const [keyword, setKeyword] = useState(""); const [debouncedKeyword, setDebouncedKeyword] = useState(""); const keywordTimerRef = useRef(null); const [printRows, setPrintRows] = useState([]); const [printTotal, setPrintTotal] = useState(0); const [printLoading, setPrintLoading] = useState(false); const [pageIndex, setPageIndex] = useState(1); const [pageSize] = useState(10); const [labelData, setLabelData] = useState(null); const [labelLoading, setLabelLoading] = useState(false); const [exporting, setExporting] = useState(false); const [filterMetaLoading, setFilterMetaLoading] = useState(true); const printAbortRef = useRef(null); const labelAbortRef = useRef(null); useEffect(() => { if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); keywordTimerRef.current = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300); return () => { if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); }; }, [keyword]); useEffect(() => { if (layoutReportsOpenKey > 0 && layoutReportsOpenKey > lastAppliedLayoutKeyRef.current) { lastAppliedLayoutKeyRef.current = layoutReportsOpenKey; setActiveTab(layoutReportsTargetTab); } }, [layoutReportsOpenKey, layoutReportsTargetTab]); const totalPages = Math.max(1, Math.ceil(printTotal / pageSize)); const effectivePartnerId = useMemo(() => { if (scopeAuth.requireCompanySelection) return partnerId; const fixed = scopeAuth.fixedPartnerId.trim(); return fixed || partnerId; }, [scopeAuth.requireCompanySelection, scopeAuth.fixedPartnerId, partnerId]); const partnerNameForQuery = useCallback((): string | undefined => { if (effectivePartnerId === ALL) return undefined; const p = partners.find((x) => x.id === effectivePartnerId); return p?.partnerName?.trim() || undefined; }, [effectivePartnerId, partners]); const groupNameForQuery = useCallback((): string | undefined => { if (groupId === ALL) return undefined; const g = groups.find((x) => x.id === groupId); return g?.groupName?.trim() || undefined; }, [groupId, groups]); const buildReportFilters = useCallback( () => ({ partnerId: effectivePartnerId === ALL ? undefined : effectivePartnerId, groupId: groupId === ALL ? undefined : groupId, locationId: locationId === ALL ? undefined : locationId, startDate: startDate.trim() || undefined, endDate: endDate.trim() || undefined, keyword: debouncedKeyword || undefined, }), [debouncedKeyword, effectivePartnerId, endDate, groupId, locationId, startDate] ); useEffect(() => { setPageIndex(1); }, [debouncedKeyword, partnerId, groupId, locationId, startDate, endDate, activeTab, pageSize]); useEffect(() => { if (scopeAuth.requireCompanySelection) return; const pid = scopeAuth.fixedPartnerId.trim(); if (pid) setPartnerId(pid); }, [scopeAuth.requireCompanySelection, scopeAuth.fixedPartnerId]); useEffect(() => { let cancel = false; const run = async () => { setFilterMetaLoading(true); try { const tasks: [Promise, Promise] = [ (async () => { if (!scopeAuth.requireCompanySelection) return; const pRes = await getPartners({ skipCount: 1, maxResultCount: 500, state: true }); if (cancel) return; setPartners(pRes.items ?? []); })(), (async () => { const gRes = await getGroups({ skipCount: 1, maxResultCount: 500, state: true }); if (cancel) return; setGroups(gRes.items ?? []); })(), ]; await Promise.all(tasks); } catch (e) { if (!cancel) { if (scopeAuth.requireCompanySelection) setPartners([]); setGroups([]); toast.error("Failed to load companies / regions.", { description: e instanceof Error ? e.message : "Please try again.", }); } } finally { if (!cancel) setFilterMetaLoading(false); } }; run(); return () => { cancel = true; }; }, [scopeAuth.requireCompanySelection]); useEffect(() => { let cancel = false; const run = async () => { try { const res = await getLocations({ skipCount: 1, maxResultCount: 2000, partner: partnerNameForQuery(), groupName: groupNameForQuery(), state: true, }); if (cancel) return; setLocations(res.items ?? []); } catch (e) { if (!cancel) { setLocations([]); toast.error("Failed to load locations.", { description: e instanceof Error ? e.message : "Please try again.", }); } } }; run(); return () => { cancel = true; }; }, [groupNameForQuery, partnerNameForQuery, effectivePartnerId, groupId]); useEffect(() => { if (activeTab !== "print-log") return; const run = async () => { printAbortRef.current?.abort(); const ac = new AbortController(); printAbortRef.current = ac; setPrintLoading(true); const f = buildReportFilters(); try { const res = await getReportsPrintLogList( { skipCount: skipCountForPage(pageIndex), maxResultCount: pageSize, sorting: "PrintedAt desc", ...f, }, ac.signal ); setPrintRows(res.items ?? []); setPrintTotal(res.totalCount ?? 0); } catch (e) { if (e instanceof Error && e.name === "AbortError") return; toast.error("Failed to load print log.", { description: e instanceof Error ? e.message : "Please try again.", }); setPrintRows([]); setPrintTotal(0); } finally { if (!ac.signal.aborted) setPrintLoading(false); } }; void run(); return () => printAbortRef.current?.abort(); }, [activeTab, buildReportFilters, pageIndex, pageSize]); useEffect(() => { if (activeTab !== "label-report") return; const run = async () => { labelAbortRef.current?.abort(); const ac = new AbortController(); labelAbortRef.current = ac; setLabelLoading(true); const f = buildReportFilters(); try { const data = await getLabelReport(f, ac.signal); setLabelData(data); } catch (e) { if (e instanceof Error && e.name === "AbortError") return; toast.error("Failed to load label report.", { description: e instanceof Error ? e.message : "Please try again.", }); setLabelData(EMPTY_LABEL_REPORT); } finally { if (!ac.signal.aborted) setLabelLoading(false); } }; void run(); return () => labelAbortRef.current?.abort(); }, [activeTab, buildReportFilters]); const onPartnerChange = (v: string) => { setPartnerId(v); setGroupId(ALL); setLocationId(ALL); }; const onGroupChange = (v: string) => { setGroupId(v); setLocationId(ALL); }; const handleExport = async () => { const f = buildReportFilters(); setExporting(true); try { await exportPrintLogExcel({ ...f, skipCount: 1, maxResultCount: 10, sorting: "PrintedAt desc", }); toast.success("Export ready", { description: "The Excel download should start shortly." }); } catch (e) { const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Please try again."; toast.error("Export failed", { description: msg }); } finally { setExporting(false); } }; const report = labelData ?? EMPTY_LABEL_REPORT; const trendData = (report.printVolumeTrend ?? []).map((p) => ({ date: formatTrendLabel(p.date), count: p.count, })); const categoryData = (report.labelsByCategory ?? []).map((c) => ({ name: c.name, count: c.count })); return (
{scopeAuth.requireCompanySelection ? ( ) : null} { setStartDate(start); setEndDate(end); }} />
setKeyword(e.target.value)} placeholder="Search Product or Category..." className="flex-1 min-w-0 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 py-2 px-2 h-full placeholder:text-gray-500" />
{activeTab === "print-log" && ( )}
{activeTab === "print-log" && (
{printLoading && (
Loading print log…
)} {!printLoading && ( Label ID Product Name Product Category Label Category Template Printed at Printed by Location Expiration Action {printRows.length === 0 && ( No print records )} {printRows.map((log) => ( {toDisplay(log.labelCode)} {toDisplay(log.productName)} {toDisplay(log.productCategoryName)} {toDisplay(log.labelCategoryName)} {templateCell(log.templateText)} {formatPrintedAt(log.printedAt)} {toDisplay(log.printedByName)} {toDisplay(log.locationText)} {toDisplay(log.expiryDateText)}
Reprint
))}
)}
{printTotal === 0 ? "Showing 0 of 0" : `Showing ${(pageIndex - 1) * pageSize + 1}–${Math.min(pageIndex * pageSize, printTotal)} of ${printTotal}`} pageIndex > 1 && setPageIndex((p) => Math.max(1, p - 1))} aria-disabled={pageIndex <= 1} /> e.preventDefault()} > Page {pageIndex} / {totalPages} = totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} onClick={() => pageIndex < totalPages && setPageIndex((p) => Math.min(totalPages, p + 1))} aria-disabled={pageIndex >= totalPages} />
)} {activeTab === "label-report" && (
{labelLoading &&
Loading label report…
} {!labelLoading && ( <>
Total Labels Printed
{report.summary.totalLabelsPrinted.toLocaleString("en-US")}

{formatPct(report.summary.totalLabelsPrintedChangeRate)} vs previous period

Most Printed Category
{toDisplay(report.summary.hottestCategoryName)}

{report.summary.hottestCategoryCount.toLocaleString("en-US")} label(s) in range

Top Product
{toDisplay(report.summary.topProductName)}

{report.summary.topProductCount.toLocaleString("en-US")} label(s) in range

Avg. Daily Prints
{Number.isFinite(report.summary.avgDailyPrints) ? report.summary.avgDailyPrints.toLocaleString("en-US", { maximumFractionDigits: 1 }) : "0"}

{formatPct(report.summary.avgDailyPrintsChangeRate)} vs previous period

Labels by Category Distribution of printed labels by label category in the selected range. {categoryData.length === 0 ? (
No data
) : ( `${v}`} /> )}
Print Volume Trends Daily print volume in the current filter window (up to 7 days). {trendData.length === 0 ? (
No data
) : ( )}
Most Used Products Product Name Category Total Printed Usage % {report.mostUsedProducts.length === 0 && ( No data )} {report.mostUsedProducts.map((row, i) => ( {toDisplay(row.productName)} {toDisplay(row.categoryName)} {row.totalPrinted.toLocaleString("en-US")} {Number.isFinite(row.usagePercent) ? `${row.usagePercent.toFixed(1)}%` : "—"} ))}
)}
)}
); }