Carry the branch on every offline-sales row instead of picking a store
The upload modal made the admin choose a branch, then generated and validated the workbook against that choice. A merchant with several outlets had to repeat the whole cycle per branch, and the picker defaulted to the first outlet, so an admin who never opened it credited the wrong store — the file and the selection agreed, so nothing flagged it. The sheet now carries tenantid, locationid and the store name as locked columns on every row, and the row's own locationid routes its sale. One file covers the whole business: fill in qtysold wherever something sold, across as many branches as needed, and upload once. The picker is gone from the admin surface entirely, and the store user's page keeps passing its locationId, which pins the upload to that branch and rejects rows for any other before they are even sent. Bills are keyed on branch first and bill number second. Counter books at different outlets restart numbering from 1, so a shared number is two sales rather than a duplicate, and keying on the number alone would have dropped the second one. Because a single upload can now move stock at six outlets, one total is no longer enough to check before committing: the preview gains a store count, a per-store table of lines, units, amount and problems, and a Store column on every row, and results name the branch on each bill. Rows are ordered store then product with an Excel autofilter, so a branch can isolate its own rows in a file spanning the business. Verified end to end against a two-branch tenant sharing a product id across both outlets: one upload deducted each branch independently, a pinned upload refused the other branch's rows, and re-uploading deducted nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,18 +7,24 @@
|
||||
* 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.
|
||||
* 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 in so nobody has to know it.
|
||||
* download fills productid and locationid in so nobody has to know them.
|
||||
*
|
||||
* 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.
|
||||
* 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';
|
||||
@@ -30,6 +36,7 @@ import {
|
||||
FileSpreadsheet,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Store,
|
||||
Upload,
|
||||
X,
|
||||
XCircle,
|
||||
@@ -49,19 +56,15 @@ import {
|
||||
|
||||
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. */
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -73,28 +76,29 @@ export default function OfflineSalesUpload({
|
||||
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 pinned = (locationId ?? 0) > 0;
|
||||
|
||||
const templateQuery = useQuery({
|
||||
queryKey: ['saleTemplate', tenantId, activeLocationId],
|
||||
queryFn: () => getSaleTemplate({ tenantid: tenantId, locationid: activeLocationId }),
|
||||
enabled: tenantId > 0 && activeLocationId > 0,
|
||||
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 outlet right now.
|
||||
// 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));
|
||||
|
||||
@@ -103,7 +107,9 @@ export default function OfflineSalesUpload({
|
||||
if (!parsed) throw new Error('No file loaded.');
|
||||
return uploadOfflineSales({
|
||||
tenantid: tenantId,
|
||||
locationid: activeLocationId,
|
||||
// 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),
|
||||
});
|
||||
@@ -111,9 +117,10 @@ export default function OfflineSalesUpload({
|
||||
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.
|
||||
// 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),
|
||||
@@ -126,19 +133,23 @@ export default function OfflineSalesUpload({
|
||||
setFileName(file.name);
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
setParsed(parseSalesWorkbook(buffer, { tenantid: tenantId, locationid: activeLocationId }));
|
||||
setParsed(
|
||||
parseSalesWorkbook(buffer, {
|
||||
tenantid: tenantId,
|
||||
locationid: locationId,
|
||||
allowedLocationIds: template?.locations.map((l) => l.locationid),
|
||||
}),
|
||||
);
|
||||
} 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],
|
||||
[tenantId, locationId, template],
|
||||
);
|
||||
|
||||
const reset = () => {
|
||||
@@ -149,12 +160,12 @@ export default function OfflineSalesUpload({
|
||||
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}`;
|
||||
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 (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
|
||||
@@ -167,7 +178,7 @@ export default function OfflineSalesUpload({
|
||||
<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>
|
||||
Counter sales for <span className="font-semibold text-white/90">{scopeLabel}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,36 +196,6 @@ export default function OfflineSalesUpload({
|
||||
<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">
|
||||
@@ -224,19 +205,21 @@ export default function OfflineSalesUpload({
|
||||
<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
|
||||
Download the template
|
||||
</h3>
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-slate-600">
|
||||
{templateQuery.isLoading
|
||||
? 'Loading this outlet’s catalogue…'
|
||||
? 'Loading your 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.`}
|
||||
? '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.`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => templateQuery.data && downloadSaleTemplate(templateQuery.data)}
|
||||
disabled={!templateQuery.data || templateQuery.isLoading}
|
||||
onClick={() => template && downloadSaleTemplate(template)}
|
||||
disabled={!template || 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 ? (
|
||||
@@ -247,6 +230,25 @@ export default function OfflineSalesUpload({
|
||||
Download Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* What the one file covers. Shown so it is obvious up front
|
||||
that no store has to be chosen anywhere. */}
|
||||
{!pinned && storeCount > 1 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2 border-t border-slate-200 pt-3">
|
||||
{template?.locations.map((l) => (
|
||||
<span
|
||||
key={l.locationid}
|
||||
className="flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] font-medium text-slate-700"
|
||||
>
|
||||
<Store size={11} className="text-[#662582]" />
|
||||
{l.locationname}
|
||||
<span className="text-slate-400">#{l.locationid}</span>
|
||||
<span className="text-slate-400">· {l.productcount}</span>
|
||||
</span>
|
||||
))}
|
||||
</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}
|
||||
@@ -325,7 +327,8 @@ export default function OfflineSalesUpload({
|
||||
|
||||
{summary && parsed.rows.length > 0 && (
|
||||
<>
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-6">
|
||||
<Stat label="Stores" value={String(summary.stores)} />
|
||||
<Stat label="Bills" value={String(summary.bills)} />
|
||||
<Stat label="Lines" value={String(summary.lines)} />
|
||||
<Stat label="Units" value={String(summary.units)} />
|
||||
@@ -337,6 +340,45 @@ export default function OfflineSalesUpload({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<div className="mb-4 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">Store</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Lines</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Units</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Amount</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Problems</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{summary.byStore.map((s) => (
|
||||
<tr key={s.locationid} className={s.errors > 0 ? 'bg-red-50' : 'bg-white'}>
|
||||
<td className="px-3 py-2 font-medium text-slate-800">
|
||||
{s.locationname || `Outlet ${s.locationid}`}
|
||||
<span className="ml-1.5 font-mono text-[10px] text-slate-400">#{s.locationid}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-slate-600">{s.lines}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-600">{s.units}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-slate-800">{money(s.amount)}</td>
|
||||
<td
|
||||
className={`px-3 py-2 text-right font-semibold ${
|
||||
s.errors > 0 ? 'text-red-700' : 'text-emerald-700'
|
||||
}`}
|
||||
>
|
||||
{s.errors}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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} />
|
||||
@@ -357,10 +399,11 @@ export default function OfflineSalesUpload({
|
||||
)}
|
||||
|
||||
<div className="max-h-72 overflow-auto rounded-lg border border-slate-200">
|
||||
<table className="w-full min-w-[820px] text-left text-xs">
|
||||
<table className="w-full min-w-[920px] 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">Store</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>
|
||||
@@ -379,6 +422,9 @@ export default function OfflineSalesUpload({
|
||||
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 text-slate-700">
|
||||
{r.locationname || `#${r.locationid}`}
|
||||
</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>
|
||||
@@ -425,8 +471,8 @@ export default function OfflineSalesUpload({
|
||||
{!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.
|
||||
Each sale is deducted from the store named on its own row, and appears 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 && (
|
||||
@@ -448,7 +494,8 @@ export default function OfflineSalesUpload({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload size={16} /> Import {summary?.bills ? `${summary.bills} Bill${summary.bills === 1 ? '' : 's'}` : 'Sales'}
|
||||
<Upload size={16} /> Import{' '}
|
||||
{summary?.bills ? `${summary.bills} Bill${summary.bills === 1 ? '' : 's'}` : 'Sales'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -519,6 +566,7 @@ function ResultPanel({
|
||||
<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">Store</th>
|
||||
<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>
|
||||
@@ -530,11 +578,12 @@ function ResultPanel({
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{result.results.map((r, i) => (
|
||||
<tr
|
||||
key={`${r.billno}-${i}`}
|
||||
key={`${r.locationid}-${r.billno}-${i}`}
|
||||
className={
|
||||
r.status === 'imported' ? 'bg-white' : r.status === 'duplicate' ? 'bg-slate-50' : 'bg-red-50'
|
||||
}
|
||||
>
|
||||
<td className="px-3 py-2 text-slate-700">{r.locationname || (r.locationid ? `#${r.locationid}` : '—')}</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-700">{r.billno || '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
{r.status === 'imported' && (
|
||||
|
||||
Reference in New Issue
Block a user