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-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.2.0",
|
"react-router-dom": "^7.2.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"vite": "^6.2.3"
|
"vite": "^6.2.3",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
@@ -1810,6 +1811,15 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
@@ -2040,6 +2050,19 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/claude": {
|
||||||
"version": "0.1.1",
|
"version": "0.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/claude/-/claude-0.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/claude/-/claude-0.1.1.tgz",
|
||||||
@@ -2055,6 +2078,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -2097,6 +2129,18 @@
|
|||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/d3-array": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
@@ -2619,6 +2663,15 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/fraction.js": {
|
||||||
"version": "5.3.4",
|
"version": "5.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
@@ -4074,6 +4127,18 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -4821,6 +4886,24 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/ws": {
|
||||||
"version": "8.21.0",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.2.0",
|
"react-router-dom": "^7.2.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"vite": "^6.2.3"
|
"vite": "^6.2.3",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@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 { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi';
|
||||||
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
||||||
import CatalogueBrowser from './CatalogueBrowser';
|
import CatalogueBrowser from './CatalogueBrowser';
|
||||||
|
import OfflineSalesUpload from './OfflineSalesUpload';
|
||||||
import { useCompare } from '../contexts/CompareContext';
|
import { useCompare } from '../contexts/CompareContext';
|
||||||
|
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ export default function InventoryView({
|
|||||||
const { setHideCompareBar } = useCompare();
|
const { setHideCompareBar } = useCompare();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [showCatalogueModal, setShowCatalogueModal] = useState(false);
|
const [showCatalogueModal, setShowCatalogueModal] = useState(false);
|
||||||
|
const [showOfflineSales, setShowOfflineSales] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// ── Live stock across every outlet (Fiesta) ───────────────────────────────
|
// ── 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>
|
<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>
|
||||||
|
<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
|
<button
|
||||||
onClick={() => setActiveTab('global_catalogue')}
|
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"
|
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>
|
</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>
|
</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 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 { useFiestaStockStatement, useFiestaCreateStockRequest, useFiestaGetStockRequests, useFiestaUpdateStockRequest, useFiestaCreateProductLocation, FIESTA_TENANT_ID } from '../services/fiestaQueries';
|
||||||
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||||
import { useStoreCatalogue } from '../services/storeCatalogue';
|
import { useStoreCatalogue } from '../services/storeCatalogue';
|
||||||
import AwaitingApi from './AwaitingApi';
|
import AwaitingApi from './AwaitingApi';
|
||||||
import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi';
|
import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi';
|
||||||
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
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';
|
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 [activeQtyProduct, setActiveQtyProduct] = useState<any>(null);
|
||||||
const [tempQty, setTempQty] = useState(1);
|
const [tempQty, setTempQty] = useState(1);
|
||||||
const [notice, setNotice] = useState(false);
|
const [notice, setNotice] = useState(false);
|
||||||
|
const [showOfflineSales, setShowOfflineSales] = useState(false);
|
||||||
|
|
||||||
// Every read and write on this page is scoped to the tenant + outlet the
|
// 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
|
// 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>
|
</button>
|
||||||
</div>
|
</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' && (
|
{view === 'requests' && (
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider">Date:</span>
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1287,6 +1287,118 @@ export async function getSalesSummary(opts: {
|
|||||||
return res.details;
|
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
|
// 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