/** * @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 the way that stock gets deducted: download a spreadsheet * pre-filled with the outlet's 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. * * 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 in so nobody has to know it. * * Used by both surfaces. The admin console passes the outlet it has selected; * the store user's page passes their own, which is the only one they can reach. */ import { useCallback, useMemo, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet, Loader2, RotateCcw, 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; /** The outlet to credit. For a store user this is their own and is the only * one reachable; for an admin it is the initial selection in `locations`. */ locationId: number; /** Shown in the header so it is unambiguous which store is being credited. */ storeName?: string; userId?: number; /** * Outlets the user may choose between. Passed by the admin console, whose * users manage several branches and must say which one a bill belongs to. * Omitted for a store user, which locks the import to their own outlet — the * backend enforces the same thing regardless of what the file says. */ locations?: { locationid: number; locationname: string }[]; onClose: () => void; } const money = (n: number) => `₹${n.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; export default function OfflineSalesUpload({ tenantId, locationId, storeName, userId, locations, onClose, }: OfflineSalesUploadProps) { const queryClient = useQueryClient(); const fileInputRef = useRef(null); const [activeLocationId, setActiveLocationId] = useState(locationId); const [parsed, setParsed] = useState(null); const [fileName, setFileName] = useState(''); const [dragging, setDragging] = useState(false); const [result, setResult] = useState(null); const [uploadError, setUploadError] = useState(''); const templateQuery = useQuery({ queryKey: ['saleTemplate', tenantId, activeLocationId], queryFn: () => getSaleTemplate({ tenantid: tenantId, locationid: activeLocationId }), enabled: tenantId > 0 && activeLocationId > 0, // Always refetched on open: a template is only useful if its stock figures // and product list match the outlet right now. staleTime: 0, }); 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, locationid: activeLocationId, userid: userId, bills: toBills(parsed.rows), }); }, onSuccess: (res) => { setResult(res); setUploadError(''); // Stock has moved, 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: activeLocationId })); } catch { setParsed({ tenantid: null, locationid: null, locationname: '', rows: [], skipped: 0, fatal: ['That file could not be read. Upload the .xlsx template you downloaded.'], }); } }, [tenantId, activeLocationId], ); const reset = () => { setParsed(null); setFileName(''); setResult(null); setUploadError(''); if (fileInputRef.current) fileInputRef.current.value = ''; }; const picker = locations && locations.length > 1 ? locations : null; const outletLabel = picker?.find((l) => l.locationid === activeLocationId)?.locationname || storeName || templateQuery.data?.locationname || `Outlet ${activeLocationId}`; return (

Offline Sales Upload

Counter sales for {outletLabel}

{result ? ( ) : ( <> {/* Which branch. Only rendered for a user who has more than one — a store user has no choice to make and showing them a picker would imply they do. Changing it clears any loaded file, since a file's productids belong to the outlet it was generated for. */} {picker && (

Stock is deducted from this store, and the template below is built from its catalogue.

)} {/* Step 1 — the template. Presented first and prominently because uploading anything else will not work. */}

1 Download the template for this store

{templateQuery.isLoading ? 'Loading this outlet’s catalogue…' : templateQuery.isError ? 'Could not load this outlet’s catalogue.' : `${templateQuery.data?.products.length ?? 0} products stocked here. Fill in the qtysold column and upload the file back.`}

{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'} />
{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 Product Qty Price Amount Bill Status
{r.excelRow} {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 && (

Imported sales reduce stock and appear 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) => ( ))}
Bill Result Order Items Amount Detail
{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}
); }