offline sales upload
This commit is contained in:
106
package-lock.json
generated
106
package-lock.json
generated
@@ -25,7 +25,8 @@
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.2.0",
|
||||
"recharts": "^3.8.1",
|
||||
"vite": "^6.2.3"
|
||||
"vite": "^6.2.3",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
@@ -1810,6 +1811,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -2040,6 +2050,19 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/claude": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/claude/-/claude-0.1.1.tgz",
|
||||
@@ -2055,6 +2078,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -2097,6 +2129,18 @@
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
@@ -2619,6 +2663,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@@ -4074,6 +4127,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -4821,6 +4886,24 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
@@ -4842,6 +4925,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.2.0",
|
||||
"recharts": "^3.8.1",
|
||||
"vite": "^6.2.3"
|
||||
"vite": "^6.2.3",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
|
||||
@@ -55,6 +55,7 @@ import AwaitingApi from './AwaitingApi';
|
||||
import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi';
|
||||
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
||||
import CatalogueBrowser from './CatalogueBrowser';
|
||||
import OfflineSalesUpload from './OfflineSalesUpload';
|
||||
import { useCompare } from '../contexts/CompareContext';
|
||||
|
||||
|
||||
@@ -79,6 +80,7 @@ export default function InventoryView({
|
||||
const { setHideCompareBar } = useCompare();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showCatalogueModal, setShowCatalogueModal] = useState(false);
|
||||
const [showOfflineSales, setShowOfflineSales] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ── Live stock across every outlet (Fiesta) ───────────────────────────────
|
||||
@@ -528,6 +530,13 @@ export default function InventoryView({
|
||||
<span className="ml-1 bg-rose-500 text-white text-[8px] px-1 py-0.5 rounded-sm leading-none">{storeRequests.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowOfflineSales(true)}
|
||||
title="Upload an Excel sheet of counter sales to deduct stock and record the revenue"
|
||||
className="flex items-center gap-1.5 bg-slate-50 hover:bg-slate-100 border border-slate-200 text-slate-700 px-2.5 py-1.5 rounded-lg text-[10px] font-bold transition-colors shadow-sm cursor-pointer h-[32px] whitespace-nowrap"
|
||||
>
|
||||
<UploadCloud size={12} /> Offline Sales
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('global_catalogue')}
|
||||
className="flex items-center gap-1.5 bg-[#662582] hover:bg-purple-800 text-white px-2.5 py-1.5 rounded-lg text-[10px] font-bold transition-colors shadow-sm cursor-pointer h-[32px] whitespace-nowrap"
|
||||
@@ -1098,6 +1107,18 @@ export default function InventoryView({
|
||||
})()}
|
||||
</SlideDrawer>
|
||||
|
||||
{/* Offline (counter) sales import. `locations` is passed so an admin picks
|
||||
the branch the bills belong to — this view otherwise operates on the
|
||||
tenant's first outlet, which would silently credit the wrong store. */}
|
||||
{showOfflineSales && (
|
||||
<OfflineSalesUpload
|
||||
tenantId={tenantId}
|
||||
locationId={primaryLocationId}
|
||||
locations={locations.map(({ locationid, locationname }) => ({ locationid, locationname }))}
|
||||
onClose={() => setShowOfflineSales(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
576
src/components/OfflineSalesUpload.tsx
Normal file
576
src/components/OfflineSalesUpload.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Offline (counter) sales import.
|
||||
*
|
||||
* A sale rung up at the till never passes through the app, so nothing deducts
|
||||
* its stock. This is the way that stock gets deducted: download a spreadsheet
|
||||
* pre-filled with the outlet's catalogue, type sold quantities into it, upload
|
||||
* it back. Imported sales become real orders, so they reduce stock through the
|
||||
* same path an app order uses and show up in revenue reporting.
|
||||
*
|
||||
* The template must be downloaded rather than hand-written because `productid`
|
||||
* is the only usable key for a product — SKUs are not unique in this catalogue
|
||||
* (6,245 products share 93 sku values) and names are not unique either. The
|
||||
* download fills productid in so nobody has to know it.
|
||||
*
|
||||
* Used by both surfaces. The admin console passes the outlet it has selected;
|
||||
* the store user's page passes their own, which is the only one they can reach.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Upload,
|
||||
X,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getSaleTemplate,
|
||||
uploadOfflineSales,
|
||||
type OfflineSalesUploadResponse,
|
||||
} from '../services/fiestaApi';
|
||||
import {
|
||||
downloadSaleTemplate,
|
||||
parseSalesWorkbook,
|
||||
summarise,
|
||||
toBills,
|
||||
type ParsedSheet,
|
||||
} from '../services/offlineSalesSheet';
|
||||
|
||||
interface OfflineSalesUploadProps {
|
||||
tenantId: number;
|
||||
/** The outlet to credit. For a store user this is their own and is the only
|
||||
* one reachable; for an admin it is the initial selection in `locations`. */
|
||||
locationId: number;
|
||||
/** Shown in the header so it is unambiguous which store is being credited. */
|
||||
storeName?: string;
|
||||
userId?: number;
|
||||
/**
|
||||
* Outlets the user may choose between. Passed by the admin console, whose
|
||||
* users manage several branches and must say which one a bill belongs to.
|
||||
* Omitted for a store user, which locks the import to their own outlet — the
|
||||
* backend enforces the same thing regardless of what the file says.
|
||||
*/
|
||||
locations?: { locationid: number; locationname: string }[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const money = (n: number) =>
|
||||
`₹${n.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
|
||||
export default function OfflineSalesUpload({
|
||||
tenantId,
|
||||
locationId,
|
||||
storeName,
|
||||
userId,
|
||||
locations,
|
||||
onClose,
|
||||
}: OfflineSalesUploadProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [activeLocationId, setActiveLocationId] = useState(locationId);
|
||||
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [result, setResult] = useState<OfflineSalesUploadResponse | null>(null);
|
||||
const [uploadError, setUploadError] = useState('');
|
||||
|
||||
const templateQuery = useQuery({
|
||||
queryKey: ['saleTemplate', tenantId, activeLocationId],
|
||||
queryFn: () => getSaleTemplate({ tenantid: tenantId, locationid: activeLocationId }),
|
||||
enabled: tenantId > 0 && activeLocationId > 0,
|
||||
// Always refetched on open: a template is only useful if its stock figures
|
||||
// and product list match the outlet right now.
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const summary = useMemo(() => (parsed ? summarise(parsed.rows) : null), [parsed]);
|
||||
const blocked = Boolean(parsed && (parsed.fatal.length > 0 || (summary?.errors ?? 0) > 0));
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!parsed) throw new Error('No file loaded.');
|
||||
return uploadOfflineSales({
|
||||
tenantid: tenantId,
|
||||
locationid: activeLocationId,
|
||||
userid: userId,
|
||||
bills: toBills(parsed.rows),
|
||||
});
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
setResult(res);
|
||||
setUploadError('');
|
||||
// Stock has moved, so every view reading it is now stale. Invalidating
|
||||
// broadly is deliberate — a partially-refreshed inventory screen after an
|
||||
// import is worse than a few extra refetches.
|
||||
queryClient.invalidateQueries();
|
||||
},
|
||||
onError: (err: Error) => setUploadError(err.message),
|
||||
});
|
||||
|
||||
const loadFile = useCallback(
|
||||
async (file: File) => {
|
||||
setResult(null);
|
||||
setUploadError('');
|
||||
setFileName(file.name);
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
setParsed(parseSalesWorkbook(buffer, { tenantid: tenantId, locationid: activeLocationId }));
|
||||
} catch {
|
||||
setParsed({
|
||||
tenantid: null,
|
||||
locationid: null,
|
||||
locationname: '',
|
||||
rows: [],
|
||||
skipped: 0,
|
||||
fatal: ['That file could not be read. Upload the .xlsx template you downloaded.'],
|
||||
});
|
||||
}
|
||||
},
|
||||
[tenantId, activeLocationId],
|
||||
);
|
||||
|
||||
const reset = () => {
|
||||
setParsed(null);
|
||||
setFileName('');
|
||||
setResult(null);
|
||||
setUploadError('');
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const picker = locations && locations.length > 1 ? locations : null;
|
||||
const outletLabel =
|
||||
picker?.find((l) => l.locationid === activeLocationId)?.locationname ||
|
||||
storeName ||
|
||||
templateQuery.data?.locationname ||
|
||||
`Outlet ${activeLocationId}`;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-900/50" onClick={onClose} />
|
||||
|
||||
<div className="relative z-10 flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl bg-white shadow-2xl">
|
||||
<div className="flex shrink-0 items-center justify-between bg-[#662582] px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileSpreadsheet size={20} className="text-white/90" />
|
||||
<div>
|
||||
<h2 className="text-lg font-bold tracking-tight text-white">Offline Sales Upload</h2>
|
||||
<p className="text-xs text-white/70">
|
||||
Counter sales for <span className="font-semibold text-white/90">{outletLabel}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-white/80 transition-colors hover:bg-white/10 hover:text-white"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{result ? (
|
||||
<ResultPanel result={result} onAnother={reset} onClose={onClose} />
|
||||
) : (
|
||||
<>
|
||||
{/* Which branch. Only rendered for a user who has more than one —
|
||||
a store user has no choice to make and showing them a picker
|
||||
would imply they do. Changing it clears any loaded file, since
|
||||
a file's productids belong to the outlet it was generated for. */}
|
||||
{picker && (
|
||||
<section className="mb-6 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<label className="block text-xs font-bold text-slate-800" htmlFor="offline-outlet">
|
||||
Which store are these sales from?
|
||||
</label>
|
||||
<select
|
||||
id="offline-outlet"
|
||||
value={activeLocationId}
|
||||
onChange={(e) => {
|
||||
setActiveLocationId(Number(e.target.value));
|
||||
reset();
|
||||
}}
|
||||
className="mt-2 w-full max-w-md rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-800 focus:border-[#662582] focus:outline-none"
|
||||
>
|
||||
{picker.map((l) => (
|
||||
<option key={l.locationid} value={l.locationid}>
|
||||
{l.locationname}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-2 text-[11px] text-slate-600">
|
||||
Stock is deducted from this store, and the template below is built from its catalogue.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Step 1 — the template. Presented first and prominently because
|
||||
uploading anything else will not work. */}
|
||||
<section className="mb-6 rounded-lg border border-slate-200 bg-slate-50 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-sm font-bold text-slate-800">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[#662582] text-[11px] font-bold text-white">
|
||||
1
|
||||
</span>
|
||||
Download the template for this store
|
||||
</h3>
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-slate-600">
|
||||
{templateQuery.isLoading
|
||||
? 'Loading this outlet’s catalogue…'
|
||||
: templateQuery.isError
|
||||
? 'Could not load this outlet’s catalogue.'
|
||||
: `${templateQuery.data?.products.length ?? 0} products stocked here. Fill in the qtysold column and upload the file back.`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => templateQuery.data && downloadSaleTemplate(templateQuery.data)}
|
||||
disabled={!templateQuery.data || templateQuery.isLoading}
|
||||
className="flex items-center gap-2 rounded-lg bg-[#662582] px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[#551f6d] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{templateQuery.isLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={16} />
|
||||
)}
|
||||
Download Template
|
||||
</button>
|
||||
</div>
|
||||
{templateQuery.isError && (
|
||||
<p className="mt-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{(templateQuery.error as Error).message}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Step 2 — the file. */}
|
||||
<section className="mb-6">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-bold text-slate-800">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[#662582] text-[11px] font-bold text-white">
|
||||
2
|
||||
</span>
|
||||
Upload the filled-in file
|
||||
</h3>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) void loadFile(file);
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
|
||||
dragging
|
||||
? 'border-[#662582] bg-purple-50'
|
||||
: 'border-slate-300 bg-white hover:border-slate-400 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Upload size={26} className="mx-auto mb-2 text-slate-400" />
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{fileName || 'Drop the .xlsx file here, or click to choose'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">Only the template downloaded above will import correctly.</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void loadFile(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Step 3 — the preview. Every problem is shown at once, against
|
||||
the operator's own row numbers, so the file can be fixed in one
|
||||
pass rather than one rejection at a time. */}
|
||||
{parsed && (
|
||||
<section>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-bold text-slate-800">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[#662582] text-[11px] font-bold text-white">
|
||||
3
|
||||
</span>
|
||||
Check and confirm
|
||||
</h3>
|
||||
|
||||
{parsed.fatal.length > 0 && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
{parsed.fatal.map((f, i) => (
|
||||
<p key={i} className="flex items-start gap-2 text-sm text-red-800">
|
||||
<XCircle size={16} className="mt-0.5 shrink-0" />
|
||||
{f}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary && parsed.rows.length > 0 && (
|
||||
<>
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<Stat label="Bills" value={String(summary.bills)} />
|
||||
<Stat label="Lines" value={String(summary.lines)} />
|
||||
<Stat label="Units" value={String(summary.units)} />
|
||||
<Stat label="Amount" value={money(summary.amount)} />
|
||||
<Stat
|
||||
label="Problems"
|
||||
value={String(summary.errors)}
|
||||
tone={summary.errors > 0 ? 'bad' : 'good'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{summary.errors > 0 && (
|
||||
<p className="mb-3 flex items-center gap-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-xs font-medium text-red-800">
|
||||
<XCircle size={14} />
|
||||
{summary.errors} row{summary.errors === 1 ? '' : 's'} must be fixed in the file before this can be
|
||||
uploaded.
|
||||
</p>
|
||||
)}
|
||||
{summary.errors === 0 && summary.warnings > 0 && (
|
||||
<p className="mb-3 flex items-center gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-800">
|
||||
<AlertTriangle size={14} />
|
||||
{summary.warnings} row{summary.warnings === 1 ? '' : 's'} have warnings. You can still upload.
|
||||
</p>
|
||||
)}
|
||||
{parsed.skipped > 0 && (
|
||||
<p className="mb-3 text-xs text-slate-500">
|
||||
{parsed.skipped} row{parsed.skipped === 1 ? '' : 's'} had no quantity and were ignored.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="max-h-72 overflow-auto rounded-lg border border-slate-200">
|
||||
<table className="w-full min-w-[820px] text-left text-xs">
|
||||
<thead className="sticky top-0 bg-slate-100 text-[11px] uppercase tracking-wide text-slate-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-semibold">Row</th>
|
||||
<th className="px-3 py-2 font-semibold">Product</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Qty</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Price</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Amount</th>
|
||||
<th className="px-3 py-2 font-semibold">Bill</th>
|
||||
<th className="px-3 py-2 font-semibold">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{parsed.rows.map((r) => {
|
||||
const bad = r.errors.length > 0;
|
||||
const warn = !bad && r.warnings.length > 0;
|
||||
return (
|
||||
<tr
|
||||
key={r.excelRow}
|
||||
className={bad ? 'bg-red-50' : warn ? 'bg-amber-50' : 'bg-white'}
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-slate-500">{r.excelRow}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="font-medium text-slate-800">{r.productname || '—'}</span>
|
||||
<span className="ml-1.5 font-mono text-[10px] text-slate-400">#{r.productid}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-slate-800">{r.qtysold}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-600">
|
||||
{r.unitprice ? money(r.unitprice) : <span className="text-amber-600">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-slate-600">
|
||||
{money(Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount))}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-500">{r.billno || '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
{bad ? (
|
||||
<span className="text-red-700">{r.errors.join('; ')}</span>
|
||||
) : warn ? (
|
||||
<span className="text-amber-700">{r.warnings.join('; ')}</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-emerald-700">
|
||||
<CheckCircle2 size={12} /> ready
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<p className="mt-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
|
||||
{uploadError}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!result && (
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200 bg-slate-50 px-6 py-4">
|
||||
<p className="text-xs text-slate-500">
|
||||
Imported sales reduce stock and appear in Orders marked <span className="font-semibold">OFFLINE</span>.
|
||||
Re-uploading the same file will not deduct twice.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{parsed && (
|
||||
<button
|
||||
onClick={reset}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100"
|
||||
>
|
||||
<RotateCcw size={14} /> Clear
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => uploadMutation.mutate()}
|
||||
disabled={!parsed || blocked || uploadMutation.isPending}
|
||||
className="flex items-center gap-2 rounded-lg bg-[#662582] px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#551f6d] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{uploadMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" /> Importing…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload size={16} /> Import {summary?.bills ? `${summary.bills} Bill${summary.bills === 1 ? '' : 's'}` : 'Sales'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: string; tone?: 'good' | 'bad' }) {
|
||||
const valueTone = tone === 'bad' ? 'text-red-700' : tone === 'good' ? 'text-emerald-700' : 'text-slate-900';
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">{label}</p>
|
||||
<p className={`text-base font-bold ${valueTone}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-bill outcome. Duplicates are reported as their own neutral category
|
||||
* rather than as failures: a re-upload being refused is the safeguard working,
|
||||
* and calling it an error would push people towards "fixing" it.
|
||||
*/
|
||||
function ResultPanel({
|
||||
result,
|
||||
onAnother,
|
||||
onClose,
|
||||
}: {
|
||||
result: OfflineSalesUploadResponse;
|
||||
onAnother: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-5 flex items-start gap-3 rounded-lg border border-slate-200 bg-slate-50 p-5">
|
||||
{result.imported > 0 ? (
|
||||
<CheckCircle2 size={22} className="mt-0.5 shrink-0 text-emerald-600" />
|
||||
) : (
|
||||
<AlertTriangle size={22} className="mt-0.5 shrink-0 text-amber-600" />
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-slate-900">
|
||||
{result.imported > 0
|
||||
? `${result.imported} bill${result.imported === 1 ? '' : 's'} imported`
|
||||
: 'Nothing was imported'}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{result.imported > 0 && <>Stock has been reduced and {money(result.totalamount)} recorded as revenue. </>}
|
||||
{result.duplicate > 0 && (
|
||||
<>
|
||||
{result.duplicate} bill{result.duplicate === 1 ? ' was' : 's were'} already imported and{' '}
|
||||
{result.duplicate === 1 ? 'was' : 'were'} skipped.{' '}
|
||||
</>
|
||||
)}
|
||||
{result.failed > 0 && (
|
||||
<>
|
||||
{result.failed} bill{result.failed === 1 ? '' : 's'} could not be imported — see below.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-slate-100 text-[11px] uppercase tracking-wide text-slate-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-semibold">Bill</th>
|
||||
<th className="px-3 py-2 font-semibold">Result</th>
|
||||
<th className="px-3 py-2 font-semibold">Order</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Items</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Amount</th>
|
||||
<th className="px-3 py-2 font-semibold">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{result.results.map((r, i) => (
|
||||
<tr
|
||||
key={`${r.billno}-${i}`}
|
||||
className={
|
||||
r.status === 'imported' ? 'bg-white' : r.status === 'duplicate' ? 'bg-slate-50' : 'bg-red-50'
|
||||
}
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-slate-700">{r.billno || '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
{r.status === 'imported' && (
|
||||
<span className="rounded bg-emerald-100 px-2 py-0.5 font-semibold text-emerald-800">imported</span>
|
||||
)}
|
||||
{r.status === 'duplicate' && (
|
||||
<span className="rounded bg-slate-200 px-2 py-0.5 font-semibold text-slate-700">already done</span>
|
||||
)}
|
||||
{r.status === 'failed' && (
|
||||
<span className="rounded bg-red-100 px-2 py-0.5 font-semibold text-red-800">failed</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-600">{r.orderid || '—'}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-600">{r.itemcount || '—'}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-600">{r.amount ? money(r.amount) : '—'}</td>
|
||||
<td className="px-3 py-2 text-slate-600">{r.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onAnother}
|
||||
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100"
|
||||
>
|
||||
Upload Another File
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg bg-[#662582] px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#551f6d]"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,13 +23,14 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity, Info, Inbox } from 'lucide-react';
|
||||
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity, Info, Inbox, UploadCloud } from 'lucide-react';
|
||||
import { useFiestaStockStatement, useFiestaCreateStockRequest, useFiestaGetStockRequests, useFiestaUpdateStockRequest, useFiestaCreateProductLocation, FIESTA_TENANT_ID } from '../services/fiestaQueries';
|
||||
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||
import { useStoreCatalogue } from '../services/storeCatalogue';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi';
|
||||
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
||||
import OfflineSalesUpload from './OfflineSalesUpload';
|
||||
|
||||
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
|
||||
|
||||
@@ -88,6 +89,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
const [activeQtyProduct, setActiveQtyProduct] = useState<any>(null);
|
||||
const [tempQty, setTempQty] = useState(1);
|
||||
const [notice, setNotice] = useState(false);
|
||||
const [showOfflineSales, setShowOfflineSales] = useState(false);
|
||||
|
||||
// Every read and write on this page is scoped to the tenant + outlet the
|
||||
// logged-in user is linked to. There is no safe default here: falling back to
|
||||
@@ -358,6 +360,23 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Counter sales. Deliberately outside the tab group and always visible:
|
||||
it is an action, not another way of looking at the catalogue, and the
|
||||
store needs it from wherever they are on this page. No outlet picker
|
||||
is passed — a store user can only ever import against their own. */}
|
||||
<button
|
||||
onClick={() => setShowOfflineSales(true)}
|
||||
disabled={!storeLocationId}
|
||||
title={
|
||||
storeLocationId
|
||||
? 'Upload an Excel sheet of counter sales to reduce your stock'
|
||||
: 'Your store outlet is still loading'
|
||||
}
|
||||
className="flex items-center justify-center gap-1.5 shrink-0 px-3 py-2 rounded-xl text-xs font-bold text-white bg-[#662582] hover:bg-[#531e6a] transition shadow-sm cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed w-full sm:w-auto"
|
||||
>
|
||||
<UploadCloud size={14} /> Upload Offline Sales
|
||||
</button>
|
||||
|
||||
{view === 'requests' && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider">Date:</span>
|
||||
@@ -1006,6 +1025,16 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
})()}
|
||||
</SlideDrawer>
|
||||
|
||||
{/* Counter-sales import, locked to this user's own outlet. */}
|
||||
{showOfflineSales && storeLocationId > 0 && (
|
||||
<OfflineSalesUpload
|
||||
tenantId={tenantid}
|
||||
locationId={storeLocationId}
|
||||
storeName={storeName}
|
||||
onClose={() => setShowOfflineSales(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1287,6 +1287,118 @@ export async function getSalesSummary(opts: {
|
||||
return res.details;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// OFFLINE (IN-STORE) SALES
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface SaleTemplateRow {
|
||||
productid: number;
|
||||
productname: string;
|
||||
productunit: string;
|
||||
unitvalue: string;
|
||||
categoryname: string;
|
||||
currentstock: number;
|
||||
price: number;
|
||||
taxpercent: number;
|
||||
}
|
||||
|
||||
export interface SaleTemplate {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
locationname: string;
|
||||
products: SaleTemplateRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /products/getsaletemplate — every product stocked at one outlet, with its
|
||||
* live ledger balance and price.
|
||||
*
|
||||
* This is what the offline-sales spreadsheet is built from, and the reason it
|
||||
* has to be generated rather than hand-written: `productid` is the only usable
|
||||
* key for a product. Across the live catalogue 6,245 products share just 93
|
||||
* distinct `productsku` values (one tenant has 463 products all carrying sku
|
||||
* "1"), so a store cannot identify a product by SKU, and product names are not
|
||||
* unique enough either. Pre-filling productid removes the problem entirely.
|
||||
*/
|
||||
export async function getSaleTemplate(opts: {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
}): Promise<SaleTemplate> {
|
||||
const res = await fiestaGet<{ details: SaleTemplate | null }>('products/getsaletemplate', {
|
||||
tenantid: opts.tenantid,
|
||||
locationid: opts.locationid,
|
||||
});
|
||||
if (!res?.details) throw new Error('No products are stocked at this outlet yet.');
|
||||
return res.details;
|
||||
}
|
||||
|
||||
export interface OfflineSaleItemInput {
|
||||
productid: number;
|
||||
productname?: string;
|
||||
qtysold: number;
|
||||
unitprice?: number;
|
||||
discountamount?: number;
|
||||
taxpercent?: number;
|
||||
}
|
||||
|
||||
export interface OfflineSaleBillInput {
|
||||
billno?: string;
|
||||
saledate?: string;
|
||||
paymentmode?: string;
|
||||
customername?: string;
|
||||
customermobile?: string;
|
||||
remarks?: string;
|
||||
items: OfflineSaleItemInput[];
|
||||
}
|
||||
|
||||
export interface OfflineSaleResult {
|
||||
billno: string;
|
||||
status: 'imported' | 'duplicate' | 'failed';
|
||||
orderid: string;
|
||||
orderheaderid: number;
|
||||
itemcount: number;
|
||||
amount: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface OfflineSalesUploadResponse {
|
||||
imported: number;
|
||||
duplicate: number;
|
||||
failed: number;
|
||||
totalamount: number;
|
||||
results: OfflineSaleResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /orders/uploadofflinesales — import counter sales as real orders.
|
||||
*
|
||||
* Each bill is committed independently, so the response reports a per-bill
|
||||
* outcome and a partially-good spreadsheet still imports its good bills. A
|
||||
* thrown error therefore means nothing at all was attempted (bad outlet, empty
|
||||
* batch); individual rejections come back inside `results`.
|
||||
*
|
||||
* Re-uploading the same file is safe: the backend records each bill number and
|
||||
* refuses one it has already imported rather than deducting the stock twice.
|
||||
*/
|
||||
export async function uploadOfflineSales(input: {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
userid?: number;
|
||||
bills: OfflineSaleBillInput[];
|
||||
}): Promise<OfflineSalesUploadResponse> {
|
||||
const res = await fiestaSend<{ details: OfflineSalesUploadResponse }>(
|
||||
'orders/uploadofflinesales',
|
||||
'POST',
|
||||
{
|
||||
tenantid: input.tenantid,
|
||||
locationid: input.locationid,
|
||||
userid: input.userid ?? 0,
|
||||
bills: input.bills,
|
||||
},
|
||||
);
|
||||
return res.details;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// GLOBAL CATALOGUE
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
470
src/services/offlineSalesSheet.ts
Normal file
470
src/services/offlineSalesSheet.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* The spreadsheet half of offline-sales import: turning an outlet's catalogue
|
||||
* into a workbook the store fills in, and turning that workbook back into bills
|
||||
* the API can take.
|
||||
*
|
||||
* Parsing happens here in the browser rather than on the server so the operator
|
||||
* sees every problem — a bad quantity, a product that isn't theirs, a price
|
||||
* they forgot — laid out against their own rows and can fix the file before
|
||||
* anything is written. The backend validates all of it again regardless; this
|
||||
* is for the person, not for safety.
|
||||
*/
|
||||
|
||||
import * as XLSX from 'xlsx';
|
||||
import type { OfflineSaleBillInput, SaleTemplate } from './fiestaApi';
|
||||
|
||||
/** Sheet names. Parsing looks for SALES_SHEET by name, then falls back to the
|
||||
* first sheet, 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 = 1;
|
||||
|
||||
/**
|
||||
* Column headers, in the order they appear. The first three are locked
|
||||
* reference data; `qtysold` is the one the user is expected to type in.
|
||||
*/
|
||||
const COLUMNS = [
|
||||
'productid',
|
||||
'productname',
|
||||
'currentstock',
|
||||
'qtysold',
|
||||
'unitprice',
|
||||
'discountamount',
|
||||
'taxpercent',
|
||||
'billno',
|
||||
'saledate',
|
||||
'paymentmode',
|
||||
'customername',
|
||||
'customermobile',
|
||||
'remarks',
|
||||
] as const;
|
||||
|
||||
const COLUMN_WIDTHS = [11, 38, 13, 10, 11, 15, 11, 14, 13, 13, 18, 15, 24];
|
||||
|
||||
/** Rendered above the table so the sheet explains itself without the help tab. */
|
||||
const HEADER_LABELS: Record<string, string> = {
|
||||
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[][] = [
|
||||
['How to record offline (counter) sales'],
|
||||
[''],
|
||||
['1.', 'Fill in the "qtysold" column on the Sales sheet for whatever you sold at the counter.'],
|
||||
['', 'Leave the row blank or 0 if the product did not sell — blank rows are ignored.'],
|
||||
['2.', 'Do NOT edit productid or productname. They identify the product and must match.'],
|
||||
['', 'If a product is missing from the sheet, add it to the store catalogue first,'],
|
||||
['', 'then download a fresh template.'],
|
||||
['3.', 'unitprice defaults to the store price shown. Change it if you sold at a different price.'],
|
||||
['', 'If the price shows 0, the product has no price set — type the real one or the sale'],
|
||||
['', 'will be recorded with no revenue.'],
|
||||
['4.', 'billno groups rows into one bill. Rows sharing a billno become a single order.'],
|
||||
['', 'Leave billno empty and the whole sheet is imported as one bill.'],
|
||||
['5.', 'saledate accepts YYYY-MM-DD or DD-MM-YYYY. Blank means today.'],
|
||||
['6.', 'paymentmode accepts Cash, Card or UPI. Blank means Cash.'],
|
||||
['7.', 'taxpercent is treated as already included in unitprice (MRP), so the amount'],
|
||||
['', 'collected stays exactly unitprice x qtysold minus any discount.'],
|
||||
['8.', 'customername / customermobile are optional. Give a mobile number and the sale is'],
|
||||
['', 'attached to that shopper; leave it blank and it goes to a walk-in customer.'],
|
||||
[''],
|
||||
['Uploading the same file twice is safe.'],
|
||||
['Each bill is remembered, so a repeated bill is reported as already imported'],
|
||||
['and its stock is NOT deducted a second time.'],
|
||||
[''],
|
||||
['Imported sales reduce stock exactly like an app order, and appear in Orders'],
|
||||
['and in revenue reports marked as OFFLINE.'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Build the workbook for one outlet. Every stocked product gets a row even when
|
||||
* its stock is zero — the sheet is a worksheet to fill in, and hiding rows would
|
||||
* just mean the operator cannot record a sale they actually made.
|
||||
*/
|
||||
export function buildSaleTemplateWorkbook(template: SaleTemplate): XLSX.WorkBook {
|
||||
const header = COLUMNS.map((c) => HEADER_LABELS[c] ?? c);
|
||||
|
||||
const body = template.products.map((p) => [
|
||||
p.productid,
|
||||
p.productname,
|
||||
p.currentstock,
|
||||
// qtysold onwards are left empty: these are the operator's columns, and
|
||||
// pre-filling qtysold with 0 invites a file of accidental zero-quantity rows.
|
||||
'',
|
||||
p.price > 0 ? p.price : '',
|
||||
'',
|
||||
p.taxpercent > 0 ? p.taxpercent : '',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
]);
|
||||
|
||||
const sales = XLSX.utils.aoa_to_sheet([header, ...body]);
|
||||
sales['!cols'] = COLUMN_WIDTHS.map((w) => ({ wch: w }));
|
||||
sales['!freeze'] = { xSplit: '0', ySplit: '1' };
|
||||
|
||||
// The outlet identity travels in the file so the upload can be checked against
|
||||
// the outlet the template was generated for, instead of trusting a number a
|
||||
// person could retype. The backend re-authorises it either way.
|
||||
const info = XLSX.utils.aoa_to_sheet([
|
||||
['Field', 'Value'],
|
||||
['tenantid', template.tenantid],
|
||||
['locationid', template.locationid],
|
||||
['locationname', template.locationname],
|
||||
['generatedon', new Date().toISOString()],
|
||||
['templateversion', TEMPLATE_VERSION],
|
||||
[''],
|
||||
['Do not edit this sheet.'],
|
||||
['These values tell the system which store the sales belong to.'],
|
||||
]);
|
||||
info['!cols'] = [{ wch: 18 }, { wch: 42 }];
|
||||
|
||||
const help = XLSX.utils.aoa_to_sheet(INSTRUCTIONS);
|
||||
help['!cols'] = [{ wch: 4 }, { wch: 92 }];
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, sales, SALES_SHEET);
|
||||
XLSX.utils.book_append_sheet(wb, info, INFO_SHEET);
|
||||
XLSX.utils.book_append_sheet(wb, help, HELP_SHEET);
|
||||
return wb;
|
||||
}
|
||||
|
||||
/** `offline-sales-r-mart-2026-07-30.xlsx` — outlet and date, so a folder of
|
||||
* these stays sortable and it is obvious which store a file belongs to. */
|
||||
export function saleTemplateFilename(template: SaleTemplate): string {
|
||||
const slug =
|
||||
template.locationname
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '') || `location-${template.locationid}`;
|
||||
return `offline-sales-${slug}-${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
}
|
||||
|
||||
export function downloadSaleTemplate(template: SaleTemplate): void {
|
||||
XLSX.writeFile(buildSaleTemplateWorkbook(template), saleTemplateFilename(template));
|
||||
}
|
||||
|
||||
// ── Parsing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** One spreadsheet row after parsing, carrying its own problems. */
|
||||
export interface ParsedSaleRow {
|
||||
/** 1-based row number as shown in Excel, so an error can be pointed at. */
|
||||
excelRow: number;
|
||||
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 {
|
||||
/** Outlet read from the Store Info sheet, when the file still has it. */
|
||||
tenantid: number | null;
|
||||
locationid: number | null;
|
||||
locationname: string;
|
||||
/** Rows with a quantity — blank ones are dropped, not reported. */
|
||||
rows: ParsedSaleRow[];
|
||||
/** Rows skipped for having no quantity. Counted so the operator can tell an
|
||||
* empty column apart from a file that genuinely had two sales in it. */
|
||||
skipped: number;
|
||||
/** Problems with the file as a whole, not with a row. */
|
||||
fatal: string[];
|
||||
}
|
||||
|
||||
/** Excel hands back numbers, strings, or a Date depending on the cell format. */
|
||||
function toNum(v: unknown): number | null {
|
||||
if (v === null || v === undefined || v === '') return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
const n = Number(String(v).trim().replace(/,/g, ''));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function toStr(v: unknown): string {
|
||||
if (v === null || v === undefined) return '';
|
||||
return String(v).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* A date cell can arrive as a real Date (Excel date-formatted), a serial number
|
||||
* (raw numeric cell), or text. Normalise all three to YYYY-MM-DD so the backend
|
||||
* sees one format regardless of how the operator's Excel was configured.
|
||||
*/
|
||||
function toDateString(v: unknown): string {
|
||||
if (v === null || v === undefined || v === '') return '';
|
||||
if (v instanceof Date) return v.toISOString().slice(0, 10);
|
||||
if (typeof v === 'number') {
|
||||
const parsed = XLSX.SSF?.parse_date_code?.(v);
|
||||
if (parsed && parsed.y) {
|
||||
const mm = String(parsed.m).padStart(2, '0');
|
||||
const dd = String(parsed.d).padStart(2, '0');
|
||||
return `${parsed.y}-${mm}-${dd}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return String(v).trim();
|
||||
}
|
||||
|
||||
/** Match a header cell back to a known column, tolerating the "(do not edit)"
|
||||
* and "*" suffixes and any case/spacing the operator's Excel introduced. */
|
||||
function normaliseHeader(raw: unknown): string {
|
||||
return toStr(raw)
|
||||
.toLowerCase()
|
||||
.replace(/\(.*?\)/g, '')
|
||||
.replace(/[^a-z]/g, '');
|
||||
}
|
||||
|
||||
const PAYMENT_MODES = new Set(['cash', 'card', 'upi']);
|
||||
|
||||
/**
|
||||
* Parse an uploaded workbook. Never throws for row-level problems — those are
|
||||
* attached to the row so the whole sheet can be shown at once, which is the
|
||||
* point of parsing client-side. Only a file that cannot be read at all, or has
|
||||
* no recognisable columns, produces a fatal.
|
||||
*/
|
||||
export function parseSalesWorkbook(data: ArrayBuffer, expected?: { tenantid: number; locationid: number }): ParsedSheet {
|
||||
const out: ParsedSheet = { tenantid: null, locationid: null, locationname: '', rows: [], skipped: 0, fatal: [] };
|
||||
|
||||
let wb: XLSX.WorkBook;
|
||||
try {
|
||||
wb = XLSX.read(data, { cellDates: true });
|
||||
} catch {
|
||||
out.fatal.push('That file could not be read as a spreadsheet. Upload the .xlsx template.');
|
||||
return out;
|
||||
}
|
||||
|
||||
// Store Info is read first: knowing the outlet lets a mismatched file be
|
||||
// caught before any row is interpreted against the wrong catalogue.
|
||||
const infoSheet = wb.Sheets[INFO_SHEET];
|
||||
if (infoSheet) {
|
||||
const infoRows = XLSX.utils.sheet_to_json<unknown[]>(infoSheet, { header: 1, blankrows: false });
|
||||
for (const r of infoRows) {
|
||||
const key = toStr(r?.[0]).toLowerCase();
|
||||
const val = r?.[1];
|
||||
if (key === 'tenantid') out.tenantid = toNum(val);
|
||||
else if (key === 'locationid') out.locationid = toNum(val);
|
||||
else if (key === 'locationname') out.locationname = toStr(val);
|
||||
}
|
||||
}
|
||||
|
||||
if (expected) {
|
||||
if (out.tenantid !== null && out.tenantid !== expected.tenantid) {
|
||||
out.fatal.push(
|
||||
`This file was generated for a different account (tenant ${out.tenantid}). Download a fresh template.`,
|
||||
);
|
||||
}
|
||||
if (out.locationid !== null && out.locationid !== expected.locationid) {
|
||||
out.fatal.push(
|
||||
`This file was generated for ${out.locationname || `outlet ${out.locationid}`}, not the outlet you are uploading to. Download a fresh template for this store.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sheet = wb.Sheets[SALES_SHEET] ?? wb.Sheets[wb.SheetNames[0]];
|
||||
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((cell, i) => {
|
||||
const key = normaliseHeader(cell);
|
||||
if (key && index[key] === undefined) index[key] = i;
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const cell = (row: unknown[], key: string): unknown => {
|
||||
const i = index[key];
|
||||
return i === undefined ? '' : row[i];
|
||||
};
|
||||
|
||||
for (let i = 1; i < grid.length; i++) {
|
||||
const raw = grid[i];
|
||||
const excelRow = i + 1;
|
||||
|
||||
const qty = toNum(cell(raw, 'qtysold'));
|
||||
// Nothing sold on this line. Not an error — a template lists the whole
|
||||
// catalogue and most rows are expected to be empty.
|
||||
if (qty === null || qty === 0) {
|
||||
out.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const productid = toNum(cell(raw, 'productid'));
|
||||
const unitprice = toNum(cell(raw, 'unitprice'));
|
||||
const taxpercent = toNum(cell(raw, 'taxpercent'));
|
||||
const discount = toNum(cell(raw, 'discountamount')) ?? 0;
|
||||
const stock = toNum(cell(raw, 'currentstock'));
|
||||
const paymentmode = toStr(cell(raw, 'paymentmode'));
|
||||
|
||||
const row: ParsedSaleRow = {
|
||||
excelRow,
|
||||
productid: productid ?? 0,
|
||||
productname: toStr(cell(raw, 'productname')),
|
||||
currentstock: stock,
|
||||
qtysold: qty,
|
||||
unitprice,
|
||||
discountamount: discount,
|
||||
taxpercent,
|
||||
billno: toStr(cell(raw, 'billno')),
|
||||
saledate: toDateString(cell(raw, 'saledate')),
|
||||
paymentmode,
|
||||
customername: toStr(cell(raw, 'customername')),
|
||||
customermobile: toStr(cell(raw, 'customermobile')),
|
||||
remarks: toStr(cell(raw, 'remarks')),
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
if (!productid || productid <= 0) {
|
||||
row.errors.push('productid is missing — do not delete that column');
|
||||
}
|
||||
if (qty < 0) {
|
||||
row.errors.push('qtysold cannot be negative');
|
||||
}
|
||||
// Checked here as well as server-side so the operator sees it against the
|
||||
// row instead of getting a rejected bill back.
|
||||
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 the upload. 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 product.');
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group parsed rows into bills for the API.
|
||||
*
|
||||
* Rows sharing a billno become one order. Rows with no billno collapse into a
|
||||
* single unnumbered bill rather than one bill per row: a sheet where the
|
||||
* operator ignored the billno column is one shopping trip far more often than it
|
||||
* is fifty separate ones, 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 r of rows) {
|
||||
const key = r.billno.trim().toUpperCase() || '__nobill__';
|
||||
const bucket = groups.get(key);
|
||||
if (bucket) bucket.push(r);
|
||||
else groups.set(key, [r]);
|
||||
}
|
||||
|
||||
return Array.from(groups.values()).map((group) => {
|
||||
// Bill-level fields belong to the bill, not the line. Taking the first
|
||||
// non-empty value means the operator only has to fill them in on the first
|
||||
// row of a bill, which is how people actually fill these in.
|
||||
const first = (pick: (r: ParsedSaleRow) => string): string => group.map(pick).find((v) => v !== '') ?? '';
|
||||
|
||||
return {
|
||||
billno: group[0].billno.trim(),
|
||||
saledate: first((r) => r.saledate),
|
||||
paymentmode: first((r) => r.paymentmode),
|
||||
customername: first((r) => r.customername),
|
||||
customermobile: first((r) => r.customermobile),
|
||||
remarks: first((r) => r.remarks),
|
||||
items: group.map((r) => ({
|
||||
productid: r.productid,
|
||||
productname: r.productname || undefined,
|
||||
qtysold: r.qtysold,
|
||||
unitprice: r.unitprice ?? undefined,
|
||||
discountamount: r.discountamount || undefined,
|
||||
taxpercent: r.taxpercent ?? undefined,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Totals for the preview bar. Amounts mirror the backend's arithmetic (tax
|
||||
* inclusive), so the figure shown before upload is the one that gets recorded. */
|
||||
export function summarise(rows: ParsedSaleRow[]): {
|
||||
lines: number;
|
||||
units: number;
|
||||
amount: number;
|
||||
errors: number;
|
||||
warnings: number;
|
||||
bills: number;
|
||||
} {
|
||||
let units = 0;
|
||||
let amount = 0;
|
||||
let errors = 0;
|
||||
let warnings = 0;
|
||||
const bills = new Set<string>();
|
||||
|
||||
for (const r of rows) {
|
||||
units += r.qtysold;
|
||||
amount += Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount);
|
||||
if (r.errors.length) errors++;
|
||||
if (r.warnings.length) warnings++;
|
||||
bills.add(r.billno.trim().toUpperCase() || '__nobill__');
|
||||
}
|
||||
|
||||
return { lines: rows.length, units, amount, errors, warnings, bills: bills.size };
|
||||
}
|
||||
Reference in New Issue
Block a user