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)}
))}
)}
{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)}%` : "—"}
))}
>
)}
)}
);
}