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(null); const [parsed, setParsed] = useState(null); const [readError, setReadError] = useState(null); const [result, setResult] = useState(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 (
{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.' : ''}
{result.results.map((entry, index) => ( ))}
Store Bill Result Items Amount
{entry.locationname || `Branch ${entry.locationid}`} {entry.billno || '—'} {count(entry.itemcount ?? 0)} {money(entry.amount ?? 0)}