630 lines
29 KiB
TypeScript
630 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, useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
AlertTriangle,
|
|
CheckCircle2,
|
|
Download,
|
|
FileSpreadsheet,
|
|
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';
|
|
import { Button } from '@astryxdesign/core/Button';
|
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
|
import { FileInput } from '@astryxdesign/core/FileInput';
|
|
import {
|
|
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
|
|
} from '@astryxdesign/core/Table';
|
|
|
|
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();
|
|
|
|
// Remount key for the FileInput. The old dropzone cleared its hidden input's
|
|
// `.value` on reset, which is what let the operator fix a spreadsheet and
|
|
// pick the very same filename again. FileInput owns that element now, so the
|
|
// equivalent is to give it a fresh one.
|
|
const [fileInputNonce, setFileInputNonce] = useState(0);
|
|
|
|
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
|
|
const [fileName, setFileName] = useState('');
|
|
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('');
|
|
setFileInputNonce((n) => n + 1);
|
|
};
|
|
|
|
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>
|
|
<IconButton
|
|
icon={<X size={20} />}
|
|
label="Close offline sales upload"
|
|
variant="ghost"
|
|
onClick={onClose}
|
|
style={{ height: 32, width: 32, minWidth: 0 }}
|
|
className="flex h-8 w-8 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white"
|
|
/>
|
|
</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>
|
|
{/* Astryx supplies the spinner behind `isLoading`, so the
|
|
hand-rolled Loader2 goes with it. */}
|
|
<Button
|
|
label="Download Template"
|
|
icon={<Download size={16} />}
|
|
isLoading={templateQuery.isLoading}
|
|
isDisabled={!template || templateQuery.isLoading}
|
|
onClick={() => template && downloadSaleTemplate(template)}
|
|
style={{ height: 'auto' }}
|
|
className="flex items-center gap-2 rounded-lg bg-[#662582] px-4 py-2.5 text-sm font-semibold text-white border-none transition-colors hover:bg-[#551f6d]"
|
|
/>
|
|
</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>
|
|
|
|
{/* PHASE 6 — FileInput in dropzone mode, replacing a hand-rolled
|
|
drop target that nobody could reach from a keyboard: it was a
|
|
plain <div> with an onClick, no role, no tabindex, driving a
|
|
`hidden` file input. Mouse users got a drop zone; everyone
|
|
else got nothing at all, on the only step of this flow that
|
|
accepts input. FileInput brings the drag handling, the accept
|
|
filter and a real focusable control with it, so `dragging`
|
|
and the input ref are no longer needed here. */}
|
|
<FileInput
|
|
key={fileInputNonce}
|
|
label="Filled-in sales file"
|
|
isLabelHidden
|
|
mode="dropzone"
|
|
accept=".xlsx,.xls,.csv"
|
|
value={null}
|
|
placeholder={fileName || 'Drop the .xlsx file here, or click to choose'}
|
|
description="Only the template downloaded above will import correctly."
|
|
onChange={(f) => {
|
|
const file = Array.isArray(f) ? f[0] : f;
|
|
if (file) void loadFile(file);
|
|
}}
|
|
/>
|
|
{/* `description` above is what ties this sentence to the input
|
|
for a screen reader — but Astryx hides the description
|
|
together with the label, so with `isLabelHidden` set it
|
|
collapses to a 1x1px span and sighted users simply lose it.
|
|
Repeated here so it stays on screen, as it was before. */}
|
|
<p className="mt-2 text-xs text-slate-500">
|
|
Only the template downloaded above will import correctly.
|
|
</p>
|
|
</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">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHeaderCell>Store</TableHeaderCell>
|
|
<TableHeaderCell className="text-right">Lines</TableHeaderCell>
|
|
<TableHeaderCell className="text-right">Units</TableHeaderCell>
|
|
<TableHeaderCell className="text-right">Amount</TableHeaderCell>
|
|
<TableHeaderCell className="text-right">Problems</TableHeaderCell>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{summary.byStore.map((s) => (
|
|
<TableRow key={s.locationid} className={s.errors > 0 ? 'bg-red-50' : 'bg-white'}>
|
|
<TableCell className="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>
|
|
</TableCell>
|
|
<TableCell className="text-right text-slate-600">{s.lines}</TableCell>
|
|
<TableCell className="text-right text-slate-600">{s.units}</TableCell>
|
|
<TableCell className="text-right font-semibold text-slate-800">{money(s.amount)}</TableCell>
|
|
<TableCell
|
|
className={`text-right font-semibold ${
|
|
s.errors > 0 ? 'text-red-700' : 'text-emerald-700'
|
|
}`}
|
|
>
|
|
{s.errors}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</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">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHeaderCell>Row</TableHeaderCell>
|
|
<TableHeaderCell>Store</TableHeaderCell>
|
|
<TableHeaderCell>Product</TableHeaderCell>
|
|
<TableHeaderCell className="text-right">Qty</TableHeaderCell>
|
|
<TableHeaderCell className="text-right">Price</TableHeaderCell>
|
|
<TableHeaderCell className="text-right">Amount</TableHeaderCell>
|
|
<TableHeaderCell>Bill</TableHeaderCell>
|
|
<TableHeaderCell>Status</TableHeaderCell>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{parsed.rows.map((r) => {
|
|
const bad = r.errors.length > 0;
|
|
const warn = !bad && r.warnings.length > 0;
|
|
return (
|
|
<TableRow
|
|
key={r.excelRow}
|
|
className={bad ? 'bg-red-50' : warn ? 'bg-amber-50' : 'bg-white'}
|
|
>
|
|
<TableCell className="font-mono text-slate-500">{r.excelRow}</TableCell>
|
|
<TableCell className="text-slate-700">
|
|
{r.locationname || `#${r.locationid}`}
|
|
</TableCell>
|
|
<TableCell>
|
|
<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>
|
|
</TableCell>
|
|
<TableCell className="text-right font-semibold text-slate-800">{r.qtysold}</TableCell>
|
|
<TableCell className="text-right text-slate-600">
|
|
{r.unitprice ? money(r.unitprice) : <span className="text-amber-600">—</span>}
|
|
</TableCell>
|
|
<TableCell className="text-right text-slate-600">
|
|
{money(Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount))}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-slate-500">{r.billno || '—'}</TableCell>
|
|
<TableCell>
|
|
{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>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</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
|
|
label="Clear the loaded file"
|
|
icon={<RotateCcw size={14} />}
|
|
onClick={reset}
|
|
style={{ height: 'auto' }}
|
|
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"
|
|
>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
<Button
|
|
label={
|
|
uploadMutation.isPending
|
|
? 'Importing…'
|
|
: `Import ${summary?.bills ? `${summary.bills} Bill${summary.bills === 1 ? '' : 's'}` : 'Sales'}`
|
|
}
|
|
icon={uploadMutation.isPending ? undefined : <Upload size={16} />}
|
|
isLoading={uploadMutation.isPending}
|
|
isDisabled={!parsed || blocked || uploadMutation.isPending}
|
|
onClick={() => uploadMutation.mutate()}
|
|
style={{ height: 'auto' }}
|
|
className="flex items-center gap-2 rounded-lg bg-[#662582] px-5 py-2 text-sm font-semibold text-white border-none transition-colors hover:bg-[#551f6d]"
|
|
/>
|
|
</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">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHeaderCell>Store</TableHeaderCell>
|
|
<TableHeaderCell>Bill</TableHeaderCell>
|
|
<TableHeaderCell>Result</TableHeaderCell>
|
|
<TableHeaderCell>Order</TableHeaderCell>
|
|
<TableHeaderCell className="text-right">Items</TableHeaderCell>
|
|
<TableHeaderCell className="text-right">Amount</TableHeaderCell>
|
|
<TableHeaderCell>Detail</TableHeaderCell>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{result.results.map((r, i) => (
|
|
<TableRow
|
|
key={`${r.locationid}-${r.billno}-${i}`}
|
|
className={
|
|
r.status === 'imported' ? 'bg-white' : r.status === 'duplicate' ? 'bg-slate-50' : 'bg-red-50'
|
|
}
|
|
>
|
|
<TableCell className="text-slate-700">{r.locationname || (r.locationid ? `#${r.locationid}` : '—')}</TableCell>
|
|
<TableCell className="font-mono text-slate-700">{r.billno || '—'}</TableCell>
|
|
<TableCell>
|
|
{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>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-slate-600">{r.orderid || '—'}</TableCell>
|
|
<TableCell className="text-right text-slate-600">{r.itemcount || '—'}</TableCell>
|
|
<TableCell className="text-right text-slate-600">{r.amount ? money(r.amount) : '—'}</TableCell>
|
|
<TableCell className="text-slate-600">{r.message}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
<div className="mt-6 flex justify-end gap-3">
|
|
<Button
|
|
label="Upload Another File"
|
|
onClick={onAnother}
|
|
style={{ height: 'auto' }}
|
|
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"
|
|
/>
|
|
<Button
|
|
label="Done"
|
|
onClick={onClose}
|
|
style={{ height: 'auto' }}
|
|
className="rounded-lg bg-[#662582] px-5 py-2 text-sm font-semibold text-white border-none transition-colors hover:bg-[#551f6d]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|