Files
daily_console_web/src/features/store-admin/offlineSales/CounterSalesDrawer.tsx
2026-08-25 18:00:16 +05:30

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