From eeb1630391faa5f09213249855498ad0f42c6c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20And=C4=9Bl?= Date: Wed, 12 Aug 2026 13:53:36 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20export=20historie=20j=C3=ADdel=20u?= =?UTF-8?q?=C5=BEivatele?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- client/src/pages/StatsPage.scss | 41 +++ client/src/pages/StatsPage.tsx | 89 ++++- server/changelogs/2026-08-12.json | 3 + server/package.json | 1 + server/src/routes/statsRoutes.ts | 24 ++ server/src/tests/statsRoutes.test.ts | 58 ++++ server/src/tests/userExport.test.ts | 310 +++++++++++++++++ server/src/userExport.ts | 385 +++++++++++++++++++++ server/yarn.lock | 495 ++++++++++++++++++++++++++- types/api.yml | 2 + types/paths/stats/export.yml | 44 +++ types/schemas/_index.yml | 66 ++++ 13 files changed, 1513 insertions(+), 8 deletions(-) create mode 100644 server/changelogs/2026-08-12.json create mode 100644 server/src/tests/userExport.test.ts create mode 100644 server/src/userExport.ts create mode 100644 types/paths/stats/export.yml diff --git a/.gitignore b/.gitignore index 0f5f27f..a110901 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ types/gen server/public/ .claude/*.lock .claude/worktrees -.playwright-mcp \ No newline at end of file +.playwright-mcp +.idea/ diff --git a/client/src/pages/StatsPage.scss b/client/src/pages/StatsPage.scss index 7b6c82b..6ff6d6f 100644 --- a/client/src/pages/StatsPage.scss +++ b/client/src/pages/StatsPage.scss @@ -46,6 +46,47 @@ } } + .export-panel { + margin-top: 32px; + padding: 24px; + width: 100%; + max-width: 560px; + background: var(--luncher-bg-card); + border: 1px solid var(--luncher-border-light); + border-radius: var(--luncher-radius-lg); + box-shadow: var(--luncher-shadow-sm); + text-align: center; + + h3 { + font-size: 1.1rem; + font-weight: 600; + color: var(--luncher-text); + margin-bottom: 8px; + } + + .export-hint { + font-size: 0.85rem; + color: var(--luncher-text-secondary); + margin-bottom: 16px; + } + + .export-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 12px; + + input { + max-width: 180px; + } + + select { + max-width: 160px; + } + } + } + // Chart container .recharts-wrapper { background: var(--luncher-bg-card); diff --git a/client/src/pages/StatsPage.tsx b/client/src/pages/StatsPage.tsx index 9e938d7..a016f59 100644 --- a/client/src/pages/StatsPage.tsx +++ b/client/src/pages/StatsPage.tsx @@ -4,9 +4,10 @@ import Header from "../components/Header"; import { useAuth } from "../context/auth"; import Login from "../Login"; import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils"; -import { WeeklyStats, LunchChoice, getStats } from "../../../types"; +import { WeeklyStats, LunchChoice, getStats, getStatsExport } from "../../../types"; import Loader from "../components/Loader"; -import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons"; +import { faChevronLeft, faChevronRight, faGear, faFileExcel, faFileCsv, faFileCode } from "@fortawesome/free-solid-svg-icons"; +import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { getLunchChoiceName } from "../enums"; @@ -28,10 +29,35 @@ const COLORS = [ '#7c7c7c', ] +/** Podporované formáty exportu přehledu (viz GET /api/stats/export). */ +const EXPORT_FORMATS = ['xlsx', 'csv', 'json'] as const; + +type ExportFormat = typeof EXPORT_FORMATS[number]; + +const EXPORT_FORMAT_LABELS: Record = { + xlsx: 'Excel (.xlsx)', + csv: 'CSV (.csv)', + json: 'JSON (.json)', +}; + +const EXPORT_FORMAT_ICONS: Record = { + xlsx: faFileExcel, + csv: faFileCsv, + json: faFileCode, +}; + +/** Vrátí měsíc předaného data ve formátu YYYY-MM (hodnota pro input type="month"). */ +function getMonthValue(date: Date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; +} + export default function StatsPage() { const auth = useAuth(); const [dateRange, setDateRange] = useState(); const [data, setData] = useState(); + const [exportMonth, setExportMonth] = useState(() => getMonthValue(new Date())); + const [exportFormat, setExportFormat] = useState('xlsx'); + const [exporting, setExporting] = useState(false); // Prvotní nastavení aktuálního týdne useEffect(() => { @@ -53,6 +79,31 @@ export default function StatsPage() { return data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} /> } + /** Stáhne přehled stravování přihlášeného uživatele za vybraný měsíc ve vybraném formátu. */ + const handleExport = async () => { + const [year, month] = exportMonth.split('-').map(Number); + if (!year || !month) { + return; + } + setExporting(true); + try { + // parseAs 'blob' — odpověď chceme stáhnout tak, jak přišla, bez parsování dle typu + const { data: file } = await getStatsExport({ query: { year, month, format: exportFormat }, parseAs: 'blob' }); + // Chyby řeší globální interceptor (toaster), stahujeme jen při úspěchu + if (!(file instanceof Blob)) { + return; + } + const url = URL.createObjectURL(file); + const link = document.createElement('a'); + link.href = url; + link.download = `luncher-${auth?.login ?? 'prehled'}-${exportMonth}.${exportFormat}`; + link.click(); + URL.revokeObjectURL(url); + } finally { + setExporting(false); + } + } + const handlePreviousWeek = () => { if (dateRange) { const previousStartDate = new Date(dateRange[0]); @@ -128,6 +179,40 @@ export default function StatsPage() { +
+

Můj přehled

+

+ Přehled vašich záznamů za vybraný měsíc — datum, kde jste jedli, vybrané jídlo, poznámka + a u objednávek i částka po slevě. +

+
+ setExportMonth(e.target.value)} + /> + + +
+