store user login
This commit is contained in:
464
src/features/store-admin/offlineSales/CounterSalesDrawer.tsx
Normal file
464
src/features/store-admin/offlineSales/CounterSalesDrawer.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user