feat: export historie jídel uživatele
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m53s
CI / Build and push Docker image (push) Successful in 1m1s
CI / Notify (push) Successful in 3s
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m53s
CI / Build and push Docker image (push) Successful in 1m1s
CI / Notify (push) Successful in 3s
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<ExportFormat, string> = {
|
||||
xlsx: 'Excel (.xlsx)',
|
||||
csv: 'CSV (.csv)',
|
||||
json: 'JSON (.json)',
|
||||
};
|
||||
|
||||
const EXPORT_FORMAT_ICONS: Record<ExportFormat, IconDefinition> = {
|
||||
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<Date[]>();
|
||||
const [data, setData] = useState<WeeklyStats>();
|
||||
const [exportMonth, setExportMonth] = useState<string>(() => getMonthValue(new Date()));
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormat>('xlsx');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
// Prvotní nastavení aktuálního týdne
|
||||
useEffect(() => {
|
||||
@@ -53,6 +79,31 @@ export default function StatsPage() {
|
||||
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => 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() {
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</LineChart>
|
||||
<div className="export-panel">
|
||||
<h3>Můj přehled</h3>
|
||||
<p className="export-hint">
|
||||
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ě.
|
||||
</p>
|
||||
<div className="export-controls">
|
||||
<input
|
||||
type="month"
|
||||
className="form-control"
|
||||
value={exportMonth}
|
||||
max={getMonthValue(new Date())}
|
||||
onChange={e => setExportMonth(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="form-select"
|
||||
value={exportFormat}
|
||||
onChange={e => setExportFormat(e.target.value as ExportFormat)}
|
||||
>
|
||||
{EXPORT_FORMATS.map(format => (
|
||||
<option key={format} value={format}>{EXPORT_FORMAT_LABELS[format]}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={exporting || !exportMonth}
|
||||
onClick={handleExport}
|
||||
>
|
||||
<FontAwesomeIcon icon={EXPORT_FORMAT_ICONS[exportFormat]} />{' '}
|
||||
{exporting ? 'Generuji...' : 'Stáhnout přehled'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user