465 lines
16 KiB
TypeScript
465 lines
16 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Button } from '@astryxdesign/core/Button';
|
|
import { Card } from '@astryxdesign/core/Card';
|
|
import { FileInput } from '@astryxdesign/core/FileInput';
|
|
import { HStack } from '@astryxdesign/core/HStack';
|
|
import { Text } from '@astryxdesign/core/Text';
|
|
import { VStack } from '@astryxdesign/core/VStack';
|
|
import * as XLSX from 'xlsx';
|
|
import { Download, Upload } from 'lucide-react';
|
|
import { errorMessage } from '@/api/client';
|
|
import { offlineSalesApi, type OfflineSalesUploadResponse } from '@/api/offlineSales';
|
|
import { useAuth } from '@/auth/AuthContext';
|
|
import { count, money } from '../format';
|
|
import { Drawer } from '../Drawer';
|
|
import {
|
|
buildTemplateWorkbook,
|
|
parseSalesWorkbook,
|
|
summarise,
|
|
templateFilename,
|
|
toBills,
|
|
type ParsedSheet,
|
|
} from './salesSheet';
|
|
|
|
/**
|
|
* Counter sales, from a spreadsheet.
|
|
*
|
|
* Three steps, in the order the person does them: download the workbook, fill
|
|
* in what sold, upload it back. The middle step happens in Excel, so the drawer
|
|
* has to survive being left open for an hour — which is why nothing here is
|
|
* timed and the template refetches on open rather than being cached.
|
|
*
|
|
* The preview between upload and import is the point of the whole screen: an
|
|
* import moves stock, and a row that would move the wrong stock has to be
|
|
* visible before it does, not reported afterwards.
|
|
*/
|
|
export function CounterSalesDrawer({
|
|
tenantid,
|
|
/** Set for a Store user — pins the upload to their branch. */
|
|
locationid,
|
|
branchIds,
|
|
onClose,
|
|
}: {
|
|
tenantid: number;
|
|
locationid?: number;
|
|
branchIds: number[];
|
|
onClose: () => void;
|
|
}) {
|
|
const { user } = useAuth();
|
|
const client = useQueryClient();
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
|
|
const [readError, setReadError] = useState<string | null>(null);
|
|
const [result, setResult] = useState<OfflineSalesUploadResponse | null>(null);
|
|
|
|
/**
|
|
* `staleTime: 0` on purpose. A template is only useful if its stock figures
|
|
* and product list match the shops right now — an hour-old one sends someone
|
|
* to fill in a row for a product that has since been withdrawn.
|
|
*/
|
|
const template = useQuery({
|
|
queryKey: ['saleTemplate', tenantid, locationid ?? 0],
|
|
queryFn: () => offlineSalesApi.template(tenantid, locationid),
|
|
staleTime: 0,
|
|
});
|
|
|
|
const rows = parsed?.rows ?? [];
|
|
const totals = useMemo(() => summarise(rows), [rows]);
|
|
const isBlocked = (parsed?.fatal.length ?? 0) > 0 || totals.errors > 0;
|
|
|
|
async function onFile(next: File | null) {
|
|
setFile(next);
|
|
setParsed(null);
|
|
setReadError(null);
|
|
if (!next) return;
|
|
try {
|
|
const buffer = await next.arrayBuffer();
|
|
setParsed(
|
|
parseSalesWorkbook(buffer, {
|
|
tenantid,
|
|
...(locationid ? { locationid } : {}),
|
|
allowedLocationIds: branchIds,
|
|
}),
|
|
);
|
|
} catch {
|
|
setReadError('That file could not be read. Upload the .xlsx template you downloaded.');
|
|
}
|
|
}
|
|
|
|
function download() {
|
|
if (!template.data) return;
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
XLSX.writeFile(buildTemplateWorkbook(template.data), templateFilename(template.data, today));
|
|
}
|
|
|
|
const upload = useMutation({
|
|
mutationFn: () =>
|
|
offlineSalesApi.upload({
|
|
tenantid,
|
|
// Sent even when it is 0. At 0 each bill goes to the branch its own row
|
|
// names; set, it refuses every bill naming another branch — the one
|
|
// server-side guard that holds a shop to its own store.
|
|
locationid: locationid ?? 0,
|
|
userid: user?.userid ?? 0,
|
|
bills: toBills(rows),
|
|
}),
|
|
onSuccess: async (response) => {
|
|
setResult(response);
|
|
// An import moves stock and creates orders, so everything counting either
|
|
// is stale. Cheaper to refetch broadly than to reason about which page
|
|
// the operator opens next.
|
|
await client.invalidateQueries();
|
|
},
|
|
});
|
|
|
|
if (result) {
|
|
return (
|
|
<Drawer title="Import finished" width={620} onClose={onClose}>
|
|
<div className="form-grid">
|
|
<Figure label="Imported" value={count(result.imported)} isStrong />
|
|
<Figure label="Already done" value={count(result.duplicate)} />
|
|
<Figure label="Failed" value={count(result.failed)} />
|
|
<Figure label="Recorded" value={money(result.totalamount)} />
|
|
</div>
|
|
|
|
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
|
|
{result.imported > 0
|
|
? 'Stock has come off the shelf and the sales are in Orders, marked offline.'
|
|
: 'Nothing was imported, so no stock moved.'}
|
|
{result.duplicate > 0
|
|
? ' Bills marked “already done” were uploaded before — their stock was not deducted twice.'
|
|
: ''}
|
|
</Text>
|
|
|
|
<Card padding={0} elevation="low">
|
|
<div className="table-scroll">
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
|
<thead>
|
|
<tr>
|
|
<Th>Store</Th>
|
|
<Th>Bill</Th>
|
|
<Th>Result</Th>
|
|
<Th>Items</Th>
|
|
<Th>Amount</Th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{result.results.map((entry, index) => (
|
|
<tr key={`${entry.locationid}-${entry.billno}-${index}`}>
|
|
<Td>{entry.locationname || `Branch ${entry.locationid}`}</Td>
|
|
<Td>{entry.billno || '—'}</Td>
|
|
<Td>
|
|
<ResultChip status={entry.status} message={entry.message} />
|
|
</Td>
|
|
<Td>{count(entry.itemcount ?? 0)}</Td>
|
|
<Td>{money(entry.amount ?? 0)}</Td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
<HStack gap={1} justify="end">
|
|
<Button
|
|
label="Upload another file"
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => {
|
|
setResult(null);
|
|
setParsed(null);
|
|
setFile(null);
|
|
}}
|
|
/>
|
|
<Button label="Done" variant="primary" size="sm" onClick={onClose} />
|
|
</HStack>
|
|
</Drawer>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Drawer
|
|
title="Upload counter sales"
|
|
subtitle={
|
|
locationid
|
|
? 'Sales rung up at your counter, so the stock comes off the shelf.'
|
|
: 'One file covers every branch — each row says which store it belongs to.'
|
|
}
|
|
width={720}
|
|
onClose={onClose}
|
|
>
|
|
{/* ── 1. The workbook ────────────────────────────────────────────── */}
|
|
<VStack gap={1}>
|
|
<Step number={1} title="Download the workbook" />
|
|
<HStack gap={1.5} align="center" wrap="wrap">
|
|
<Button
|
|
label={template.isLoading ? 'Preparing…' : 'Download template'}
|
|
variant="secondary"
|
|
size="sm"
|
|
icon={<Download size={13} />}
|
|
isDisabled={!template.data || template.isLoading}
|
|
onClick={download}
|
|
/>
|
|
{template.data ? (
|
|
<Text type="body" size="sm" color="secondary">
|
|
{count(template.data.products.length)} products ·{' '}
|
|
{count(template.data.locations.length)} store
|
|
{template.data.locations.length === 1 ? '' : 's'}
|
|
</Text>
|
|
) : null}
|
|
</HStack>
|
|
{template.isError ? (
|
|
<Problem message={errorMessage(template.error)} />
|
|
) : (
|
|
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
|
|
Fill in the <strong>qtysold</strong> column and upload the file back. It has to be this
|
|
workbook — the product ids are already in it, and nothing else identifies a product.
|
|
</Text>
|
|
)}
|
|
</VStack>
|
|
|
|
{/* ── 2. The filled-in file ──────────────────────────────────────── */}
|
|
<VStack gap={1}>
|
|
<Step number={2} title="Upload it back" />
|
|
<FileInput
|
|
label="Filled-in workbook"
|
|
isLabelHidden
|
|
value={file}
|
|
onChange={(next) => void onFile(Array.isArray(next) ? (next[0] ?? null) : next)}
|
|
accept=".xlsx,.xls"
|
|
mode="dropzone"
|
|
placeholder="Drop the .xlsx file here, or click to choose"
|
|
/>
|
|
{readError ? <Problem message={readError} /> : null}
|
|
{parsed?.fatal.map((message) => <Problem key={message} message={message} />)}
|
|
</VStack>
|
|
|
|
{/* ── 3. What is in it ───────────────────────────────────────────── */}
|
|
{parsed && parsed.fatal.length === 0 ? (
|
|
<VStack gap={1.5}>
|
|
<Step number={3} title="Check and import" />
|
|
|
|
<div className="form-grid">
|
|
<Figure label="Bills" value={count(totals.bills)} isStrong />
|
|
<Figure label="Lines" value={count(totals.lines)} />
|
|
<Figure label="Units" value={count(totals.units)} />
|
|
<Figure label="Amount" value={money(totals.amount)} isStrong />
|
|
</div>
|
|
|
|
{totals.errors > 0 ? (
|
|
<Problem
|
|
message={`${count(totals.errors)} row${totals.errors === 1 ? '' : 's'} must be fixed in the file before this can be uploaded.`}
|
|
/>
|
|
) : null}
|
|
{parsed.skipped > 0 ? (
|
|
<Text type="body" size="sm" color="secondary">
|
|
{count(parsed.skipped)} row{parsed.skipped === 1 ? '' : 's'} had no quantity and were
|
|
ignored.
|
|
</Text>
|
|
) : null}
|
|
|
|
<Card padding={0} elevation="low">
|
|
<div className="table-scroll" style={{ maxHeight: 320, overflowY: 'auto' }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
|
<thead>
|
|
<tr>
|
|
<Th>Row</Th>
|
|
{locationid ? null : <Th>Store</Th>}
|
|
<Th>Product</Th>
|
|
<Th>Qty</Th>
|
|
<Th>Price</Th>
|
|
<Th>Bill</Th>
|
|
<Th>Status</Th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row) => (
|
|
<tr key={row.excelRow}>
|
|
<Td isMuted>{row.excelRow}</Td>
|
|
{locationid ? null : (
|
|
<Td isMuted>{row.locationname || row.locationid}</Td>
|
|
)}
|
|
<Td>{row.productname || `#${row.productid}`}</Td>
|
|
<Td>{row.qtysold}</Td>
|
|
<Td>{row.unitprice ? money(row.unitprice) : '—'}</Td>
|
|
<Td isMuted>{row.billno || '—'}</Td>
|
|
<Td>
|
|
<RowStatus errors={row.errors} warnings={row.warnings} />
|
|
</Td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
{upload.isError ? <Problem message={errorMessage(upload.error)} /> : null}
|
|
|
|
<HStack gap={1} justify="end" wrap="wrap">
|
|
<Button
|
|
label="Clear"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setParsed(null);
|
|
setFile(null);
|
|
}}
|
|
/>
|
|
<Button
|
|
label={upload.isPending ? 'Importing…' : `Import ${count(totals.bills)} bills`}
|
|
variant="primary"
|
|
size="sm"
|
|
icon={<Upload size={13} />}
|
|
isDisabled={isBlocked || upload.isPending || rows.length === 0}
|
|
onClick={() => upload.mutate()}
|
|
/>
|
|
</HStack>
|
|
</VStack>
|
|
) : null}
|
|
</Drawer>
|
|
);
|
|
}
|
|
|
|
/* ── Bits ────────────────────────────────────────────────────────────────── */
|
|
|
|
function Step({ number, title }: { number: number; title: string }) {
|
|
return (
|
|
<HStack gap={1} align="center">
|
|
<span
|
|
style={{
|
|
width: 20,
|
|
height: 20,
|
|
flex: 'none',
|
|
display: 'grid',
|
|
placeItems: 'center',
|
|
borderRadius: 999,
|
|
background: 'var(--color-brand-tint)',
|
|
color: 'var(--color-brand)',
|
|
fontSize: 11,
|
|
fontWeight: 700,
|
|
}}
|
|
>
|
|
{number}
|
|
</span>
|
|
<Text type="label" size="sm" weight="semibold">
|
|
{title}
|
|
</Text>
|
|
</HStack>
|
|
);
|
|
}
|
|
|
|
function Figure({ label, value, isStrong }: { label: string; value: string; isStrong?: boolean }) {
|
|
return (
|
|
<VStack gap={0}>
|
|
<Text
|
|
type="label"
|
|
size="xsm"
|
|
color="secondary"
|
|
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
|
|
>
|
|
{label}
|
|
</Text>
|
|
<Text
|
|
type="label"
|
|
size={isStrong ? 'lg' : 'sm'}
|
|
weight="semibold"
|
|
style={{ fontVariantNumeric: 'tabular-nums' }}
|
|
>
|
|
{value}
|
|
</Text>
|
|
</VStack>
|
|
);
|
|
}
|
|
|
|
function RowStatus({ errors, warnings }: { errors: string[]; warnings: string[] }) {
|
|
if (errors.length > 0) {
|
|
return (
|
|
<span style={{ color: 'var(--color-error, #d64545)', fontSize: 12 }}>{errors.join(' · ')}</span>
|
|
);
|
|
}
|
|
if (warnings.length > 0) {
|
|
return (
|
|
<span style={{ color: 'var(--color-warning, #b7860b)', fontSize: 12 }}>
|
|
{warnings.join(' · ')}
|
|
</span>
|
|
);
|
|
}
|
|
return <span style={{ color: 'var(--color-success, #10b981)', fontSize: 12 }}>ready</span>;
|
|
}
|
|
|
|
/**
|
|
* A duplicate is neutral on purpose.
|
|
*
|
|
* A re-upload being refused is the safeguard working. Calling it an error would
|
|
* push someone towards "fixing" it, and the fix they would reach for is
|
|
* changing the bill number — which is exactly how stock gets deducted twice.
|
|
*/
|
|
function ResultChip({ status, message }: { status: string; message?: string }) {
|
|
const value = status.toLowerCase();
|
|
const color =
|
|
value === 'imported'
|
|
? 'var(--color-success, #10b981)'
|
|
: value === 'duplicate'
|
|
? 'var(--color-ink-3)'
|
|
: 'var(--color-error, #d64545)';
|
|
const label = value === 'duplicate' ? 'already done' : value;
|
|
|
|
return (
|
|
<VStack gap={0}>
|
|
<span style={{ color, fontSize: 12, fontWeight: 600 }}>{label}</span>
|
|
{message ? (
|
|
<span style={{ color: 'var(--color-ink-4)', fontSize: 11.5 }}>{message}</span>
|
|
) : null}
|
|
</VStack>
|
|
);
|
|
}
|
|
|
|
function Problem({ message }: { message: string }) {
|
|
return (
|
|
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)', lineHeight: 1.6 }}>
|
|
{message}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
function Th({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<th
|
|
style={{
|
|
textAlign: 'center',
|
|
padding: '10px',
|
|
borderBottom: '1px solid var(--color-line)',
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
color: 'var(--color-ink-3)',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{children}
|
|
</th>
|
|
);
|
|
}
|
|
|
|
function Td({
|
|
children,
|
|
isMuted,
|
|
}: {
|
|
children: React.ReactNode;
|
|
isMuted?: boolean;
|
|
}) {
|
|
return (
|
|
<td
|
|
style={{
|
|
textAlign: 'center',
|
|
padding: '10px',
|
|
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
|
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
|
verticalAlign: 'middle',
|
|
}}
|
|
>
|
|
{children}
|
|
</td>
|
|
);
|
|
}
|