/** * @license * SPDX-License-Identifier: Apache-2.0 */ /** * Offline (counter) sales import. * * A sale rung up at the till never passes through the app, so nothing deducts * its stock. This is how that stock gets deducted: download a spreadsheet * pre-filled with the catalogue, type sold quantities into it, upload it back. * Imported sales become real orders, so they reduce stock through the same path * an app order uses and show up in revenue reporting. * * ONE file covers EVERY branch. There is no store picker: each row of the sheet * carries its own tenantid and locationid, and that row's locationid decides * which branch the sale comes out of. A merchant with six outlets fills in rows * for all six and uploads once. Nothing here has to know or choose a store. * * The template must be downloaded rather than hand-written because `productid` * is the only usable key for a product — SKUs are not unique in this catalogue * (6,245 products share 93 sku values) and names are not unique either. The * download fills productid and locationid in so nobody has to know them. * * Used by both surfaces. The admin console passes no locationId, so the file * routes itself. The store user's page passes theirs, which pins the upload to * their branch and rejects rows for any other — enforced again server-side. */ import { useCallback, useMemo, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet, Loader2, RotateCcw, Store, Upload, X, XCircle, } from 'lucide-react'; import { getSaleTemplate, uploadOfflineSales, type OfflineSalesUploadResponse, } from '../services/fiestaApi'; import { downloadSaleTemplate, parseSalesWorkbook, summarise, toBills, type ParsedSheet, } from '../services/offlineSalesSheet'; interface OfflineSalesUploadProps { tenantId: number; /** * Pins the upload to a single branch. Passed by the store user's page so they * can only ever import for their own store. Omitted by the admin console, so * the workbook spans every branch and each row routes itself. */ locationId?: number; /** Shown in the header when the upload is pinned to one store. */ storeName?: string; userId?: number; onClose: () => void; } const money = (n: number) => `₹${n.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; export default function OfflineSalesUpload({ tenantId, locationId, storeName, userId, onClose, }: OfflineSalesUploadProps) { const queryClient = useQueryClient(); const fileInputRef = useRef(null); const [parsed, setParsed] = useState(null); const [fileName, setFileName] = useState(''); const [dragging, setDragging] = useState(false); const [result, setResult] = useState(null); const [uploadError, setUploadError] = useState(''); const pinned = (locationId ?? 0) > 0; const templateQuery = useQuery({ queryKey: ['saleTemplate', tenantId, locationId ?? 0], queryFn: () => getSaleTemplate({ tenantid: tenantId, locationid: locationId ?? 0 }), enabled: tenantId > 0, // Always refetched on open: a template is only useful if its stock figures // and product list match the outlets right now. staleTime: 0, }); const template = templateQuery.data; const summary = useMemo(() => (parsed ? summarise(parsed.rows) : null), [parsed]); const blocked = Boolean(parsed && (parsed.fatal.length > 0 || (summary?.errors ?? 0) > 0)); const uploadMutation = useMutation({ mutationFn: async () => { if (!parsed) throw new Error('No file loaded.'); return uploadOfflineSales({ tenantid: tenantId, // Sent only when pinned. Left at 0, the backend routes each bill by the // locationid the sheet gave it. locationid: locationId ?? 0, userid: userId, bills: toBills(parsed.rows), }); }, onSuccess: (res) => { setResult(res); setUploadError(''); // Stock has moved at potentially several branches, so every view reading // it is now stale. Invalidating broadly is deliberate — a partially // refreshed inventory screen after an import is worse than a few extra // refetches. queryClient.invalidateQueries(); }, onError: (err: Error) => setUploadError(err.message), }); const loadFile = useCallback( async (file: File) => { setResult(null); setUploadError(''); setFileName(file.name); try { const buffer = await file.arrayBuffer(); setParsed( parseSalesWorkbook(buffer, { tenantid: tenantId, locationid: locationId, allowedLocationIds: template?.locations.map((l) => l.locationid), }), ); } catch { setParsed({ tenantid: null, rows: [], skipped: 0, fatal: ['That file could not be read. Upload the .xlsx template you downloaded.'], }); } }, [tenantId, locationId, template], ); const reset = () => { setParsed(null); setFileName(''); setResult(null); setUploadError(''); if (fileInputRef.current) fileInputRef.current.value = ''; }; const storeCount = template?.locations.length ?? 0; const scopeLabel = pinned ? storeName || template?.locations[0]?.locationname || `Outlet ${locationId}` : storeCount === 1 ? template?.locations[0]?.locationname || 'your store' : `all ${storeCount} stores`; return (

Offline Sales Upload

Counter sales for {scopeLabel}

{result ? ( ) : ( <> {/* Step 1 — the template. Presented first and prominently because uploading anything else will not work. */}

1 Download the template

{templateQuery.isLoading ? 'Loading your catalogue…' : templateQuery.isError ? 'Could not load your catalogue.' : pinned || storeCount <= 1 ? `${template?.products.length ?? 0} products stocked. Fill in the qtysold column and upload the file back.` : `${template?.products.length ?? 0} rows covering all ${storeCount} stores. Every row already says which store it belongs to — fill in qtysold wherever you sold something and upload the one file.`}

{/* What the one file covers. Shown so it is obvious up front that no store has to be chosen anywhere. */} {!pinned && storeCount > 1 && (
{template?.locations.map((l) => ( {l.locationname} #{l.locationid} · {l.productcount} ))}
)} {templateQuery.isError && (

{(templateQuery.error as Error).message}

)}
{/* Step 2 — the file. */}

2 Upload the filled-in file

{ e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={(e) => { e.preventDefault(); setDragging(false); const file = e.dataTransfer.files?.[0]; if (file) void loadFile(file); }} onClick={() => fileInputRef.current?.click()} className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${ dragging ? 'border-[#662582] bg-purple-50' : 'border-slate-300 bg-white hover:border-slate-400 hover:bg-slate-50' }`} >

{fileName || 'Drop the .xlsx file here, or click to choose'}

Only the template downloaded above will import correctly.

{ const file = e.target.files?.[0]; if (file) void loadFile(file); }} />
{/* Step 3 — the preview. Every problem is shown at once, against the operator's own row numbers, so the file can be fixed in one pass rather than one rejection at a time. */} {parsed && (

3 Check and confirm

{parsed.fatal.length > 0 && (
{parsed.fatal.map((f, i) => (

{f}

))}
)} {summary && parsed.rows.length > 0 && ( <>
0 ? 'bad' : 'good'} />
{/* Per-store totals. With one file covering the whole business, the single figure above is not enough to sanity-check what is about to be deducted where. */} {summary.stores > 1 && (
{summary.byStore.map((s) => ( 0 ? 'bg-red-50' : 'bg-white'}> ))}
Store Lines Units Amount Problems
{s.locationname || `Outlet ${s.locationid}`} #{s.locationid} {s.lines} {s.units} {money(s.amount)} 0 ? 'text-red-700' : 'text-emerald-700' }`} > {s.errors}
)} {summary.errors > 0 && (

{summary.errors} row{summary.errors === 1 ? '' : 's'} must be fixed in the file before this can be uploaded.

)} {summary.errors === 0 && summary.warnings > 0 && (

{summary.warnings} row{summary.warnings === 1 ? '' : 's'} have warnings. You can still upload.

)} {parsed.skipped > 0 && (

{parsed.skipped} row{parsed.skipped === 1 ? '' : 's'} had no quantity and were ignored.

)}
{parsed.rows.map((r) => { const bad = r.errors.length > 0; const warn = !bad && r.warnings.length > 0; return ( ); })}
Row Store Product Qty Price Amount Bill Status
{r.excelRow} {r.locationname || `#${r.locationid}`} {r.productname || '—'} #{r.productid} {r.qtysold} {r.unitprice ? money(r.unitprice) : } {money(Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount))} {r.billno || '—'} {bad ? ( {r.errors.join('; ')} ) : warn ? ( {r.warnings.join('; ')} ) : ( ready )}
)} {uploadError && (

{uploadError}

)}
)} )}
{!result && (

Each sale is deducted from the store named on its own row, and appears in Orders marked{' '} OFFLINE. Re-uploading the same file will not deduct twice.

{parsed && ( )}
)}
); } function Stat({ label, value, tone }: { label: string; value: string; tone?: 'good' | 'bad' }) { const valueTone = tone === 'bad' ? 'text-red-700' : tone === 'good' ? 'text-emerald-700' : 'text-slate-900'; return (

{label}

{value}

); } /** * Per-bill outcome. Duplicates are reported as their own neutral category * rather than as failures: a re-upload being refused is the safeguard working, * and calling it an error would push people towards "fixing" it. */ function ResultPanel({ result, onAnother, onClose, }: { result: OfflineSalesUploadResponse; onAnother: () => void; onClose: () => void; }) { return (
{result.imported > 0 ? ( ) : ( )}

{result.imported > 0 ? `${result.imported} bill${result.imported === 1 ? '' : 's'} imported` : 'Nothing was imported'}

{result.imported > 0 && <>Stock has been reduced and {money(result.totalamount)} recorded as revenue. } {result.duplicate > 0 && ( <> {result.duplicate} bill{result.duplicate === 1 ? ' was' : 's were'} already imported and{' '} {result.duplicate === 1 ? 'was' : 'were'} skipped.{' '} )} {result.failed > 0 && ( <> {result.failed} bill{result.failed === 1 ? '' : 's'} could not be imported — see below. )}

{result.results.map((r, i) => ( ))}
Store Bill Result Order Items Amount Detail
{r.locationname || (r.locationid ? `#${r.locationid}` : '—')} {r.billno || '—'} {r.status === 'imported' && ( imported )} {r.status === 'duplicate' && ( already done )} {r.status === 'failed' && ( failed )} {r.orderid || '—'} {r.itemcount || '—'} {r.amount ? money(r.amount) : '—'} {r.message}
); }