Files
Red-Bull-Tracker/src/components/ImportPreviewModal.tsx
T
nh9961 767967f25d fix: unify day boundaries on device-local time, JSON import preview parity
- Limits/streaks/greeting now use device-local days (was hardcoded Europe/London),
  matching dashboard metrics; label copy updated
- JSON imports now go through the same preview/duplicate-detection modal as Excel
2026-07-19 21:12:02 +00:00

139 lines
6.5 KiB
TypeScript

import { motion } from "framer-motion";
import { FileSpreadsheet, Loader2, X } from "lucide-react";
import { humanDateTime } from "../lib/metrics";
import { MODAL_TRANSITION } from "../lib/motion";
import { MiniMetric } from "./MetricTiles";
import { Modal } from "./Modal";
import type { ImportPreview, ImportPreviewRow, ImportProgress } from "../types";
type ImportPreviewModalProps = {
busy: boolean;
preview: ImportPreview | null;
importProgress: ImportProgress | null;
onClose: () => void;
onConfirm: () => void;
};
export function ImportPreviewModal({
busy,
preview,
importProgress,
onClose,
onConfirm,
}: ImportPreviewModalProps) {
const validRows = preview?.rows.filter((row) => row.entry && !row.errors.length && !row.duplicate) ?? [];
const invalidRows = preview?.rows.filter((row) => row.errors.length) ?? [];
const duplicateRows = preview?.rows.filter((row) => row.duplicate) ?? [];
return (
<Modal open={Boolean(preview)} onClose={onClose} labelledBy="import-preview-title">
{preview && (
<motion.div
className="modal-panel max-w-5xl"
initial={{ opacity: 0, y: 18, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 14, scale: 0.98 }}
transition={MODAL_TRANSITION}
>
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium uppercase tracking-[0.18em] text-pink-100">
{preview.fileName.toLowerCase().endsWith(".json") ? "JSON import" : "Excel import"}
</p>
<h2 id="import-preview-title" className="mt-1 text-3xl font-semibold tracking-tight text-white">
Preview rows
</h2>
<p className="mt-2 text-sm text-slate-400">{preview.fileName}</p>
</div>
<button className="icon-button" type="button" onClick={onClose} aria-label="Close import preview">
<X size={18} aria-hidden="true" />
</button>
</div>
<div className="mb-4 grid gap-3 sm:grid-cols-3">
<MiniMetric label="Ready" value={`${validRows.length}`} accent="#39d5ff" />
<MiniMetric label="Duplicates" value={`${duplicateRows.length}`} accent="#ffd84d" />
<MiniMetric label="Invalid" value={`${invalidRows.length}`} accent="#ff3448" />
</div>
<div className="hidden max-h-[48vh] overflow-auto rounded-lg border border-white/10 sm:block">
<table className="w-full min-w-[760px] border-collapse text-left text-sm">
<thead className="sticky top-0 bg-[#0d142c] text-xs uppercase tracking-[0.14em] text-slate-400">
<tr>
<th className="px-3 py-3">Row</th>
<th className="px-3 py-3">Status</th>
<th className="px-3 py-3">Date</th>
<th className="px-3 py-3">Flavour</th>
<th className="px-3 py-3">Size</th>
<th className="px-3 py-3">Cans</th>
<th className="px-3 py-3">Message</th>
</tr>
</thead>
<tbody>
{preview.rows.map((row) => (
<tr key={row.rowNumber} className="border-t border-white/10">
<td className="px-3 py-3 text-slate-400">{row.rowNumber}</td>
<td className="px-3 py-3">
<ImportStatusBadge invalid={row.errors.length > 0} duplicate={row.duplicate} />
</td>
<td className="px-3 py-3 text-slate-300">{row.entry ? humanDateTime(row.entry.dateTime) : "-"}</td>
<td className="px-3 py-3 text-white">{row.entry?.flavour ?? "-"}</td>
<td className="px-3 py-3 text-slate-300">{row.entry ? `${row.entry.sizeMl}ml` : "-"}</td>
<td className="px-3 py-3 text-slate-300">{row.entry?.cans ?? "-"}</td>
<td className="px-3 py-3 text-slate-400">{rowMessage(row)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="grid max-h-[48vh] gap-2 overflow-auto sm:hidden">
{preview.rows.map((row) => (
<article key={row.rowNumber} className="rounded-lg border border-white/10 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-slate-400">Row {row.rowNumber}</span>
<ImportStatusBadge invalid={row.errors.length > 0} duplicate={row.duplicate} />
</div>
<p className="mt-2 text-sm text-white">{row.entry?.flavour ?? "No entry parsed"}</p>
{row.entry ? (
<p className="mt-1 text-xs text-slate-400">
{humanDateTime(row.entry.dateTime)} · {row.entry.sizeMl}ml · {row.entry.cans} can{row.entry.cans === 1 ? "" : "s"}
</p>
) : null}
<p className="mt-1 text-xs text-slate-400">{rowMessage(row)}</p>
</article>
))}
</div>
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
{busy && importProgress ? (
<p className="text-center text-sm text-slate-400 sm:mr-auto" role="status" aria-live="polite">
Importing {importProgress.done} of {importProgress.total}
</p>
) : null}
<button className="secondary-button justify-center" type="button" onClick={onClose}>
Cancel
</button>
<button className="excel-button justify-center" type="button" disabled={!validRows.length || busy} onClick={onConfirm}>
{busy ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <FileSpreadsheet size={17} aria-hidden="true" />}
Import {validRows.length} row{validRows.length === 1 ? "" : "s"}
</button>
</div>
</motion.div>
)}
</Modal>
);
}
function ImportStatusBadge({ invalid, duplicate }: { invalid: boolean; duplicate: boolean }) {
return (
<span className={`rounded px-2 py-1 text-xs font-semibold ${invalid ? "bg-red-500/15 text-red-100" : duplicate ? "bg-amber-300/15 text-amber-100" : "bg-cyan-300/15 text-cyan-100"}`}>
{invalid ? "Invalid" : duplicate ? "Duplicate" : "Ready"}
</span>
);
}
function rowMessage(row: ImportPreviewRow) {
return row.errors.join(" ") || row.duplicateReason || "Looks good.";
}