import React, { useEffect, useRef, useState } from 'react'; import { AlertTriangleIcon, CheckCircle2Icon, FileSpreadsheetIcon, Loader2Icon, UndoIcon, UploadIcon, } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '../components/ui/button'; import { Callout, ErrorCallout } from '../components/ui/callout'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '../components/ui/table'; import type { IPriceJob, IPricePreview, IPriceRow, } from '../service/prices/prices.interface'; import { applyPrices, getPriceJob, listPriceJobs, previewPrices, undoPrices, } from '../service/prices/prices.service'; /** How often the page asks how far the import has got. */ const POLL_MS = 2_000; /** How many rows the preview draws; the totals count them all. */ const MAX_VISIBLE = 200; /** The order the preview reads in: what needs a decision comes first. */ const STATUS_ORDER: Record = { invalid: 0, change: 1, not_found: 2, nochange: 3, }; /** What each verdict looks like. */ const STATUS_STYLE: Record< IPriceRow['status'], { label: string; chip: string } > = { change: { label: 'Will change', chip: 'bg-brand/10 text-brand' }, nochange: { label: 'Already right', chip: 'bg-muted text-muted-foreground' }, not_found: { label: 'No such SKU', chip: 'bg-muted text-muted-foreground' }, invalid: { label: 'Refused', chip: 'bg-amber-400/20 text-amber-700' }, }; /** * One price, as the table prints it. * * @param props - Component props. * @param props.regular - The normal price. * @param props.sale - The reduced price, when there is one. * @return The pair. */ const Price = ({ regular, sale, }: { regular: string; sale: string; }): JSX.Element => ( {regular === '' ? '—' : regular} {sale !== '' && {sale}} ); /** * Prices from a spreadsheet. * * The supplier sends a file, or the sale is planned in a sheet, and the shop * changes two hundred prices by hand. This reads the file, says exactly what * would change before anything is written, applies it in the background, and * keeps every previous price so the whole import can be taken back. * * @return The page. */ const PriceImport = (): JSX.Element => { const [fileName, setFileName] = useState(''); const [preview, setPreview] = useState(null); const [reading, setReading] = useState(false); const [error, setError] = useState(''); const [job, setJob] = useState(null); const [undoing, setUndoing] = useState(''); const [past, setPast] = useState([]); const inputRef = useRef(null); // What was imported before, so an undo survives a reload. const refreshPast = (): void => { void listPriceJobs() .then(result => setPast(result.jobs)) .catch(() => undefined); }; useEffect(refreshPast, []); // Follow the import while it runs, and stop the moment it lands. useEffect(() => { if (!job || (job.status !== 'pending' && job.status !== 'running')) { return undefined; } const handle: number = window.setInterval(() => { void getPriceJob(job.job_id) .then(next => { setJob(next); if (next.status === 'done') refreshPast(); }) .catch(() => undefined); }, POLL_MS); return () => window.clearInterval(handle); }, [job]); const read = async (file: File): Promise => { setReading(true); setError(''); setJob(null); setPreview(null); setFileName(file.name); try { setPreview(await previewPrices(await file.text())); } catch (reason) { setError((reason as Error).message); } finally { setReading(false); } }; const apply = async (): Promise => { if (!preview) return; setError(''); try { setJob(await applyPrices(preview.items, fileName)); } catch (reason) { setError((reason as Error).message); } }; const takeBack = async (jobId: string): Promise => { setUndoing(jobId); try { const { restored } = await undoPrices(jobId); toast.success( `${restored} ${restored === 1 ? 'price' : 'prices'} put back.` ); const updated: IPriceJob = await getPriceJob(jobId); if (job?.job_id === jobId) setJob(updated); refreshPast(); } catch (reason) { toast.error((reason as Error).message); } finally { setUndoing(''); } }; const rows: IPriceRow[] = (preview?.items ?? []) .slice() .sort( (left, right) => STATUS_ORDER[left.status] - STATUS_ORDER[right.status] ) .slice(0, MAX_VISIBLE); const totals = preview?.totals; const running: boolean = job !== null && (job.status === 'pending' || job.status === 'running'); return (

Prices from a file

A spreadsheet with a sku column, a{' '} regular_price for the normal price and a{' '} sale_price for the reduced one (pret_vechi{' '} and pret_nou work too). Variations match by their own SKU. Nothing is written until you have read what changes.

{ const file = event.target.files?.[0]; if (file) void read(file); event.target.value = ''; }} /> {fileName !== '' && ( {fileName} )}
{error !== '' && } {totals && (
{[ { label: 'Will change', value: totals.change }, { label: 'Already right', value: totals.nochange }, { label: 'No such SKU', value: totals.not_found }, { label: 'Refused', value: totals.invalid }, ].map(cell => (

{cell.label}

{cell.value}

))}
{totals.stale > 0 && (

{totals.stale} {totals.stale === 1 ? 'row has' : 'rows have'} a normal price the shop does not agree with - the file may have been written before somebody changed those prices by hand. They are still applied if you go ahead.

)} {job === null && (
)}
)} {job && (
{running ? ( ) : ( )}

{job.undone ? 'Import taken back' : running ? 'Changing prices…' : 'Prices changed'}

{job.written} of {job.total} written {job.failed > 0 && `, ${job.failed} failed`}

{job.can_undo && ( )}
)} {past.length > 0 && (

Imports you have run

{past.map((entry: IPriceJob, index: number) => (
0 ? 'border-t border-border' : ''}`} >

{entry.file || 'A price file'}

{entry.created_at ? new Date(entry.created_at * 1000).toLocaleString() : ''} {' · '} {entry.undone ? 'taken back' : `${entry.written} of ${entry.total} written`}

{entry.can_undo && ( )}
))}
)} {rows.length > 0 && (
SKU Product Now After Verdict {rows.map((row: IPriceRow, index: number) => ( {row.sku} {row.name || '—'} {row.status === 'change' ? ( ) : ( — )} {STATUS_STYLE[row.status].label} {row.note !== '' && (

{row.note}

)}
))}
{(preview?.items.length ?? 0) > MAX_VISIBLE && (

Showing the first {MAX_VISIBLE} of {preview?.items.length} rows; the counts above cover the whole file.

)}
)}
); }; export default PriceImport;