offline sales upload
This commit is contained in:
576
src/components/OfflineSalesUpload.tsx
Normal file
576
src/components/OfflineSalesUpload.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* @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 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.`}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user