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>
626 lines
29 KiB
TypeScript
626 lines
29 KiB
TypeScript
/**
|
|
* @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<HTMLInputElement>(null);
|
|
|
|
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, 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 (
|
|
<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">{scopeLabel}</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} />
|
|
) : (
|
|
<>
|
|
{/* 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
|
|
</h3>
|
|
<p className="mt-1.5 text-xs leading-relaxed text-slate-600">
|
|
{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.`}
|
|
</p>
|
|
</div>
|
|
<button
|
|
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 ? (
|
|
<Loader2 size={16} className="animate-spin" />
|
|
) : (
|
|
<Download size={16} />
|
|
)}
|
|
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}
|
|
</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-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)} />
|
|
<Stat label="Amount" value={money(summary.amount)} />
|
|
<Stat
|
|
label="Problems"
|
|
value={String(summary.errors)}
|
|
tone={summary.errors > 0 ? 'bad' : 'good'}
|
|
/>
|
|
</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} />
|
|
{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-[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>
|
|
<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 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>
|
|
</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">
|
|
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 && (
|
|
<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">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>
|
|
<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.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' && (
|
|
<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>
|
|
);
|
|
}
|