Files
daily_merchant_web/src/components/OfflineSalesUpload.tsx
2026-07-30 17:23:21 +05:30

577 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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<HTMLInputElement>(null);
const [activeLocationId, setActiveLocationId] = useState(locationId);
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
const [fileName, setFileName] = useState('');
const [dragging, setDragging] = useState(false);
const [result, setResult] = useState<OfflineSalesUploadResponse | null>(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 (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/50" onClick={onClose} />
<div className="relative z-10 flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl bg-white shadow-2xl">
<div className="flex shrink-0 items-center justify-between bg-[#662582] px-6 py-4">
<div className="flex items-center gap-3">
<FileSpreadsheet size={20} className="text-white/90" />
<div>
<h2 className="text-lg font-bold tracking-tight text-white">Offline Sales Upload</h2>
<p className="text-xs text-white/70">
Counter sales for <span className="font-semibold text-white/90">{outletLabel}</span>
</p>
</div>
</div>
<button
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white"
aria-label="Close"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
{result ? (
<ResultPanel result={result} onAnother={reset} onClose={onClose} />
) : (
<>
{/* 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 && (
<section className="mb-6 rounded-lg border border-amber-200 bg-amber-50 p-4">
<label className="block text-xs font-bold text-slate-800" htmlFor="offline-outlet">
Which store are these sales from?
</label>
<select
id="offline-outlet"
value={activeLocationId}
onChange={(e) => {
setActiveLocationId(Number(e.target.value));
reset();
}}
className="mt-2 w-full max-w-md rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-800 focus:border-[#662582] focus:outline-none"
>
{picker.map((l) => (
<option key={l.locationid} value={l.locationid}>
{l.locationname}
</option>
))}
</select>
<p className="mt-2 text-[11px] text-slate-600">
Stock is deducted from this store, and the template below is built from its catalogue.
</p>
</section>
)}
{/* Step 1 — the template. Presented first and prominently because
uploading anything else will not work. */}
<section className="mb-6 rounded-lg border border-slate-200 bg-slate-50 p-5">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h3 className="flex items-center gap-2 text-sm font-bold text-slate-800">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[#662582] text-[11px] font-bold text-white">
1
</span>
Download the template for this store
</h3>
<p className="mt-1.5 text-xs leading-relaxed text-slate-600">
{templateQuery.isLoading
? 'Loading this outlets catalogue…'
: templateQuery.isError
? 'Could not load this outlets catalogue.'
: `${templateQuery.data?.products.length ?? 0} products stocked here. Fill in the qtysold column and upload the file back.`}
</p>
</div>
<button
onClick={() => templateQuery.data && downloadSaleTemplate(templateQuery.data)}
disabled={!templateQuery.data || templateQuery.isLoading}
className="flex items-center gap-2 rounded-lg bg-[#662582] px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[#551f6d] disabled:cursor-not-allowed disabled:opacity-50"
>
{templateQuery.isLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Download size={16} />
)}
Download Template
</button>
</div>
{templateQuery.isError && (
<p className="mt-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{(templateQuery.error as Error).message}
</p>
)}
</section>
{/* Step 2 — the file. */}
<section className="mb-6">
<h3 className="mb-3 flex items-center gap-2 text-sm font-bold text-slate-800">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[#662582] text-[11px] font-bold text-white">
2
</span>
Upload the filled-in file
</h3>
<div
onDragOver={(e) => {
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'
}`}
>
<Upload size={26} className="mx-auto mb-2 text-slate-400" />
<p className="text-sm font-medium text-slate-700">
{fileName || 'Drop the .xlsx file here, or click to choose'}
</p>
<p className="mt-1 text-xs text-slate-500">Only the template downloaded above will import correctly.</p>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void loadFile(file);
}}
/>
</div>
</section>
{/* 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 && (
<section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-bold text-slate-800">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[#662582] text-[11px] font-bold text-white">
3
</span>
Check and confirm
</h3>
{parsed.fatal.length > 0 && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-4">
{parsed.fatal.map((f, i) => (
<p key={i} className="flex items-start gap-2 text-sm text-red-800">
<XCircle size={16} className="mt-0.5 shrink-0" />
{f}
</p>
))}
</div>
)}
{summary && parsed.rows.length > 0 && (
<>
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-5">
<Stat label="Bills" value={String(summary.bills)} />
<Stat label="Lines" value={String(summary.lines)} />
<Stat label="Units" value={String(summary.units)} />
<Stat label="Amount" value={money(summary.amount)} />
<Stat
label="Problems"
value={String(summary.errors)}
tone={summary.errors > 0 ? 'bad' : 'good'}
/>
</div>
{summary.errors > 0 && (
<p className="mb-3 flex items-center gap-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-xs font-medium text-red-800">
<XCircle size={14} />
{summary.errors} row{summary.errors === 1 ? '' : 's'} must be fixed in the file before this can be
uploaded.
</p>
)}
{summary.errors === 0 && summary.warnings > 0 && (
<p className="mb-3 flex items-center gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-800">
<AlertTriangle size={14} />
{summary.warnings} row{summary.warnings === 1 ? '' : 's'} have warnings. You can still upload.
</p>
)}
{parsed.skipped > 0 && (
<p className="mb-3 text-xs text-slate-500">
{parsed.skipped} row{parsed.skipped === 1 ? '' : 's'} had no quantity and were ignored.
</p>
)}
<div className="max-h-72 overflow-auto rounded-lg border border-slate-200">
<table className="w-full min-w-[820px] text-left text-xs">
<thead className="sticky top-0 bg-slate-100 text-[11px] uppercase tracking-wide text-slate-600">
<tr>
<th className="px-3 py-2 font-semibold">Row</th>
<th className="px-3 py-2 font-semibold">Product</th>
<th className="px-3 py-2 text-right font-semibold">Qty</th>
<th className="px-3 py-2 text-right font-semibold">Price</th>
<th className="px-3 py-2 text-right font-semibold">Amount</th>
<th className="px-3 py-2 font-semibold">Bill</th>
<th className="px-3 py-2 font-semibold">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{parsed.rows.map((r) => {
const bad = r.errors.length > 0;
const warn = !bad && r.warnings.length > 0;
return (
<tr
key={r.excelRow}
className={bad ? 'bg-red-50' : warn ? 'bg-amber-50' : 'bg-white'}
>
<td className="px-3 py-2 font-mono text-slate-500">{r.excelRow}</td>
<td className="px-3 py-2">
<span className="font-medium text-slate-800">{r.productname || '—'}</span>
<span className="ml-1.5 font-mono text-[10px] text-slate-400">#{r.productid}</span>
</td>
<td className="px-3 py-2 text-right font-semibold text-slate-800">{r.qtysold}</td>
<td className="px-3 py-2 text-right text-slate-600">
{r.unitprice ? money(r.unitprice) : <span className="text-amber-600"></span>}
</td>
<td className="px-3 py-2 text-right text-slate-600">
{money(Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount))}
</td>
<td className="px-3 py-2 font-mono text-slate-500">{r.billno || '—'}</td>
<td className="px-3 py-2">
{bad ? (
<span className="text-red-700">{r.errors.join('; ')}</span>
) : warn ? (
<span className="text-amber-700">{r.warnings.join('; ')}</span>
) : (
<span className="flex items-center gap-1 text-emerald-700">
<CheckCircle2 size={12} /> ready
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
{uploadError && (
<p className="mt-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
{uploadError}
</p>
)}
</section>
)}
</>
)}
</div>
{!result && (
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-6 py-4">
<p className="text-xs text-slate-500">
Imported sales reduce stock and appear in Orders marked <span className="font-semibold">OFFLINE</span>.
Re-uploading the same file will not deduct twice.
</p>
<div className="flex items-center gap-3">
{parsed && (
<button
onClick={reset}
className="flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100"
>
<RotateCcw size={14} /> Clear
</button>
)}
<button
onClick={() => uploadMutation.mutate()}
disabled={!parsed || blocked || uploadMutation.isPending}
className="flex items-center gap-2 rounded-lg bg-[#662582] px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#551f6d] disabled:cursor-not-allowed disabled:opacity-40"
>
{uploadMutation.isPending ? (
<>
<Loader2 size={16} className="animate-spin" /> Importing
</>
) : (
<>
<Upload size={16} /> Import {summary?.bills ? `${summary.bills} Bill${summary.bills === 1 ? '' : 's'}` : 'Sales'}
</>
)}
</button>
</div>
</div>
)}
</div>
</div>
);
}
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 (
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">{label}</p>
<p className={`text-base font-bold ${valueTone}`}>{value}</p>
</div>
);
}
/**
* 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 (
<div>
<div className="mb-5 flex items-start gap-3 rounded-lg border border-slate-200 bg-slate-50 p-5">
{result.imported > 0 ? (
<CheckCircle2 size={22} className="mt-0.5 shrink-0 text-emerald-600" />
) : (
<AlertTriangle size={22} className="mt-0.5 shrink-0 text-amber-600" />
)}
<div>
<h3 className="text-base font-bold text-slate-900">
{result.imported > 0
? `${result.imported} bill${result.imported === 1 ? '' : 's'} imported`
: 'Nothing was imported'}
</h3>
<p className="mt-1 text-sm text-slate-600">
{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.
</>
)}
</p>
</div>
</div>
<div className="overflow-hidden rounded-lg border border-slate-200">
<table className="w-full text-left text-xs">
<thead className="bg-slate-100 text-[11px] uppercase tracking-wide text-slate-600">
<tr>
<th className="px-3 py-2 font-semibold">Bill</th>
<th className="px-3 py-2 font-semibold">Result</th>
<th className="px-3 py-2 font-semibold">Order</th>
<th className="px-3 py-2 text-right font-semibold">Items</th>
<th className="px-3 py-2 text-right font-semibold">Amount</th>
<th className="px-3 py-2 font-semibold">Detail</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{result.results.map((r, i) => (
<tr
key={`${r.billno}-${i}`}
className={
r.status === 'imported' ? 'bg-white' : r.status === 'duplicate' ? 'bg-slate-50' : 'bg-red-50'
}
>
<td className="px-3 py-2 font-mono text-slate-700">{r.billno || '—'}</td>
<td className="px-3 py-2">
{r.status === 'imported' && (
<span className="rounded bg-emerald-100 px-2 py-0.5 font-semibold text-emerald-800">imported</span>
)}
{r.status === 'duplicate' && (
<span className="rounded bg-slate-200 px-2 py-0.5 font-semibold text-slate-700">already done</span>
)}
{r.status === 'failed' && (
<span className="rounded bg-red-100 px-2 py-0.5 font-semibold text-red-800">failed</span>
)}
</td>
<td className="px-3 py-2 font-mono text-slate-600">{r.orderid || '—'}</td>
<td className="px-3 py-2 text-right text-slate-600">{r.itemcount || '—'}</td>
<td className="px-3 py-2 text-right text-slate-600">{r.amount ? money(r.amount) : '—'}</td>
<td className="px-3 py-2 text-slate-600">{r.message}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={onAnother}
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100"
>
Upload Another File
</button>
<button
onClick={onClose}
className="rounded-lg bg-[#662582] px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#551f6d]"
>
Done
</button>
</div>
</div>
);
}