store user login

This commit is contained in:
2026-08-25 18:00:16 +05:30
parent 1dc582ba07
commit 517a99d577
62 changed files with 8059 additions and 650 deletions

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

View File

@@ -0,0 +1,163 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import * as XLSX from 'xlsx';
import type { SaleTemplate } from '@/api/offlineSales';
import {
buildTemplateWorkbook,
parseSalesWorkbook,
summarise,
templateFilename,
toBills,
} from './salesSheet';
const TEMPLATE: SaleTemplate = {
tenantid: 1135,
locationid: 0,
locations: [
{ locationid: 1, locationname: 'R mart — RS Puram', productcount: 2 },
{ locationid: 2, locationname: 'R mart — Peelamedu', productcount: 1 },
],
products: [
{ tenantid: 1135, locationid: 1, locationname: 'R mart — RS Puram', productid: 9001, productname: 'Aachi Chilli Powder 100gm', currentstock: 24, price: 35, taxpercent: 5 },
{ tenantid: 1135, locationid: 1, locationname: 'R mart — RS Puram', productid: 9004, productname: 'Amul Butter 500g', currentstock: 2, price: 265, taxpercent: 12 },
{ tenantid: 1135, locationid: 2, locationname: 'R mart — Peelamedu', productid: 9007, productname: 'Nestle Milkmaid 400g', currentstock: 40, price: 160, taxpercent: 12 },
],
};
/** Round-trip helper: build the workbook, fill in cells, hand back a buffer. */
function filled(edits: Record<number, Record<string, string | number>>): ArrayBuffer {
const workbook = buildTemplateWorkbook(TEMPLATE);
const sheet = workbook.Sheets['Sales'] as XLSX.WorkSheet;
const header = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1 })[0] as string[];
const column = (name: string) =>
header.findIndex((cell) => String(cell).toLowerCase().startsWith(name));
for (const [row, values] of Object.entries(edits)) {
for (const [name, value] of Object.entries(values)) {
const address = XLSX.utils.encode_cell({ r: Number(row) - 1, c: column(name) });
sheet[address] = typeof value === 'number' ? { t: 'n', v: value } : { t: 's', v: value };
}
}
const out = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer;
return out;
}
test('the workbook carries every product, ids filled in', () => {
const workbook = buildTemplateWorkbook(TEMPLATE);
assert.deepEqual(workbook.SheetNames, ['Sales', 'Store Info', 'Instructions']);
const rows = XLSX.utils.sheet_to_json<unknown[]>(workbook.Sheets['Sales'] as XLSX.WorkSheet, {
header: 1,
});
assert.equal(rows.length, 4, 'header plus three products');
assert.equal((rows[1] as unknown[])[3], 9001, 'productid is pre-filled');
assert.equal((rows[1] as unknown[])[6], '', 'qtysold is left empty, never defaulted to 0');
});
test('the filename says what the workbook spans', () => {
assert.equal(templateFilename(TEMPLATE, '2026-08-25'), 'counter-sales-all-stores-2026-08-25.xlsx');
assert.equal(
templateFilename({ ...TEMPLATE, locations: [TEMPLATE.locations[0] as never] }, '2026-08-25'),
'counter-sales-r-mart-rs-puram-2026-08-25.xlsx',
);
});
test('rows with no quantity are skipped, not reported as problems', () => {
const parsed = parseSalesWorkbook(filled({ 2: { qtysold: 3 } }), { tenantid: 1135 });
assert.deepEqual(parsed.fatal, []);
assert.equal(parsed.rows.length, 1);
assert.equal(parsed.skipped, 2);
assert.equal(parsed.rows[0]?.productid, 9001);
});
test('a file from another account is refused whole', () => {
const parsed = parseSalesWorkbook(filled({ 2: { qtysold: 1 } }), { tenantid: 9999 });
assert.ok(parsed.fatal[0]?.includes('different account'));
});
test('selling more than the shelf holds is an error on that row', () => {
const parsed = parseSalesWorkbook(filled({ 3: { qtysold: 5 } }), { tenantid: 1135 });
assert.equal(parsed.rows.length, 1);
assert.ok(parsed.rows[0]?.errors.some((problem) => problem.includes('only 2 in stock')));
});
test('a pinned upload refuses rows belonging to another branch', () => {
const parsed = parseSalesWorkbook(filled({ 2: { qtysold: 1 }, 4: { qtysold: 1 } }), {
tenantid: 1135,
locationid: 1,
});
const foreign = parsed.rows.find((row) => row.locationid === 2);
assert.ok(foreign, 'the row is still parsed, so the operator can see it');
assert.ok(foreign?.errors.some((problem) => problem.includes('another store')));
const own = parsed.rows.find((row) => row.locationid === 1);
assert.deepEqual(own?.errors, []);
});
test('an unpriced line is a warning, not a blocker', () => {
const parsed = parseSalesWorkbook(filled({ 2: { qtysold: 1, unitprice: 0 } }), {
tenantid: 1135,
});
assert.deepEqual(parsed.rows[0]?.errors, []);
assert.ok(parsed.rows[0]?.warnings.some((note) => note.includes('no price')));
});
test('an unknown payment mode is caught before upload', () => {
const parsed = parseSalesWorkbook(filled({ 2: { qtysold: 1, paymentmode: 'cheque' } }), {
tenantid: 1135,
});
assert.ok(parsed.rows[0]?.errors.some((problem) => problem.includes('not Cash, Card or UPI')));
});
test('the same bill number at two branches stays two bills', () => {
const parsed = parseSalesWorkbook(
filled({ 2: { qtysold: 1, billno: '1' }, 4: { qtysold: 1, billno: '1' } }),
{ tenantid: 1135 },
);
const bills = toBills(parsed.rows);
assert.equal(bills.length, 2);
assert.deepEqual(
bills.map((bill) => bill.locationid).sort(),
[1, 2],
);
});
test('rows sharing a bill number become one bill with its lines', () => {
const parsed = parseSalesWorkbook(
filled({ 2: { qtysold: 2, billno: 'A-7' }, 3: { qtysold: 1, billno: 'A-7' } }),
{ tenantid: 1135 },
);
const bills = toBills(parsed.rows);
assert.equal(bills.length, 1);
assert.equal(bills[0]?.items.length, 2);
assert.equal(bills[0]?.billno, 'A-7');
});
test('bill-level fields are taken from whichever row carries them', () => {
const parsed = parseSalesWorkbook(
filled({
2: { qtysold: 1, billno: 'B-2' },
3: { qtysold: 1, billno: 'B-2', paymentmode: 'UPI', customername: 'Kavitha' },
}),
{ tenantid: 1135 },
);
const bill = toBills(parsed.rows)[0];
assert.equal(bill?.paymentmode, 'UPI');
assert.equal(bill?.customername, 'Kavitha');
});
test('the preview total is unit price by quantity, less discount', () => {
const parsed = parseSalesWorkbook(
filled({ 2: { qtysold: 4, discountamount: 20 }, 4: { qtysold: 2 } }),
{ tenantid: 1135 },
);
const totals = summarise(parsed.rows);
assert.equal(totals.lines, 2);
assert.equal(totals.units, 6);
assert.equal(totals.amount, 35 * 4 - 20 + 160 * 2);
assert.equal(totals.stores, 2);
assert.equal(totals.bills, 2, 'no bill number means one bill per branch');
});
test('a file with nothing filled in says so rather than uploading nothing', () => {
const parsed = parseSalesWorkbook(filled({}), { tenantid: 1135 });
assert.ok(parsed.fatal[0]?.includes('No sold quantities'));
});

View File

@@ -0,0 +1,555 @@
import * as XLSX from 'xlsx';
import type {
OfflineSaleBillInput,
SaleTemplate,
SaleTemplateRow,
} from '@/api/offlineSales';
/**
* The spreadsheet half of counter-sales import: turning a merchant's catalogue
* into a workbook the shops fill in, and turning that workbook back into bills
* the API will take.
*
* **One workbook covers every branch.** Each row carries its own `tenantid` and
* `locationid`, and that row's `locationid` decides which branch the sale comes
* out of. A merchant running six outlets downloads one file; rows for all six
* can be filled in and uploaded together. Nobody picks a store in the UI,
* because the sheet already says which store each line is for.
*
* **The template must be downloaded, not typed.** `productid` is the only field
* that identifies a product: across the live catalogue 6,245 products share 93
* distinct SKU values — one tenant has 463 products all carrying sku "1" — and
* names are not unique either. So the sheet is generated with the ids already
* filled in, and the parser matches on nothing else.
*
* Parsing happens in the browser so the operator sees every problem laid out
* against their own rows and can fix the file before anything is written. The
* backend re-validates all of it; this is for the person, not for safety.
*/
/** Parsing looks for this sheet by name, then falls back to the first one, so a
* file re-saved by Excel under a translated name still works. */
export const SALES_SHEET = 'Sales';
export const INFO_SHEET = 'Store Info';
const HELP_SHEET = 'Instructions';
/** Bumped only when the column set changes in a way an old file would break on. */
export const TEMPLATE_VERSION = 2;
/**
* The columns, in order. The first six are locked reference data —
* `locationid` among them, since it routes the sale — and `qtysold` is the one
* the operator is expected to type in.
*/
const COLUMNS = [
'tenantid',
'locationid',
'locationname',
'productid',
'productname',
'currentstock',
'qtysold',
'unitprice',
'discountamount',
'taxpercent',
'billno',
'saledate',
'paymentmode',
'customername',
'customermobile',
'remarks',
] as const;
const COLUMN_WIDTHS = [10, 11, 22, 11, 38, 13, 10, 11, 15, 11, 14, 13, 13, 18, 15, 24];
const HEADER_LABELS: Record<string, string> = {
tenantid: 'tenantid (do not edit)',
locationid: 'locationid (do not edit)',
locationname: 'store (do not edit)',
productid: 'productid (do not edit)',
productname: 'productname (do not edit)',
currentstock: 'currentstock (info)',
qtysold: 'qtysold *',
unitprice: 'unitprice',
discountamount: 'discountamount',
taxpercent: 'taxpercent',
billno: 'billno',
saledate: 'saledate',
paymentmode: 'paymentmode',
customername: 'customername',
customermobile: 'customermobile',
remarks: 'remarks',
};
const INSTRUCTIONS: (string | number)[][] = [
['How to record counter sales'],
[''],
['This ONE file covers every one of your stores.'],
['Each row already says which store it belongs to, in the locationid and store columns.'],
[''],
['1.', 'Fill in the "qtysold" column for whatever was sold at the counter.'],
['', 'Leave the row blank or 0 if it did not sell — blank rows are ignored.'],
['2.', 'Do NOT edit tenantid, locationid, store, productid or productname.'],
['', 'They identify the store and the product, and must match.'],
['3.', 'unitprice defaults to that store price. Change it if you sold at a different price.'],
['4.', 'billno groups rows into one bill. Rows sharing a billno become one order.'],
['', 'Bill numbers only need to be unique within a store.'],
['5.', 'saledate accepts YYYY-MM-DD. Blank means today.'],
['6.', 'paymentmode accepts Cash, Card or UPI. Blank means Cash.'],
['7.', 'taxpercent is treated as already included in unitprice, so the amount collected'],
['', 'stays exactly unitprice x qtysold minus any discount.'],
[''],
['Uploading the same file twice is safe — a repeated bill is reported as already'],
['imported and its stock is NOT deducted a second time.'],
];
/**
* Build the workbook.
*
* Every stocked product gets a row, even at zero stock: this is a worksheet to
* fill in, and hiding a row would mean an operator could not record a sale they
* actually made.
*/
export function buildTemplateWorkbook(template: SaleTemplate): XLSX.WorkBook {
const header = COLUMNS.map((column) => HEADER_LABELS[column] ?? column);
const body = template.products.map((product: SaleTemplateRow) => [
product.tenantid,
product.locationid,
product.locationname,
product.productid,
product.productname,
product.currentstock,
// qtysold onward are the operator's columns, left empty: pre-filling
// qtysold with 0 invites a file of accidental zero-quantity rows.
'',
product.price > 0 ? product.price : '',
'',
product.taxpercent > 0 ? product.taxpercent : '',
'',
'',
'',
'',
'',
'',
]);
const sales = XLSX.utils.aoa_to_sheet([header, ...body]);
sales['!cols'] = COLUMN_WIDTHS.map((width) => ({ wch: width }));
sales['!autofilter'] = {
ref: XLSX.utils.encode_range({
s: { r: 0, c: 0 },
e: { r: body.length, c: COLUMNS.length - 1 },
}),
};
const info = XLSX.utils.aoa_to_sheet([
['Field', 'Value'],
['tenantid', template.tenantid],
['templateversion', TEMPLATE_VERSION],
['stores in this file', template.locations.length],
[''],
['Stores covered', 'Products'],
...template.locations.map((location) => [
`${location.locationid} — ${location.locationname}`,
location.productcount,
]),
[''],
['Do not edit this sheet.'],
]);
info['!cols'] = [{ wch: 34 }, { wch: 42 }];
const help = XLSX.utils.aoa_to_sheet(INSTRUCTIONS);
help['!cols'] = [{ wch: 4 }, { wch: 92 }];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sales, SALES_SHEET);
XLSX.utils.book_append_sheet(workbook, info, INFO_SHEET);
XLSX.utils.book_append_sheet(workbook, help, HELP_SHEET);
return workbook;
}
/** One store by name, or "all-stores", plus the date so a folder stays sortable. */
export function templateFilename(template: SaleTemplate, today: string): string {
const single = template.locations.length === 1 ? (template.locations[0]?.locationname ?? '') : '';
const slug =
(single || 'all-stores')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'all-stores';
return `counter-sales-${slug}-${today}.xlsx`;
}
/* ── Parsing ─────────────────────────────────────────────────────────────── */
export interface ParsedSaleRow {
/** 1-based row number as Excel shows it, so a problem can be pointed at. */
excelRow: number;
tenantid: number | null;
locationid: number;
locationname: string;
productid: number;
productname: string;
currentstock: number | null;
qtysold: number;
unitprice: number | null;
discountamount: number;
taxpercent: number | null;
billno: string;
saledate: string;
paymentmode: string;
customername: string;
customermobile: string;
remarks: string;
errors: string[];
warnings: string[];
}
export interface ParsedSheet {
tenantid: number | null;
/** Rows with a quantity. Blank ones are dropped, not reported as problems. */
rows: ParsedSaleRow[];
/** Rows skipped for having no quantity — counted so "the column was empty"
* can be told apart from "the file had two sales in it". */
skipped: number;
/** Problems with the file as a whole rather than with a row. */
fatal: string[];
}
export interface ParseScope {
tenantid: number;
/** Set for a Store user: rows for any other branch are refused. */
locationid?: number;
/** Branches the uploader may write to, for naming a stray id usefully. */
allowedLocationIds?: number[];
}
const PAYMENT_MODES = new Set(['cash', 'card', 'upi']);
function toNumber(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
const parsed = Number(String(value).trim().replace(/,/g, ''));
return Number.isFinite(parsed) ? parsed : null;
}
function toText(value: unknown): string {
if (value === null || value === undefined) return '';
return String(value).trim();
}
/**
* A date cell arrives as a real Date, a serial number, or text depending on how
* the operator's Excel was configured. All three become YYYY-MM-DD so the
* backend sees one format.
*/
function toDate(value: unknown): string {
if (value === null || value === undefined || value === '') return '';
if (value instanceof Date) return value.toISOString().slice(0, 10);
if (typeof value === 'number') {
const parsed = XLSX.SSF?.parse_date_code?.(value);
if (parsed && parsed.y) {
return `${parsed.y}-${String(parsed.m).padStart(2, '0')}-${String(parsed.d).padStart(2, '0')}`;
}
return '';
}
return String(value).trim();
}
/** Match a header cell back to a column, tolerating "(do not edit)" and "*". */
function normaliseHeader(raw: unknown): string {
return toText(raw)
.toLowerCase()
.replace(/\(.*?\)/g, '')
.replace(/[^a-z]/g, '');
}
/**
* Parse an uploaded workbook.
*
* Never throws for a row-level problem — those are attached to the row so the
* whole sheet can be shown at once, which is the point of parsing here. Only a
* file that cannot be read, or has no recognisable columns, is fatal.
*/
export function parseSalesWorkbook(data: ArrayBuffer, scope: ParseScope): ParsedSheet {
const out: ParsedSheet = { tenantid: null, rows: [], skipped: 0, fatal: [] };
let workbook: XLSX.WorkBook;
try {
workbook = XLSX.read(data, { cellDates: true });
} catch {
out.fatal.push('That file could not be read as a spreadsheet. Upload the .xlsx template.');
return out;
}
const infoSheet = workbook.Sheets[INFO_SHEET];
if (infoSheet) {
const infoRows = XLSX.utils.sheet_to_json<unknown[]>(infoSheet, {
header: 1,
blankrows: false,
});
for (const row of infoRows) {
if (toText(row?.[0]).toLowerCase() === 'tenantid') out.tenantid = toNumber(row?.[1]);
}
}
if (out.tenantid !== null && out.tenantid !== scope.tenantid) {
out.fatal.push(
`This file was generated for a different account (tenant ${out.tenantid}). Download a fresh template.`,
);
}
const firstName = workbook.SheetNames[0];
const sheet = workbook.Sheets[SALES_SHEET] ?? (firstName ? workbook.Sheets[firstName] : undefined);
if (!sheet) {
out.fatal.push('The workbook has no sheets.');
return out;
}
const grid = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
header: 1,
blankrows: false,
defval: '',
});
if (grid.length < 2) {
out.fatal.push('The Sales sheet has no rows to import.');
return out;
}
const index: Record<string, number> = {};
(grid[0] ?? []).forEach((cellValue, position) => {
const key = normaliseHeader(cellValue);
if (key && index[key] === undefined) index[key] = position;
});
if (index['productid'] === undefined || index['qtysold'] === undefined) {
out.fatal.push(
'The Sales sheet is missing the "productid" or "qtysold" column. Upload the downloaded template without renaming its columns.',
);
return out;
}
if (index['locationid'] === undefined && !scope.locationid) {
// Without it there is nothing to route a sale by, and guessing a branch
// would silently move the wrong store's stock.
out.fatal.push(
'The Sales sheet is missing the "locationid" column, so there is no way to tell which store each sale belongs to. Download a fresh template.',
);
return out;
}
const allowed = scope.allowedLocationIds?.length ? new Set(scope.allowedLocationIds) : null;
const cell = (row: unknown[], key: string): unknown => {
const position = index[key];
return position === undefined ? '' : row[position];
};
for (let i = 1; i < grid.length; i += 1) {
const raw = grid[i] ?? [];
const excelRow = i + 1;
const qty = toNumber(cell(raw, 'qtysold'));
// Nothing sold on this line. Not an error — the template lists an entire
// catalogue and most rows are expected to be empty.
if (qty === null || qty === 0) {
out.skipped += 1;
continue;
}
const productid = toNumber(cell(raw, 'productid'));
const rowLocation = toNumber(cell(raw, 'locationid'));
const unitprice = toNumber(cell(raw, 'unitprice'));
const taxpercent = toNumber(cell(raw, 'taxpercent'));
const discount = toNumber(cell(raw, 'discountamount')) ?? 0;
const stock = toNumber(cell(raw, 'currentstock'));
const paymentmode = toText(cell(raw, 'paymentmode'));
const locationid = rowLocation ?? scope.locationid ?? 0;
const row: ParsedSaleRow = {
excelRow,
tenantid: toNumber(cell(raw, 'tenantid')),
locationid,
locationname: toText(cell(raw, 'locationname')),
productid: productid ?? 0,
productname: toText(cell(raw, 'productname')),
currentstock: stock,
qtysold: qty,
unitprice,
discountamount: discount,
taxpercent,
billno: toText(cell(raw, 'billno')),
saledate: toDate(cell(raw, 'saledate')),
paymentmode,
customername: toText(cell(raw, 'customername')),
customermobile: toText(cell(raw, 'customermobile')),
remarks: toText(cell(raw, 'remarks')),
errors: [],
warnings: [],
};
if (!productid || productid <= 0) {
row.errors.push('productid is missing — do not delete that column');
}
if (!locationid || locationid <= 0) {
row.errors.push('locationid is missing — this row does not say which store it belongs to');
} else if (row.tenantid !== null && row.tenantid !== scope.tenantid) {
row.errors.push(`tenantid ${row.tenantid} is not your account`);
} else if (scope.locationid && locationid !== scope.locationid) {
// The Store user guard. The backend enforces it too; saying it here means
// they see it before uploading rather than as a rejected bill.
row.errors.push('this row is for another store, which you cannot upload for');
} else if (allowed && !allowed.has(locationid)) {
row.errors.push(`locationid ${locationid} is not one of your stores`);
}
if (qty < 0) row.errors.push('qtysold cannot be negative');
if (stock !== null && qty > stock) {
row.errors.push(`only ${stock} in stock, ${qty} sold`);
}
if (discount < 0) row.errors.push('discountamount cannot be negative');
if (unitprice !== null && unitprice > 0 && discount > unitprice * qty) {
row.errors.push('discount is larger than the line total');
}
if (paymentmode && !PAYMENT_MODES.has(paymentmode.toLowerCase())) {
row.errors.push(`paymentmode "${paymentmode}" is not Cash, Card or UPI`);
}
// Warnings do not block. A zero price is legitimate — a free sample — but
// is nearly always a forgotten cell, so it is worth saying.
if (unitprice === null || unitprice <= 0) {
row.warnings.push('no price — this sale will record no revenue');
}
if (!Number.isInteger(qty)) row.warnings.push('fractional quantity');
out.rows.push(row);
}
if (out.rows.length === 0 && out.fatal.length === 0) {
out.fatal.push('No sold quantities found. Fill in the "qtysold" column for at least one row.');
}
return out;
}
/**
* Group rows into bills.
*
* Keyed on BRANCH first and bill number second, so the same bill number at two
* stores stays two sales rather than colliding — counter books at different
* outlets routinely restart numbering from 1.
*
* Rows with no bill number collapse into one unnumbered bill per branch rather
* than one bill per row: a sheet where the operator ignored the column is one
* shopping trip far more often than fifty, and one bill per row would also mean
* one order per row cluttering the order list.
*/
export function toBills(rows: ParsedSaleRow[]): OfflineSaleBillInput[] {
const groups = new Map<string, ParsedSaleRow[]>();
for (const row of rows) {
const key = billKey(row);
const bucket = groups.get(key);
if (bucket) bucket.push(row);
else groups.set(key, [row]);
}
return [...groups.values()].map((group) => {
const head = group[0] as ParsedSaleRow;
// Bill-level fields belong to the bill, not the line: taking the first
// non-empty value means the operator fills them in on the first row only,
// which is how people actually fill these in.
const first = (pick: (row: ParsedSaleRow) => string): string =>
group.map(pick).find((value) => value !== '') ?? '';
return {
locationid: head.locationid,
billno: head.billno.trim(),
saledate: first((row) => row.saledate),
paymentmode: first((row) => row.paymentmode),
customername: first((row) => row.customername),
customermobile: first((row) => row.customermobile),
remarks: first((row) => row.remarks),
items: group.map((row) => ({
productid: row.productid,
productname: row.productname || undefined,
qtysold: row.qtysold,
unitprice: row.unitprice ?? undefined,
discountamount: row.discountamount || undefined,
taxpercent: row.taxpercent ?? undefined,
})),
};
});
}
function billKey(row: ParsedSaleRow): string {
return `${row.locationid}::${row.billno.trim().toUpperCase() || '__nobill__'}`;
}
export interface SaleSummary {
lines: number;
units: number;
amount: number;
errors: number;
warnings: number;
bills: number;
stores: number;
byStore: {
locationid: number;
locationname: string;
lines: number;
units: number;
amount: number;
errors: number;
}[];
}
/**
* Totals for the preview.
*
* The arithmetic mirrors the backend's — tax inclusive — so the figure shown
* before the upload is the figure that gets recorded.
*/
export function summarise(rows: ParsedSaleRow[]): SaleSummary {
let units = 0;
let amount = 0;
let errors = 0;
let warnings = 0;
const bills = new Set<string>();
const stores = new Map<number, SaleSummary['byStore'][number]>();
for (const row of rows) {
const lineAmount = Math.max(0, (row.unitprice ?? 0) * row.qtysold - row.discountamount);
units += row.qtysold;
amount += lineAmount;
if (row.errors.length) errors += 1;
if (row.warnings.length) warnings += 1;
bills.add(billKey(row));
let store = stores.get(row.locationid);
if (!store) {
store = {
locationid: row.locationid,
locationname: row.locationname,
lines: 0,
units: 0,
amount: 0,
errors: 0,
};
stores.set(row.locationid, store);
}
store.lines += 1;
store.units += row.qtysold;
store.amount += lineAmount;
if (row.errors.length) store.errors += 1;
if (!store.locationname && row.locationname) store.locationname = row.locationname;
}
return {
lines: rows.length,
units,
amount,
errors,
warnings,
bills: bills.size,
stores: stores.size,
byStore: [...stores.values()].sort((a, b) => b.amount - a.amount),
};
}