page wise astryx conversion completed
This commit is contained in:
@@ -27,14 +27,13 @@
|
||||
* their branch and rejects rows for any other — enforced again server-side.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Store,
|
||||
Upload,
|
||||
@@ -53,6 +52,12 @@ import {
|
||||
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;
|
||||
@@ -79,11 +84,15 @@ export default function OfflineSalesUpload({
|
||||
onClose,
|
||||
}: OfflineSalesUploadProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 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 [dragging, setDragging] = useState(false);
|
||||
const [result, setResult] = useState<OfflineSalesUploadResponse | null>(null);
|
||||
const [uploadError, setUploadError] = useState('');
|
||||
|
||||
@@ -157,7 +166,7 @@ export default function OfflineSalesUpload({
|
||||
setFileName('');
|
||||
setResult(null);
|
||||
setUploadError('');
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setFileInputNonce((n) => n + 1);
|
||||
};
|
||||
|
||||
const storeCount = template?.locations.length ?? 0;
|
||||
@@ -182,13 +191,14 @@ export default function OfflineSalesUpload({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
<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"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
@@ -217,18 +227,17 @@ export default function OfflineSalesUpload({
|
||||
: `${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
|
||||
{/* 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)}
|
||||
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>
|
||||
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
|
||||
@@ -265,41 +274,36 @@ export default function OfflineSalesUpload({
|
||||
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];
|
||||
{/* 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);
|
||||
}}
|
||||
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>
|
||||
/>
|
||||
{/* `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
|
||||
@@ -345,37 +349,37 @@ export default function OfflineSalesUpload({
|
||||
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">
|
||||
<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) => (
|
||||
<tr key={s.locationid} className={s.errors > 0 ? 'bg-red-50' : 'bg-white'}>
|
||||
<td className="px-3 py-2 font-medium text-slate-800">
|
||||
<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>
|
||||
</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 ${
|
||||
</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}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -399,45 +403,45 @@ export default function OfflineSalesUpload({
|
||||
)}
|
||||
|
||||
<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">
|
||||
<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 (
|
||||
<tr
|
||||
<TableRow
|
||||
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">
|
||||
<TableCell className="font-mono text-slate-500">{r.excelRow}</TableCell>
|
||||
<TableCell className="text-slate-700">
|
||||
{r.locationname || `#${r.locationid}`}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
</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>
|
||||
</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">
|
||||
</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>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-slate-600">
|
||||
</TableCell>
|
||||
<TableCell className="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">
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-slate-500">{r.billno || '—'}</TableCell>
|
||||
<TableCell>
|
||||
{bad ? (
|
||||
<span className="text-red-700">{r.errors.join('; ')}</span>
|
||||
) : warn ? (
|
||||
@@ -447,12 +451,12 @@ export default function OfflineSalesUpload({
|
||||
<CheckCircle2 size={12} /> ready
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -476,29 +480,29 @@ export default function OfflineSalesUpload({
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{parsed && (
|
||||
<button
|
||||
<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"
|
||||
>
|
||||
<RotateCcw size={14} /> Clear
|
||||
</button>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<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()}
|
||||
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>
|
||||
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>
|
||||
)}
|
||||
@@ -563,29 +567,29 @@ function ResultPanel({
|
||||
</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">
|
||||
<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) => (
|
||||
<tr
|
||||
<TableRow
|
||||
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">
|
||||
<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>
|
||||
)}
|
||||
@@ -595,30 +599,30 @@ function ResultPanel({
|
||||
{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>
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
<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"
|
||||
>
|
||||
Upload Another File
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<Button
|
||||
label="Done"
|
||||
onClick={onClose}
|
||||
className="rounded-lg bg-[#662582] px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#551f6d]"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user