page wise astryx conversion completed

This commit is contained in:
2026-08-14 12:33:42 +05:30
parent 0daec5311d
commit cd079950eb
18 changed files with 1154 additions and 696 deletions

View File

@@ -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>
);