Compare commits

...

7 Commits

Author SHA1 Message Date
13db63e219 console update 2026-08-03 15:36:34 +05:30
8deaf8513b Fix the empty rider dropdown and notify riders on assign
The assign dropdown was empty on open. Two faults compounded: riders were
queried by tenantid, and the source list defaulted to a tab that could
never match.

/partners/getriders returns nothing for any tenant, because a rider record
leaves app_users.tenantid unset — riders belong to a partner and an
app-location. Scoping by applocationid returns them, so both Orders and
Deliveries now take the app-location from the rows they are showing and
fall back to the caller's.

The second fault was the "Store Fleet" default, which filtered for
partnerid === 0. Every on-duty rider has a partnerid, so the default tab
was always empty even once the query returned rows. It is now On Duty /
This Partner, the latter enabled only when the orders name a partner.

Dropped the getallusers?roleid=5 "own fleet" list it merged in. There is no
rider role: app_roles defines 1-6 as Super admin / Operations / Admin /
Manager per configid, and riders are identified by configid=6 inside
getriders. roleid=5 matched a single user with two deliveries in the
platform's history, while the users actually driving deliveries carry
roleid 0.

/partners/getriders is already a presence query rather than a roster — it
requires status Active, onduty=1 and a riderlog dated today with
logstatus=0 — so the list is riders working right now, and it carries the
userfcmtoken needed to reach them. Added a refetch so someone logging off
mid-shift drops out of the list.

Riders are now notified. The push runs after the write and is reported
separately: the deliveries are committed by then, so a failed push must not
read as a failed assignment, but it must still be visible because a rider
who was never told has work sitting unseen. A missing token is reported as
a rider with no device registered rather than as a transport failure, since
the remedy is different.

Deliveries gains the rider actions its placeholder promised: change rider,
notify, and send-cancellation carrying data.type=cancel. Change-rider is
offered only while a delivery is pending, accepted or arrived, because
reassigning resets orderstatus to pending and would otherwise rewind a
journey already completed.

reassignDeliveries was posting to /riders/reassigndeliveries, which is not
registered on the backend and answers 404. It had no callers, so the
failure had never been observed. It now goes through updatedelivery, one
call per delivery, tolerating partial failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:48:57 +05:30
cc08f2f6c5 Carry the branch on every offline-sales row instead of picking a store
The upload modal made the admin choose a branch, then generated and
validated the workbook against that choice. A merchant with several
outlets had to repeat the whole cycle per branch, and the picker
defaulted to the first outlet, so an admin who never opened it credited
the wrong store — the file and the selection agreed, so nothing flagged
it.

The sheet now carries tenantid, locationid and the store name as locked
columns on every row, and the row's own locationid routes its sale. One
file covers the whole business: fill in qtysold wherever something sold,
across as many branches as needed, and upload once. The picker is gone
from the admin surface entirely, and the store user's page keeps passing
its locationId, which pins the upload to that branch and rejects rows for
any other before they are even sent.

Bills are keyed on branch first and bill number second. Counter books at
different outlets restart numbering from 1, so a shared number is two
sales rather than a duplicate, and keying on the number alone would have
dropped the second one.

Because a single upload can now move stock at six outlets, one total is
no longer enough to check before committing: the preview gains a store
count, a per-store table of lines, units, amount and problems, and a
Store column on every row, and results name the branch on each bill.

Rows are ordered store then product with an Excel autofilter, so a
branch can isolate its own rows in a file spanning the business.

Verified end to end against a two-branch tenant sharing a product id
across both outlets: one upload deducted each branch independently, a
pinned upload refused the other branch's rows, and re-uploading deducted
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:18:09 +05:30
9990355e19 offline sales upload 2026-07-30 17:23:21 +05:30
eb6e750b6a main stocks update 2026-07-27 19:00:09 +05:30
8885c55817 lat and long 2026-07-27 12:09:41 +05:30
6a6221f9f3 Auto-geocode tenant/store address into latitude/longitude on onboarding
Both the tenant onboarding and store/branch forms only had free-text
address fields with no coordinates captured, even though the backend
already accepts and stores latitude/longitude on both tenants and
tenantlocations. Wires in the existing keyless AddressAutocomplete
(Nominatim) component used by UsersPanel so picking an address also
geocodes it, and threads latitude/longitude through both create payloads.
2026-07-27 11:34:35 +05:30
21 changed files with 2573 additions and 552 deletions

106
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -276,18 +276,7 @@ export default function App() {
if (activeStore) { if (activeStore) {
return ( return (
<div className="space-y-md animate-in fade-in duration-300"> <div className="space-y-md animate-in fade-in duration-300">
{isSoleStore && (
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-4 border-b border-zinc-205">
<div>
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
Store Console
</h1>
<p className="text-zinc-500 font-sans text-xs mt-1">
This merchant operates a single store. Add a branch to manage multiple outlets.
</p>
</div>
</div>
)}
<StoreDetailView <StoreDetailView
store={activeStore} store={activeStore}
onBack={() => setSelectedStore(null)} onBack={() => setSelectedStore(null)}

View File

@@ -32,6 +32,7 @@ import {
} from '../services/fiestaQueries'; } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, str as fstr, num as fnum } from '../services/fiestaApi'; import { FIESTA_TENANT_ID, str as fstr, num as fnum } from '../services/fiestaApi';
import StoreQRView from './StoreQRView'; import StoreQRView from './StoreQRView';
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
export default function AdminConsole({ activeTab: propActiveTab, showHeader = true, onBack, tenantId }: { activeTab?: 'tenant' | 'store' | 'rider', showHeader?: boolean, onBack?: () => void, tenantId?: number }) { export default function AdminConsole({ activeTab: propActiveTab, showHeader = true, onBack, tenantId }: { activeTab?: 'tenant' | 'store' | 'rider', showHeader?: boolean, onBack?: () => void, tenantId?: number }) {
const [activeTab, setActiveTab] = useState<'tenant' | 'store' | 'rider'>(propActiveTab || 'tenant'); const [activeTab, setActiveTab] = useState<'tenant' | 'store' | 'rider'>(propActiveTab || 'tenant');
@@ -62,6 +63,10 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
city: 'Coimbatore', city: 'Coimbatore',
state: 'Tamil Nadu', state: 'Tamil Nadu',
postcode: '', postcode: '',
// Populated from AddressAutocomplete's geocoding lookup, not typed —
// keeps HQ address and coordinates from drifting apart.
latitude: '',
longitude: '',
// Primary outlet — created in the same call as the tenant, so a fresh // Primary outlet — created in the same call as the tenant, so a fresh
// tenant is never left without a location to actually operate from. // tenant is never left without a location to actually operate from.
locationname: '', locationname: '',
@@ -84,6 +89,10 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
city: 'Coimbatore', city: 'Coimbatore',
state: 'Tamil Nadu', state: 'Tamil Nadu',
postcode: '', postcode: '',
// Populated from AddressAutocomplete's geocoding lookup, not typed —
// keeps the branch address and coordinates from drifting apart.
latitude: '',
longitude: '',
contactno: '', contactno: '',
email: '', email: '',
opentime: '06:00:00', opentime: '06:00:00',
@@ -128,6 +137,34 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
setTimeout(() => setCopiedSql(false), 2000); setTimeout(() => setCopiedSql(false), 2000);
}; };
// Address autocomplete → discrete fields + geocoded lat/long (or clear them
// when the field is emptied), same pattern UsersPanel uses for team members.
const handleTenantAddressSelect = (r: AddressResult | null) => {
setTenantForm((f) => ({
...f,
address: r?.address ?? '',
suburb: r?.suburb ?? '',
city: r?.city ?? '',
state: r?.state ?? '',
postcode: r?.postcode ?? '',
latitude: r?.latitude ?? '',
longitude: r?.longitude ?? '',
}));
};
const handleStoreAddressSelect = (r: AddressResult | null) => {
setStoreForm((f) => ({
...f,
address: r?.address ?? '',
suburb: r?.suburb ?? '',
city: r?.city ?? '',
state: r?.state ?? '',
postcode: r?.postcode ?? '',
latitude: r?.latitude ?? '',
longitude: r?.longitude ?? '',
}));
};
// ---------------------------------------------------- // ----------------------------------------------------
// Submissions // Submissions
// ---------------------------------------------------- // ----------------------------------------------------
@@ -154,6 +191,8 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
city: tenantForm.city, city: tenantForm.city,
state: tenantForm.state, state: tenantForm.state,
postcode: tenantForm.postcode, postcode: tenantForm.postcode,
latitude: tenantForm.latitude,
longitude: tenantForm.longitude,
applocationid: tenantForm.applocationid, applocationid: tenantForm.applocationid,
}, },
}); });
@@ -353,13 +392,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-bold text-slate-450 uppercase tracking-widest">HQ Street Address</label> <label className="text-[10px] font-bold text-slate-450 uppercase tracking-widest">HQ Street Address</label>
<input <AddressAutocomplete value={tenantForm.address} onSelect={handleTenantAddressSelect} placeholder="e.g. 12, Avinashi Road" />
type="text"
placeholder="e.g. 12, Avinashi Road"
value={tenantForm.address}
onChange={(e) => setTenantForm({ ...tenantForm, address: e.target.value })}
className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800"
/>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
@@ -471,7 +504,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
)} )}
<button <button
type="button" type="button"
onClick={() => { setStoreSuccess(null); setStoreForm({ tenantid: FIESTA_TENANT_ID, locationname: '', address: '', suburb: '', city: 'Coimbatore', state: 'Tamil Nadu', postcode: '', contactno: '', email: '', opentime: '06:00:00', closetime: '22:00:00', deliverymins: 45, deliveryradius: 5000 }); }} onClick={() => { setStoreSuccess(null); setStoreForm({ tenantid: FIESTA_TENANT_ID, locationname: '', address: '', suburb: '', city: 'Coimbatore', state: 'Tamil Nadu', postcode: '', latitude: '', longitude: '', contactno: '', email: '', opentime: '06:00:00', closetime: '22:00:00', deliverymins: 45, deliveryradius: 5000 }); }}
className="bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-xs uppercase tracking-wider px-5 py-2.5 rounded-lg border-none cursor-pointer active:scale-95 transition-all shadow-sm" className="bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-xs uppercase tracking-wider px-5 py-2.5 rounded-lg border-none cursor-pointer active:scale-95 transition-all shadow-sm"
> >
Add Another Branch Add Another Branch
@@ -561,18 +594,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
</h4> </h4>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-bold text-slate-500 uppercase tracking-widest block mb-1">Store Branch Address</label> <label className="text-[10px] font-bold text-slate-500 uppercase tracking-widest block mb-1">Store Branch Address</label>
<div className="relative rounded-xl"> <AddressAutocomplete value={storeForm.address} onSelect={handleStoreAddressSelect} placeholder="e.g. 240, DB Road" />
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<MapPin size={14} />
</div>
<input
type="text"
placeholder="e.g. 240, DB Road"
value={storeForm.address}
onChange={(e) => setStoreForm({ ...storeForm, address: e.target.value })}
className="pl-10 pr-4 py-2.5 w-full border border-slate-200 rounded-xl bg-slate-50/40 hover:bg-slate-100/60 focus:bg-white outline-none focus:ring-4 focus:ring-purple-100 focus:border-purple-600 transition-all font-semibold text-xs text-slate-800 shadow-sm"
/>
</div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
@@ -1107,13 +1129,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-bold text-slate-450 uppercase tracking-widest">HQ Street Address</label> <label className="text-[10px] font-bold text-slate-450 uppercase tracking-widest">HQ Street Address</label>
<input <AddressAutocomplete value={tenantForm.address} onSelect={handleTenantAddressSelect} placeholder="e.g. 12, Avinashi Road" />
type="text"
placeholder="e.g. 12, Avinashi Road"
value={tenantForm.address}
onChange={(e) => setTenantForm({ ...tenantForm, address: e.target.value })}
className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800"
/>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
@@ -1218,7 +1234,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
</button> </button>
)} )}
<button <button
onClick={() => { setStoreSuccess(null); setStoreForm({ tenantid: FIESTA_TENANT_ID, locationname: '', address: '', suburb: '', city: 'Coimbatore', state: 'Tamil Nadu', postcode: '', contactno: '', email: '', opentime: '06:00:00', closetime: '22:00:00', deliverymins: 45, deliveryradius: 5000 }); }} onClick={() => { setStoreSuccess(null); setStoreForm({ tenantid: FIESTA_TENANT_ID, locationname: '', address: '', suburb: '', city: 'Coimbatore', state: 'Tamil Nadu', postcode: '', latitude: '', longitude: '', contactno: '', email: '', opentime: '06:00:00', closetime: '22:00:00', deliverymins: 45, deliveryradius: 5000 }); }}
className="bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-xs uppercase tracking-wider px-5 py-2.5 rounded-lg border-none cursor-pointer" className="bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-xs uppercase tracking-wider px-5 py-2.5 rounded-lg border-none cursor-pointer"
> >
Add Another Branch Add Another Branch
@@ -1281,13 +1297,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-bold text-slate-450 uppercase tracking-widest">Store Branch Address</label> <label className="text-[10px] font-bold text-slate-450 uppercase tracking-widest">Store Branch Address</label>
<input <AddressAutocomplete value={storeForm.address} onSelect={handleStoreAddressSelect} placeholder="e.g. 240, DB Road" />
type="text"
placeholder="e.g. 240, DB Road"
value={storeForm.address}
onChange={(e) => setStoreForm({ ...storeForm, address: e.target.value })}
className="w-full border border-slate-250 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 transition-all font-semibold text-xs text-slate-800"
/>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">

View File

@@ -6,7 +6,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Users, Search, Phone, Mail, ChevronRight, Award, MapPin } from 'lucide-react'; import { Users, Search, Phone, Mail, ChevronRight, Award, MapPin } from 'lucide-react';
import { useFiestaTenantCustomers, useFiestaTenantLocations } from '../services/fiestaQueries'; import { useFiestaTenantCustomers, useFiestaTenantLocations } from '../services/fiestaQueries';
import { num as fnum, str as fstr } from '../services/fiestaApi'; import { num as fnum, str as fstr, customerName, customerStoreId } from '../services/fiestaApi';
interface AdminCustomersViewProps { interface AdminCustomersViewProps {
tenantId: number; tenantId: number;
@@ -36,16 +36,19 @@ export default function AdminCustomersView({ tenantId }: AdminCustomersViewProps
null; null;
const spent = fnum(c.totalspent); const spent = fnum(c.totalspent);
const locId = fnum(c.locationid) || fnum(c.applocationid); // `locationid` is not a column on this response — the store link is
const loc = locations.find((l: any) => fnum(l.locationid) === locId || fnum(l.applocationid) === locId); // aliased `tenantlocationid`. Matching on the wrong name meant every
const storeName = loc ? fstr(loc.locationname) : (fstr(c.locationname) || 'Central'); // customer fell through to the invented store name 'Central'.
const locId = customerStoreId(c);
const loc = locations.find((l: any) => fnum(l.locationid) === locId);
const storeName = (loc ? fstr(loc.locationname) : fstr(c.locationname)).trim() || '—';
return { return {
id, id,
name: fstr(c.fullname) || `${fstr(c.firstname)} ${fstr(c.lastname)}`.trim() || 'Customer', name: customerName(c) || 'Customer',
phone: fstr(c.contactno) || '—', phone: fstr(c.contactno) || '—',
email: fstr(c.email), email: fstr(c.email),
address: fstr(c.address) || 'Coimbatore', address: fstr(c.address) || '',
ordersCount: Number(c.orderscount) || 0, ordersCount: Number(c.orderscount) || 0,
totalSpent: spent > 0 ? `${spent.toLocaleString('en-IN')}` : '—', totalSpent: spent > 0 ? `${spent.toLocaleString('en-IN')}` : '—',
storeName, storeName,

View File

@@ -1,6 +1,6 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useFiestaCustomerOrders } from '../services/fiestaQueries'; import { useFiestaCustomerOrders } from '../services/fiestaQueries';
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; import { num as fnum, str as fstr, customerName, customerStoreId, type Row } from '../services/fiestaApi';
import { Phone, MapPin, Mail, Receipt, X, Calendar, ShoppingBag, Wallet, TrendingUp, IndianRupee, Store } from 'lucide-react'; import { Phone, MapPin, Mail, Receipt, X, Calendar, ShoppingBag, Wallet, TrendingUp, IndianRupee, Store } from 'lucide-react';
import OrderDetailsModal from './OrderDetailsModal'; import OrderDetailsModal from './OrderDetailsModal';
import './CustomerDetailPanel.css'; import './CustomerDetailPanel.css';
@@ -70,8 +70,12 @@ export default function CustomerDetailPanel({ customer, onClose }: CustomerDetai
}; };
}, [orders]); }, [orders]);
const name = fstr(customer.customername) || fstr(customer.name) || 'Unknown Customer'; // firstname/lastname are what the endpoint actually returns; `customername`
// and `name` are not columns on this response, so the old read always produced
// "Unknown Customer" (and "UC" initials) for every customer.
const name = customerName(customer) || 'Unknown Customer';
const phone = fstr(customer.contactno) || fstr(customer.phone) || ''; const phone = fstr(customer.contactno) || fstr(customer.phone) || '';
const storeId = customerStoreId(customer);
const email = fstr(customer.email) || ''; const email = fstr(customer.email) || '';
const address = fstr(customer.address) || fstr(customer.deliveryaddress) || ''; const address = fstr(customer.address) || fstr(customer.deliveryaddress) || '';
@@ -113,10 +117,10 @@ export default function CustomerDetailPanel({ customer, onClose }: CustomerDetai
<span className="cdp-contact-icon-bg bg-purple-100"><Store size={12} /></span> <span className="cdp-contact-icon-bg bg-purple-100"><Store size={12} /></span>
{fstr(customer.locationname) || fstr(customer.storename)} {fstr(customer.locationname) || fstr(customer.storename)}
</div> </div>
) : fnum(customer.locationid) ? ( ) : storeId ? (
<div className="cdp-contact-chip bg-purple-50 text-purple-700 border border-purple-100" style={{ cursor: 'default' }}> <div className="cdp-contact-chip bg-purple-50 text-purple-700 border border-purple-100" style={{ cursor: 'default' }}>
<span className="cdp-contact-icon-bg bg-purple-100"><Store size={12} /></span> <span className="cdp-contact-icon-bg bg-purple-100"><Store size={12} /></span>
Store {fnum(customer.locationid)} Store {storeId}
</div> </div>
) : null} ) : null}
{phone && ( {phone && (

View File

@@ -132,60 +132,7 @@ export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID
return ( return (
<div className="space-y-lg animate-in fade-in duration-500 relative"> <div className="space-y-lg animate-in fade-in duration-500 relative">
{/* ── Immersive Executive Banner (cover image + slate→purple gradient overlay) ── */}
<div className="relative p-6 md:p-8 text-white shadow-xl border border-purple-500/20 overflow-hidden animate-in fade-in duration-300">
{/* Cover image background & decorative glow */}
<div className="absolute inset-0 z-0 overflow-hidden">
<img
src="https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1400&q=80"
alt="Executive operations dashboard"
className="w-full h-full object-cover object-center opacity-40"
/>
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-900/90 to-purple-950/80" />
<div className="absolute top-0 right-0 w-72 h-72 bg-purple-500/10 rounded-full blur-3xl -mr-20 -mt-20 pointer-events-none" />
<div className="absolute bottom-0 left-0 w-56 h-56 bg-indigo-500/10 rounded-full blur-2xl -ml-20 -mb-20 pointer-events-none" />
</div>
{/* Content row */}
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-lg">
<div>
<h1 className="font-sans font-bold text-2xl md:text-3xl tracking-tight text-white flex items-center gap-2.5">
Executive Command Center
<span className="text-[10px] text-purple-200 font-bold bg-purple-900/60 border border-purple-500/30 px-2 py-0.5 rounded-full uppercase tracking-wider animate-pulse">
Live Core
</span>
</h1>
<p className="text-slate-300 font-sans text-sm mt-2 leading-relaxed">
Month-to-date order operations for <strong className="text-white font-semibold">{tenantName}</strong>, pulled live from the API.
</p>
<div className="mt-4">
{loading ? (
<span className="inline-flex items-center gap-1.5 text-[11px] font-bold text-slate-300 uppercase tracking-wide">
<span className="w-2 h-2 rounded-full bg-slate-400 animate-pulse" /> Syncing live data
</span>
) : errored ? (
<span className="inline-flex items-center gap-1.5 text-[11px] font-bold text-rose-300 uppercase tracking-wide" title="Restart the dev server so the /hasura proxy is active.">
<span className="w-2 h-2 rounded-full bg-rose-400" /> Live data unavailable
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-[11px] font-bold text-emerald-300 uppercase tracking-wide">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" /> Live · {tenantName}
</span>
)}
</div>
</div>
{/* Reporting scope panel */}
<div className="flex flex-col items-start md:items-end gap-2 shrink-0">
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur-md border border-white/15 rounded-xl px-3.5 py-2.5 shadow-sm">
<Clock size={14} className="text-purple-300" />
<span className="text-xs font-bold font-mono text-white tracking-tight">{fromdate} {todate}</span>
</div>
<span className="text-[10px] text-slate-400 uppercase tracking-widest font-bold">Month-to-date reporting scope</span>
</div>
</div>
</div>
{/* Error hint */} {/* Error hint */}
{errored && ( {errored && (
@@ -202,31 +149,35 @@ export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID
)} )}
{/* KPI cards — all live from getordersummary / getinvoiceinsight */} {/* KPI cards — all live from getordersummary / getinvoiceinsight */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-gutter"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
{kpis.map((kpi) => { {kpis.map((kpi) => {
const Icon = kpi.icon; const Icon = kpi.icon;
return ( return (
<div <div
key={kpi.title} key={kpi.title}
className="group relative flex flex-col overflow-hidden bg-white border border-slate-200/70 rounded-2xl p-5 shadow-[0_1px_2px_rgba(16,24,40,0.04)] transition-all duration-300 hover:-translate-y-1 hover:border-purple-200 hover:shadow-[0_16px_36px_rgba(16,24,40,0.10)]" className="group relative flex items-center gap-3 overflow-hidden bg-white border border-slate-200/70 rounded-xl p-3 shadow-[0_1px_2px_rgba(16,24,40,0.04)] transition-all duration-300 hover:-translate-y-1 hover:border-purple-200 hover:shadow-[0_16px_36px_rgba(16,24,40,0.10)]"
> >
{/* Gradient accent bar */} {/* Gradient accent bar */}
<span className={`absolute inset-x-0 top-0 h-1 bg-gradient-to-r ${kpi.bar}`} /> <span className={`absolute inset-y-0 left-0 w-1 bg-gradient-to-b ${kpi.bar}`} />
<div className="flex items-start justify-between">
<div className={`h-11 w-11 rounded-xl flex items-center justify-center ring-1 group-hover:scale-110 transition-transform duration-300 ${kpi.chip}`}> <div className={`h-10 w-10 shrink-0 rounded-lg flex items-center justify-center ring-1 group-hover:scale-110 transition-transform duration-300 ml-1 ${kpi.chip}`}>
<Icon size={19} /> <Icon size={18} />
</div> </div>
<ArrowUpRight size={16} className="text-slate-300 group-hover:text-purple-400 transition-colors" />
<div className="flex-1 min-w-0 flex flex-col justify-center">
<div className="flex items-center justify-between">
<p className="text-[9px] font-bold text-slate-400 tracking-widest uppercase font-sans truncate pr-2">
{kpi.title}
</p>
<ArrowUpRight size={12} className="text-slate-300 group-hover:text-purple-400 transition-colors shrink-0" />
</div>
<p className="font-sans font-extrabold text-lg leading-tight text-slate-900 tracking-tight mt-0.5 truncate">
{kpi.loading ? <span className="text-slate-300"></span> : kpi.display}
</p>
<p className="text-[9px] text-slate-400 font-medium mt-0.5 leading-snug truncate">
{kpi.sub}
</p>
</div> </div>
<p className="text-[10px] font-bold text-slate-400 tracking-widest uppercase font-sans mt-4">
{kpi.title}
</p>
<p className="font-sans font-extrabold text-[28px] leading-tight text-slate-900 tracking-tight mt-1">
{kpi.loading ? <span className="text-slate-300"></span> : kpi.display}
</p>
<p className="text-[11px] text-slate-400 font-medium mt-1.5 leading-snug">
{kpi.sub}
</p>
</div> </div>
); );
})} })}

View File

@@ -18,8 +18,8 @@ import { createPortal } from 'react-dom';
import { import {
Truck, Clock, CheckCircle2, XCircle, Calendar, Sun, Sunset, Moon, Layers, UserCheck, MapPin, Phone, Package, Loader2, X, Bike, Truck, Clock, CheckCircle2, XCircle, Calendar, Sun, Sunset, Moon, Layers, UserCheck, MapPin, Phone, Package, Loader2, X, Bike,
} from 'lucide-react'; } from 'lucide-react';
import { useFiestaDeliverySummary, useFiestaDeliveries, useFiestaRiders, useFiestaOrderDetails } from '../services/fiestaQueries'; import { useFiestaDeliverySummary, useFiestaDeliveries, useFiestaRiders, useFiestaOrderDetails, useFiestaChangeRider, useFiestaNotifyRider } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi'; import { FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, RIDER_MESSAGES, RiderNotReachableError, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import { shortTime } from '../services/fiestaMappers'; import { shortTime } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi'; import AwaitingApi from './AwaitingApi';
import { import {
@@ -27,7 +27,7 @@ import {
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge, DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge,
} from './consoleUi'; } from './consoleUi';
interface DeliveriesViewProps { searchQuery?: string; locationid?: number; tenantId?: number; date?: string; } interface DeliveriesViewProps { searchQuery?: string; locationid?: number; tenantId?: number; applocationid?: number; date?: string; }
type DeliveryStatus = 'pending' | 'accepted' | 'arrived' | 'picked' | 'active' | 'skipped' | 'delivered' | 'cancelled'; type DeliveryStatus = 'pending' | 'accepted' | 'arrived' | 'picked' | 'active' | 'skipped' | 'delivered' | 'cancelled';
const STATUS_TABS: Array<{ key: DeliveryStatus; label: string }> = [ const STATUS_TABS: Array<{ key: DeliveryStatus; label: string }> = [
@@ -57,7 +57,7 @@ function inBatch(r: Row, b: BatchId): boolean {
return h >= 16 && h < 19; return h >= 16 && h < 19;
} }
export default function DeliveriesView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID, date }: DeliveriesViewProps) { export default function DeliveriesView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID, applocationid = FIESTA_APPLOCATION_ID, date }: DeliveriesViewProps) {
const today = new Date(); const today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); }; const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); };
@@ -89,7 +89,15 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
// the whole day (status='all', large pagesize); status/search filter client-side. // the whole day (status='all', large pagesize); status/search filter client-side.
const summaryQ = useFiestaDeliverySummary({ tenantid: tenantId, fromdate, todate, locationid }); const summaryQ = useFiestaDeliverySummary({ tenantid: tenantId, fromdate, todate, locationid });
const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate, todate, locationid, status: 'all', pagesize: 200 }); const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate, todate, locationid, status: 'all', pagesize: 200 });
const ridersQ = useFiestaRiders({ tenantid: tenantId }); // Scoped by app-location, not tenant: rider records leave app_users.tenantid
// unset, so a tenant-scoped call returns an empty list and the change-rider
// dropdown would have nothing in it. Prefer the app-location the visible
// deliveries name, falling back to the caller's.
const rowApplocationId = useMemo(
() => fnum((deliveriesQ.data ?? []).find((r) => fnum(r.applocationid))?.applocationid),
[deliveriesQ.data],
);
const ridersQ = useFiestaRiders({ applocationid: rowApplocationId || applocationid || undefined });
const allRows = deliveriesQ.data ?? []; const allRows = deliveriesQ.data ?? [];
const summary = summaryQ.data; const summary = summaryQ.data;
@@ -243,13 +251,142 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
</div> </div>
</div> </div>
{detailRow && <DeliveryDetailModal row={detailRow} onClose={() => setDetailRow(null)} />} {detailRow && <DeliveryDetailModal row={detailRow} riders={ridersQ.data ?? []} onClose={() => setDetailRow(null)} />}
</div>
);
}
/**
* Change-rider / notify controls for one delivery.
*
* Change-rider is offered only while the delivery is still pending, accepted or
* arrived — reassigning resets orderstatus to 'pending', so allowing it once the
* parcel is picked up or delivered would rewind a completed journey.
*
* Notify and reassign are reported separately on purpose. The reassign is
* committed the moment it returns; a push that then fails leaves a rider
* holding work nobody told them about, which is worth saying out loud rather
* than folding into a single "done".
*/
function RiderActions({ row, riders }: { row: Row; riders: Row[] }) {
const st = fstr(row.orderstatus).toLowerCase();
const deliveryid = fnum(row.deliveryid);
const orderheaderid = fnum(row.orderheaderid);
const canChange = ['pending', 'accepted', 'arrived'].includes(st) && deliveryid > 0;
const canNotify = st !== 'delivered' && st !== 'cancelled';
const [picked, setPicked] = useState(0);
const [msg, setMsg] = useState('');
const changeMut = useFiestaChangeRider();
const notifyMut = useFiestaNotifyRider();
const options = useMemo(
() =>
riders
.map((r) => ({
id: fnum(r.userid),
label: (fstr(r.fullname) || `${fstr(r.firstname)} ${fstr(r.lastname)}`).trim(),
token: fstr(r.userfcmtoken),
}))
.filter((o) => o.id > 0 && o.label),
[riders],
);
// The rider currently on the delivery. Deliveries carry the token on the row
// itself, so notifying does not depend on them still being in the on-duty list.
const currentToken = fstr(row.userfcmtoken);
const push = async (token: string, body: string, data?: Record<string, string>, okLabel = 'Rider notified') => {
try {
await notifyMut.mutateAsync({ token, body, data });
setMsg(okLabel);
} catch (err) {
setMsg(
err instanceof RiderNotReachableError
? 'Not sent — this rider has no device registered'
: 'Not sent — the push failed, tell them another way',
);
}
};
const handleChange = async () => {
if (!picked) return;
const option = options.find((o) => o.id === picked);
try {
await changeMut.mutateAsync({ deliveryid, orderheaderid, userid: picked });
setMsg(`Moved to ${option?.label ?? 'rider'} · notifying…`);
await push(option?.token ?? '', RIDER_MESSAGES.reassigned, undefined, `Moved to ${option?.label ?? 'rider'} · notified`);
} catch {
setMsg('Could not change the rider — please retry.');
}
};
const busy = changeMut.isPending || notifyMut.isPending;
return (
<div className="rounded-xl p-3" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
<p className="text-[10px] font-extrabold uppercase tracking-wider mb-2" style={{ color: TEXT_2 }}>Rider actions</p>
{canChange ? (
<div className="flex flex-wrap items-center gap-2">
<select
value={picked}
onChange={(e) => setPicked(Number(e.target.value))}
disabled={busy}
className="rounded-full font-bold text-[11px] outline-none cursor-pointer disabled:opacity-50"
style={{ padding: '6px 10px', border: `1px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 200 }}
>
<option value={0}>{options.length ? 'Change rider…' : 'No riders on duty'}</option>
{options.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<button
onClick={handleChange}
disabled={!picked || busy}
className="rounded-full font-extrabold cursor-pointer text-white disabled:opacity-40 disabled:cursor-not-allowed"
style={{ padding: '6px 14px', fontSize: 11, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}
>
{changeMut.isPending ? 'Moving…' : 'Change'}
</button>
</div>
) : (
<p className="text-[11px] font-medium" style={{ color: TEXT_3 }}>
{st === 'delivered' || st === 'cancelled'
? `This delivery is ${st} — the rider can no longer be changed.`
: 'The rider can only be changed while a delivery is pending, accepted or arrived.'}
</p>
)}
{canNotify && (
<div className="flex flex-wrap items-center gap-2 mt-2 pt-2" style={{ borderTop: `1px solid ${DIVIDER}` }}>
<button
onClick={() => push(currentToken, RIDER_MESSAGES.reminder)}
disabled={busy}
className="rounded-full font-bold cursor-pointer disabled:opacity-40"
style={{ padding: '5px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}
>
Notify rider
</button>
<button
onClick={() =>
push(currentToken, RIDER_MESSAGES.cancelled(fstr(row.orderid) || `DLV-${deliveryid}`), { type: 'cancel' }, 'Cancellation sent')
}
disabled={busy}
title="Tells the rider app to drop this delivery"
className="rounded-full font-bold cursor-pointer disabled:opacity-40"
style={{ padding: '5px 12px', fontSize: 11, color: '#b91c1c', background: '#fef2f2', border: '1px solid #fecaca' }}
>
Send cancellation
</button>
</div>
)}
{msg && <p className="text-[11px] font-semibold mt-2" style={{ color: TEXT_2 }}>{msg}</p>}
</div> </div>
); );
} }
// ── Delivery details modal ────────────────────────────────────────────────────── // ── Delivery details modal ──────────────────────────────────────────────────────
function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }) { function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[]; onClose: () => void }) {
const orderheaderid = row.orderheaderid ?? row.orderid; const orderheaderid = row.orderheaderid ?? row.orderid;
const detailsQ = useFiestaOrderDetails(orderheaderid as number | string); const detailsQ = useFiestaOrderDetails(orderheaderid as number | string);
const lines = (detailsQ.data ?? []).map((d) => { const lines = (detailsQ.data ?? []).map((d) => {
@@ -269,9 +406,9 @@ function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }
return createPortal( return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}> <div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="bg-white max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}> <div className="bg-white max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
<div style={{ height: 4, background: `linear-gradient(90deg, #6366f1 0%, ${soft('#6366f1')} 100%)` }} /> <div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${BRAND_LIGHT} 100%)` }} />
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}> <div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Truck size={16} style={{ color: '#6366f1' }} /> {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}</h4> <h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Truck size={16} style={{ color: BRAND }} /> {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}</h4>
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button> <button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button>
</div> </div>
<div className="p-4 space-y-4 overflow-y-auto flex-1"> <div className="p-4 space-y-4 overflow-y-auto flex-1">
@@ -312,7 +449,7 @@ function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }
))} ))}
</div> </div>
</div> </div>
<AwaitingApi label="Reassign · Cancel · Notify rider" api="dispatch backend" compact /> <RiderActions row={row} riders={riders} />
</div> </div>
<div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}> <div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}>Close</button> <button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}>Close</button>

View File

@@ -8,8 +8,8 @@ import { Route, ShoppingBag, Truck, MapPin, Calendar, ChevronLeft, ChevronRight,
import DispatchView from './DispatchView'; import DispatchView from './DispatchView';
import OrdersView from './OrdersView'; import OrdersView from './OrdersView';
import DeliveriesView from './DeliveriesView'; import DeliveriesView from './DeliveriesView';
import { useFiestaDeliveries } from '../services/fiestaQueries'; import { useFiestaDeliveries, useFiestaTenantLocations } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, ymd, num as fnum } from '../services/fiestaApi'; import { FIESTA_TENANT_ID, ymd, num as fnum, str as fstr } from '../services/fiestaApi';
import type { Row } from '../services/fiestaApi'; import type { Row } from '../services/fiestaApi';
import './DispatchView.css'; // For #hdr and date-chip styles import './DispatchView.css'; // For #hdr and date-chip styles
@@ -34,6 +34,26 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
const rows = useMemo(() => allRows.filter(inScope), [allRows, locationid]); const rows = useMemo(() => allRows.filter(inScope), [allRows, locationid]);
const totalOrders = rows.length; const totalOrders = rows.length;
// Operating area for the header pill. This was the hardcoded string
// "Coimbatore", shown to every tenant on the platform regardless of where they
// actually trade. The locations query is already cached by DispatchView (same
// key), so reading it here costs no extra request.
const locationsQ = useFiestaTenantLocations(tenantId);
const scopeLabel = useMemo(() => {
const locs = locationsQ.data ?? [];
if (locs.length === 0) return '';
if (locationid) {
// Store user — name the outlet's own city.
const mine = locs.find((l) => fnum(l.locationid) === locationid);
return fstr(mine?.city).trim() || fstr(mine?.state).trim();
}
// Admin — one city if the whole tenant sits in one, otherwise a count.
const cities = [...new Set(locs.map((l) => fstr(l.city).trim()).filter(Boolean))];
if (cities.length === 1) return cities[0];
if (cities.length > 1) return `${cities.length} cities`;
return '';
}, [locationsQ.data, locationid]);
const isToday = date === ymd(today); const isToday = date === ymd(today);
const dateObj = new Date(`${date}T00:00:00`); const dateObj = new Date(`${date}T00:00:00`);
const prettyDate = `${WEEKDAYS[dateObj.getDay()]}, ${dateObj.getDate()} ${MONTHS[dateObj.getMonth()]}`; const prettyDate = `${WEEKDAYS[dateObj.getDay()]}, ${dateObj.getDate()} ${MONTHS[dateObj.getMonth()]}`;
@@ -53,12 +73,14 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center font-extrabold text-xs sm:text-sm text-white shadow-sm">C</div> <div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center font-extrabold text-xs sm:text-sm text-white shadow-sm">C</div>
<div className="text-base sm:text-lg font-extrabold text-slate-800 tracking-tight">Console</div> <div className="text-base sm:text-lg font-extrabold text-slate-800 tracking-tight">Console</div>
<div className="relative inline-block ml-1"> {scopeLabel && (
<span className="flex items-center gap-1 px-2 sm:px-2.5 py-0.5 sm:py-1 bg-purple-50 text-purple-700 border border-purple-200/60 rounded-full text-[10px] sm:text-[11px] font-bold cursor-default shadow-sm"> <div className="relative inline-block ml-1">
<MapPin size={12} className="text-purple-500" /> <span className="flex items-center gap-1 px-2 sm:px-2.5 py-0.5 sm:py-1 bg-purple-50 text-purple-700 border border-purple-200/60 rounded-full text-[10px] sm:text-[11px] font-bold cursor-default shadow-sm">
<span className="max-w-[120px] sm:max-w-[180px] truncate">Coimbatore</span> <MapPin size={12} className="text-purple-500" />
</span> <span className="max-w-[120px] sm:max-w-[180px] truncate">{scopeLabel}</span>
</div> </span>
</div>
)}
</div> </div>
</div> </div>
@@ -77,7 +99,7 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-semibold text-emerald-600 bg-emerald-50 px-2.5 sm:px-3 py-1 sm:py-1.5 rounded-full border border-emerald-100"> <span className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-semibold text-emerald-600 bg-emerald-50 px-2.5 sm:px-3 py-1 sm:py-1.5 rounded-full border border-emerald-100">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" /> Live · {totalOrders} orders <span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" /> Live · {totalOrders} {totalOrders === 1 ? 'delivery' : 'deliveries'}
</span> </span>
)} )}
@@ -140,7 +162,9 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
</button> </button>
</div> </div>
{/* Sub-tabs for Map View */} {/* Sub-tabs for Map View — only meaningful on the Map tab; they used to
stay visible (and inert) on Orders and Deliveries. */}
{activeTab === 'map' && (
<div className="flex items-center p-1 ml-3 sm:ml-4 bg-slate-100/80 rounded-lg border border-slate-200/80 shadow-inner shrink-0"> <div className="flex items-center p-1 ml-3 sm:ml-4 bg-slate-100/80 rounded-lg border border-slate-200/80 shadow-inner shrink-0">
<button <button
onClick={() => setMapViewMode('stores')} onClick={() => setMapViewMode('stores')}
@@ -160,6 +184,7 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
<Users size={13} className={mapViewMode === 'customers' ? 'text-blue-500' : 'text-slate-400'} /> By Customer <Users size={13} className={mapViewMode === 'customers' ? 'text-blue-500' : 'text-slate-400'} /> By Customer
</button> </button>
</div> </div>
)}
</div> </div>
</div> </div>

View File

@@ -2555,6 +2555,32 @@
transform: translateX(4px); transform: translateX(4px);
} }
/* Idle outlet — listed so the admin can see every store under the tenant, but
visually recessed so the stores actually dispatching today read first. Still
clickable: the focused view explains that nothing has gone out. */
.dispatch-container .rcard.zone-card.is-idle {
background: #fcfcfd;
box-shadow: none;
}
.dispatch-container .rcard.zone-card.is-idle::before {
background: linear-gradient(180deg, #cbd5e1, #94a3b8);
opacity: 0.4;
}
.dispatch-container .rcard.zone-card.is-idle .zone-card-name {
color: var(--text-muted);
}
.dispatch-container .rcard.zone-card.is-idle .zone-card-emoji {
background: rgba(148, 163, 184, 0.12);
border-color: rgba(148, 163, 184, 0.25);
}
.dispatch-container .rcard.zone-card.is-idle .zone-card-header {
margin-bottom: 0;
}
/* Progress row: status bar + delivered/total counter */ /* Progress row: status bar + delivered/total counter */
.dispatch-container .zone-progress-row { .dispatch-container .zone-progress-row {
display: flex; display: flex;

View File

@@ -22,10 +22,9 @@ import {
Map as MapIcon, Map as MapIcon,
MapPin, MapPin,
Bike, Bike,
Store,
Users, Users,
Phone, Phone,
ShoppingBag, Store,
Truck, Truck,
Package, Package,
Ruler, Ruler,
@@ -36,7 +35,6 @@ import {
Mailbox, Mailbox,
StickyNote, StickyNote,
ArrowLeftRight, ArrowLeftRight,
Calendar,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
List, List,
@@ -44,20 +42,19 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { import {
useFiestaDeliveries, useFiestaDeliveries,
useFiestaRiders,
useFiestaRiderPeriodicLogs,
useFiestaTenantLocations, useFiestaTenantLocations,
useFiestaTenantCustomers, useFiestaTenantCustomers,
} from '../services/fiestaQueries'; } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import { import {
colorFor, FIESTA_TENANT_ID,
getStatusStyle, num as fnum,
STATUS_STYLES, str as fstr,
extractTimeOnly, customerName,
} from '../services/dispatchShared'; customerStoreId,
type Row,
} from '../services/fiestaApi';
import { colorFor } from '../services/dispatchShared';
import DispatchMap, { type MapPoint } from './DispatchMap'; import DispatchMap, { type MapPoint } from './DispatchMap';
import RiderTelemetryPanel from './RiderTelemetryPanel';
import CustomerDetailPanel from './CustomerDetailPanel'; import CustomerDetailPanel from './CustomerDetailPanel';
import './DispatchView.css'; import './DispatchView.css';
@@ -78,6 +75,25 @@ function statusStyle(s: string): React.CSSProperties {
return { background: `${hex}1f`, color: hex }; return { background: `${hex}1f`, color: hex };
} }
/**
* Best available area label for a delivery row. `getdeliveries` has NO
* `deliverysuburb` column — reading it left every "areas" count at 0 and hid the
* suburb strip entirely. The feed does carry `locationsuburb` and a full
* `deliveryaddress`, so fall back to the address's locality segment
* ("12 Main St, Peelamedu, Coimbatore, 641004" → "Coimbatore").
*/
function areaOf(r: Row): string {
const direct = fstr(r.deliverysuburb).trim() || fstr(r.locationsuburb).trim();
if (direct) return direct;
const parts = fstr(r.deliveryaddress)
.split(',')
.map((p) => p.trim())
.filter(Boolean)
// Trailing postcode / country segments aren't areas.
.filter((p) => !/^\d{4,6}$/.test(p) && p.toLowerCase() !== 'india');
return parts.length > 1 ? parts[parts.length - 1] : '';
}
/** Drop coordinates from a delivery row (several field spellings), or null. */ /** Drop coordinates from a delivery row (several field spellings), or null. */
function dropLatLon(r: Row): [number, number] | null { function dropLatLon(r: Row): [number, number] | null {
const lat = fnum(r.droplat) || fnum(r.deliverylat) || fnum(r.deliverylatitude); const lat = fnum(r.droplat) || fnum(r.deliverylat) || fnum(r.deliverylatitude);
@@ -92,12 +108,9 @@ function pickupLatLon(r: Row): [number, number] | null {
return lat && lon ? [lat, lon] : null; return lat && lon ? [lat, lon] : null;
} }
// ── View modes (match #strat-row tabs) ─────────────────────────────────────────── // ── View modes ──────────────────────────────────────────────────────────────────
type ViewMode = 'stores' | 'zones' | 'customers' | 'riders' | 'kitchens'; // The tab row lives in DispatchHubView; only these two modes are reachable.
const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [ type ViewMode = 'stores' | 'customers';
{ id: 'stores', label: 'By Store', icon: Store },
{ id: 'customers', label: 'By Customer', icon: Users },
];
interface Group { interface Group {
id: string; id: string;
@@ -111,21 +124,19 @@ interface Group {
suburbs: Map<string, number>; suburbs: Map<string, number>;
statusCounts: Record<string, number>; statusCounts: Record<string, number>;
raw?: any; raw?: any;
/** Customer view: the outlet this customer is registered against, for the card badge. */
storeName?: string;
} }
interface DispatchViewProps { interface DispatchViewProps {
locationid?: number; locationid?: number;
tenantId?: number; tenantId?: number;
date: string; date: string;
viewMode: 'stores' | 'zones' | 'customers' | 'riders' | 'kitchens'; viewMode: ViewMode;
} }
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID, date, viewMode }: DispatchViewProps) { export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID, date, viewMode }: DispatchViewProps) {
const [focusedId, setFocusedId] = useState<string | null>(null); const [focusedId, setFocusedId] = useState<string | null>(null);
const [focusedRiderId, setFocusedRiderId] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
setFocusedId(null); setFocusedId(null);
@@ -140,16 +151,10 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
// Core dispatch data // Core dispatch data
const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate: date, todate: date, locationid }); const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate: date, todate: date, locationid });
const locationsQ = useFiestaTenantLocations(tenantId); const locationsQ = useFiestaTenantLocations(tenantId);
// Customers. Admin (no locationid) gets every customer under the tenant and
// filters client-side; a store user sends its locationid so the backend scopes
// to that outlet server-side via `tenantcustomers.locationid`.
const customersQ = useFiestaTenantCustomers({ tenantid: tenantId, locationid: locationid || 0 }); const customersQ = useFiestaTenantCustomers({ tenantid: tenantId, locationid: locationid || 0 });
const ridersQ = useFiestaRiders({ tenantid: tenantId });
// Rider periodic logs (GPS snapshots) for the focused rider
const riderLogsQ = useFiestaRiderPeriodicLogs({
userid: focusedRiderId ?? undefined,
fromdate: date,
todate: date,
tenantid: tenantId,
});
// Live deliveries only — no sample/demo fallback. When the feed is empty the // Live deliveries only — no sample/demo fallback. When the feed is empty the
// cockpit shows a genuine empty state rather than fabricated riders/stops. // cockpit shows a genuine empty state rather than fabricated riders/stops.
@@ -162,55 +167,94 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
[allRows, locationid], [allRows, locationid],
); );
/** locationid → outlet name, for stamping a store badge on customer cards. */
const storeNames = useMemo(() => {
const m = new Map<number, string>();
for (const loc of locationsQ.data ?? []) {
const id = fnum(loc.locationid);
if (id) m.set(id, fstr(loc.locationname).trim() || `Store ${id}`);
}
return m;
}, [locationsQ.data]);
// ── Grouping ──────────────────────────────────────────────────────────────── // ── Grouping ────────────────────────────────────────────────────────────────
// Both modes seed from the roster first (every outlet / every customer under
// the scope) and then fold the day's deliveries into those seeds. That's what
// makes an outlet with no deliveries today, or a customer who hasn't ordered
// today, still appear in the list rather than vanishing.
const groups = useMemo<Group[]>(() => { const groups = useMemo<Group[]>(() => {
const map = new Map<string, Group>(); const map = new Map<string, Group>();
const blank = (id: string, name: string): Group => ({
id,
name,
color: colorFor(id),
orders: [],
delivered: 0,
totalKm: 0,
profit: 0,
riders: new Set(),
suburbs: new Map(),
statusCounts: {},
});
if (viewMode === 'stores' && locationsQ.data) { if (viewMode === 'stores' && locationsQ.data) {
// Admin: every outlet under the tenant. Store user: only its own, because
// `locationid` is set.
for (const loc of locationsQ.data) { for (const loc of locationsQ.data) {
if (locationid && fnum(loc.locationid) !== locationid) continue; if (locationid && fnum(loc.locationid) !== locationid) continue;
const id = String(fnum(loc.locationid)).toLowerCase(); const id = String(fnum(loc.locationid)).toLowerCase();
const name = fstr(loc.locationname) || `Store ${id}`; map.set(id, blank(id, fstr(loc.locationname) || `Store ${id}`));
map.set(id, { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {} });
} }
} }
if (viewMode === 'customers' && customersQ.data) { if (viewMode === 'customers' && customersQ.data) {
for (const cust of customersQ.data) { for (const cust of customersQ.data) {
if (customerStoreFilter !== 'all') { const storeId = customerStoreId(cust);
const locId = String(fnum(cust.locationid)); // Admin's store dropdown. A store user never sees it (the backend has
if (locId !== customerStoreFilter) continue; // already scoped the list), so this only ever narrows the admin view.
} if (customerStoreFilter !== 'all' && String(storeId) !== customerStoreFilter) continue;
const id = String(fnum(cust.customerid) || fstr(cust.contactno)).toLowerCase(); const id = String(fnum(cust.customerid) || fstr(cust.contactno)).toLowerCase();
const name = fstr(cust.customername) || fstr(cust.name) || `Customer ${id}`; map.set(id, {
map.set(id, { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {}, raw: cust }); ...blank(id, customerName(cust) || `Customer ${id}`),
raw: cust,
storeName: storeNames.get(storeId),
});
} }
} }
const titleCase = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
const keyOf = (r: Row): { id: string; name: string } => { const keyOf = (r: Row): { id: string; name: string } => {
if (viewMode === 'riders') {
const id = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
return { id, name: fstr(r.ridername) || fstr(r.username) || (id === 'unassigned' ? 'Unassigned' : `Rider ${id}`) };
}
if (viewMode === 'stores') { if (viewMode === 'stores') {
const locId = fstr(r.locationid) || fstr(r.pickuplocationid) || 'unknown'; const locId = fstr(r.locationid) || fstr(r.pickuplocationid) || 'unknown';
const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || `Store ${locId}`; const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || `Store ${locId}`;
return { id: locId.toLowerCase(), name }; return { id: locId.toLowerCase(), name };
} }
if (viewMode === 'customers') { const custId = fstr(r.customerid) || fstr(r.deliverycontactno) || 'unknown';
const custId = fstr(r.customerid) || fstr(r.contactno) || fstr(r.deliverycustomerphone) || 'unknown'; return { id: custId.toLowerCase(), name: customerName(r) || `Customer ${custId}` };
const name = fstr(r.deliverycustomer) || fstr(r.customername) || `Customer ${custId}`;
return { id: custId.toLowerCase(), name };
}
const name = fstr(r.deliverysuburb) || fstr(r.zone_name) || 'Unzoned';
return { id: name.toLowerCase(), name };
}; };
for (const r of rows) { for (const r of rows) {
const { id, name } = keyOf(r); const { id, name } = keyOf(r);
let g = map.get(id); let g = map.get(id);
if (!g) { if (!g) {
g = { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {} }; // A delivery whose customer isn't in the roster (not linked to this
// tenant, or beyond the page we fetched). Keep it — dropping it would
// hide real work from the board — but honour an active store filter.
if (viewMode === 'customers' && customerStoreFilter !== 'all') continue;
g = blank(id, name);
if (viewMode === 'customers') {
// Stand in for the missing customer record so the card still opens.
// Without a `raw` the detail panel stayed on "Select a customer" no
// matter how many times the card was clicked.
g.raw = {
customerid: fnum(r.customerid),
firstname: fstr(r.deliverycustomer),
contactno: fstr(r.deliverycontactno),
address: fstr(r.deliveryaddress),
tenantlocationid: fnum(r.locationid),
locationname: fstr(r.locationname),
};
g.storeName = storeNames.get(fnum(r.locationid)) || fstr(r.locationname) || undefined;
}
map.set(id, g); map.set(id, g);
} }
g.orders.push(r); g.orders.push(r);
@@ -221,11 +265,11 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
g.profit += fnum(r.profit); g.profit += fnum(r.profit);
const rid = fstr(r.userid) || fstr(r.ridername); const rid = fstr(r.userid) || fstr(r.ridername);
if (rid) g.riders.add(rid); if (rid) g.riders.add(rid);
const sub = fstr(r.deliverysuburb); const sub = areaOf(r);
if (sub) g.suburbs.set(sub, (g.suburbs.get(sub) ?? 0) + 1); if (sub) g.suburbs.set(sub, (g.suburbs.get(sub) ?? 0) + 1);
} }
return Array.from(map.values()).sort((a, b) => b.orders.length - a.orders.length || a.name.localeCompare(b.name)); return Array.from(map.values()).sort((a, b) => b.orders.length - a.orders.length || a.name.localeCompare(b.name));
}, [rows, viewMode, locationsQ.data, customersQ.data]); }, [rows, viewMode, locationid, locationsQ.data, customersQ.data, customerStoreFilter, storeNames]);
useEffect(() => { useEffect(() => {
if (viewMode === 'stores' && locationid && groups.length === 1 && !focusedId) { if (viewMode === 'stores' && locationid && groups.length === 1 && !focusedId) {
@@ -234,28 +278,16 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
}, [viewMode, locationid, groups, focusedId]); }, [viewMode, locationid, groups, focusedId]);
const focused = groups.find((g) => g.id === focusedId) ?? null; const focused = groups.find((g) => g.id === focusedId) ?? null;
const groupedByRider = viewMode !== 'riders';
// Trip blocks for the focused group: by trip# (rider view) or by rider (zone/all view). // Trip blocks for the focused group, one per rider.
const tripBlocks = useMemo(() => { const tripBlocks = useMemo(() => {
if (!focused) return []; if (!focused) return [];
const map = new Map<string, { label: string; color: string; orders: Row[] }>(); const map = new Map<string, { label: string; color: string; orders: Row[] }>();
for (const r of focused.orders) { for (const r of focused.orders) {
let key: string; const rid = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
let label: string; const label = fstr(r.ridername) || (rid === 'unassigned' ? 'Unassigned' : `Rider ${rid}`);
let color: string; let blk = map.get(rid);
if (groupedByRider) { if (!blk) { blk = { label, color: colorFor(rid), orders: [] }; map.set(rid, blk); }
const rid = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
key = rid;
label = fstr(r.ridername) || fstr(r.username) || (rid === 'unassigned' ? 'Unassigned' : `Rider ${rid}`);
color = colorFor(rid);
} else {
key = fstr(r.trip_number) || '1';
label = `Trip ${key}`;
color = focused.color;
}
let blk = map.get(key);
if (!blk) { blk = { label, color, orders: [] }; map.set(key, blk); }
blk.orders.push(r); blk.orders.push(r);
} }
const blocks = Array.from(map.values()); const blocks = Array.from(map.values());
@@ -266,6 +298,8 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
const tb = fstr(b.deliverytime) || fstr(b.expecteddeliverytime); const tb = fstr(b.deliverytime) || fstr(b.expecteddeliverytime);
return ta.localeCompare(tb); return ta.localeCompare(tb);
} }
// `step` isn't in the getdeliveries response, so planned order falls back
// to assignment time. Kept in case the backend starts sending it.
const sa = fnum(a.step); const sa = fnum(a.step);
const sb = fnum(b.step); const sb = fnum(b.step);
if (sa && sb && sa !== sb) return sa - sb; if (sa && sb && sa !== sb) return sa - sb;
@@ -273,7 +307,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
}); });
} }
return blocks; return blocks;
}, [focused, groupedByRider, tripSort]); }, [focused, tripSort]);
// Map points: the focused group's ordered stops (with a route), else every stop // Map points: the focused group's ordered stops (with a route), else every stop
// for the day (coloured per rider). Rows without coordinates are skipped. // for the day (coloured per rider). Rows without coordinates are skipped.
@@ -289,8 +323,8 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
lon: ll[1], lon: ll[1],
step: fnum(r.step) || i + 1, step: fnum(r.step) || i + 1,
color: focused ? focused.color : colorFor(fstr(r.userid) || fstr(r.ridername) || 'x'), color: focused ? focused.color : colorFor(fstr(r.userid) || fstr(r.ridername) || 'x'),
title: fstr(r.deliverycustomer) || `Order ${fstr(r.orderid)}`, title: customerName(r) || `Order ${fstr(r.orderid)}`,
subtitle: fstr(r.deliverysuburb) || fstr(r.deliveryaddress), subtitle: areaOf(r) || fstr(r.deliveryaddress),
status: fstr(r.orderstatus), status: fstr(r.orderstatus),
raw: r, raw: r,
}); });
@@ -302,11 +336,14 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
const firstOrder = tripBlocks[0]?.orders[0] ?? focused?.orders[0]; const firstOrder = tripBlocks[0]?.orders[0] ?? focused?.orders[0];
const routeStart = focused && firstOrder ? pickupLatLon(firstOrder) : null; const routeStart = focused && firstOrder ? pickupLatLon(firstOrder) : null;
// The roster query matters as much as the delivery feed — gating on deliveries
// alone flashed "No customers found" while the customer list was still loading.
const isLoading =
deliveriesQ.isLoading || (viewMode === 'customers' ? customersQ.isLoading : locationsQ.isLoading);
// KPI scope. // KPI scope.
const totalOrders = rows.length; const totalOrders = rows.length;
const totalDelivered = rows.filter((r) => fstr(r.orderstatus).toLowerCase() === 'delivered').length; const totalDelivered = rows.filter((r) => fstr(r.orderstatus).toLowerCase() === 'delivered').length;
const activeRiders = new Set(rows.map((r) => fstr(r.userid) || fstr(r.ridername)).filter(Boolean)).size;
const fleetSize = (ridersQ.data ?? []).length;
const fmtTime = (raw: unknown): string => { const fmtTime = (raw: unknown): string => {
const m = fstr(raw).match(/(\d{1,2}):(\d{2})/); const m = fstr(raw).match(/(\d{1,2}):(\d{2})/);
@@ -364,26 +401,27 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
</div> </div>
<div id="riders-panel"> <div id="riders-panel">
{deliveriesQ.isLoading ? ( {isLoading ? (
<div className="ph">Loading dispatch feed</div> <div className="ph">{viewMode === 'customers' ? 'Loading customers…' : 'Loading dispatch feed…'}</div>
) : focused && viewMode !== 'customers' ? ( ) : focused && viewMode !== 'customers' ? (
<FocusedDetail <FocusedDetail
focused={focused} focused={focused}
tripBlocks={tripBlocks} tripBlocks={tripBlocks}
groupedByRider={groupedByRider}
tripSort={tripSort} tripSort={tripSort}
setTripSort={setTripSort} setTripSort={setTripSort}
onBack={(locationid && viewMode === 'stores' && groups.length === 1) ? undefined : () => setFocusedId(null)} onBack={(locationid && viewMode === 'stores' && groups.length === 1) ? undefined : () => setFocusedId(null)}
fmtTime={fmtTime} fmtTime={fmtTime}
riderLogs={riderLogsQ.data}
riderLogsLoading={riderLogsQ.isLoading}
/> />
) : groups.length === 0 ? ( ) : groups.length === 0 ? (
<div className="ph">{viewMode === 'customers' ? 'No customers found' : 'No deliveries for this day'}</div> <div className="ph">
{viewMode === 'customers'
? (customerStoreFilter !== 'all' ? 'No customers for this store' : 'No customers found')
: 'No stores found'}
</div>
) : ( ) : (
<> <>
<div className="ph relative flex items-center justify-between w-full h-8"> <div className="ph relative flex items-center justify-between w-full h-8">
<span>{viewMode === 'riders' ? 'Riders' : viewMode === 'customers' ? 'Customers' : viewMode === 'stores' ? 'Stores' : 'Zones'} ({groups.length})</span> <span>{viewMode === 'customers' ? 'Customers' : 'Stores'} ({groups.length})</span>
{viewMode === 'customers' && !locationid && locationsQ.data && ( {viewMode === 'customers' && !locationid && locationsQ.data && (
<div className="absolute right-0 flex items-center group/filter cursor-pointer"> <div className="absolute right-0 flex items-center group/filter cursor-pointer">
<select <select
@@ -408,30 +446,15 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
</div> </div>
)} )}
</div> </div>
{groups.map((g) => { {groups.map((g) => (
const isSelected = focusedId === g.id; <React.Fragment key={g.id}>
return ( {viewMode === 'customers' ? (
<React.Fragment key={g.id}> <CustomerCard g={g} onClick={() => setFocusedId(g.id)} isSelected={focusedId === g.id} />
{viewMode === 'customers' ? ( ) : (
<CustomerCard g={g} onClick={() => setFocusedId(g.id)} isSelected={isSelected} /> <StoreCard g={g} onClick={() => setFocusedId(g.id)} />
) : viewMode === 'riders' ? ( )}
<RiderCard </React.Fragment>
g={g} ))}
onClick={() => {
setFocusedId(g.id);
const rid = fnum(g.orders[0]?.userid);
if (rid) setFocusedRiderId(rid);
}}
/>
) : (
<ZoneCard g={g} onClick={() => {
setFocusedId(g.id);
setFocusedRiderId(null);
}} />
)}
</React.Fragment>
);
})}
</> </>
)} )}
</div> </div>
@@ -480,7 +503,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
</div> </div>
) : !focused ? ( ) : !focused ? (
<div className="dmp-overlay-note"> <div className="dmp-overlay-note">
<MapIcon size={13} /> Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : viewMode === 'riders' ? 'rider' : 'group'} to draw its route. <MapIcon size={13} /> Select a store to draw its route.
</div> </div>
) : null} ) : null}
@@ -511,88 +534,70 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
); );
} }
// ── Rider card ─────────────────────────────────────────────────────────────────── // ── Store card ───────────────────────────────────────────────────────────────────
function RiderCard({ g, onClick }: { g: Group; onClick: () => void }) { // One per outlet. Admin sees every outlet under the tenant, a store user sees
const total = g.orders.length; // only its own. Outlets with no deliveries today are deliberately still listed —
const percent = total ? Math.round((g.delivered / total) * 100) : 0; // "which of my stores is idle" is the question the admin list has to answer — so
const isDone = total > 0 && g.delivered === total; // they render a quiet idle state instead of a row of zeroes.
const zoneName = [...g.suburbs.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || 'Mixed'; function StoreCard({ g, onClick }: { g: Group; onClick: () => void }) {
const trips = new Set(g.orders.map((o) => fstr(o.trip_number) || '1')).size;
return (
<div className="rcard" onClick={onClick}>
<div className="rcard-top">
<div className="rcard-emo" style={{ background: `${g.color}18`, color: g.color }}>
<Bike size={18} />
</div>
<div className="rcard-info">
<div className="rcard-name">{g.name}</div>
<div className="rcard-zone">{zoneName} · {trips} trip{trips > 1 ? 's' : ''}</div>
</div>
<div className={`rcard-badge ${isDone ? 'is-done' : ''}`}>{g.delivered}/{total}</div>
</div>
<div className="bar-bg">
<div className="bar-fg" style={{ width: `${percent}%`, background: g.color }} />
</div>
<div className="rcard-meta">
<span><Ruler size={11} /> {g.totalKm.toFixed(1)} km</span>
{g.profit > 0 && <span><Wallet size={11} /> {g.profit.toLocaleString('en-IN')}</span>}
</div>
<div className="step-ids">
{g.orders.slice(0, 16).map((o, i) => (
<span key={fstr(o.orderid) || i} className="step-id">S{fnum(o.step) || i + 1}</span>
))}
</div>
</div>
);
}
// ── Zone card (also used for By Location / All Routes) ───────────────────────────
function ZoneCard({ g, onClick }: { g: Group; onClick: () => void }) {
const suburbs = [...g.suburbs.entries()].sort((a, b) => b[1] - a[1]).map(([s]) => s); const suburbs = [...g.suburbs.entries()].sort((a, b) => b[1] - a[1]).map(([s]) => s);
const idle = g.orders.length === 0;
return ( return (
<div className="rcard zone-card" onClick={onClick}> <div className={`rcard zone-card${idle ? ' is-idle' : ''}`} onClick={onClick}>
<div className="zone-card-header"> <div className="zone-card-header">
<div className="zone-card-emoji" style={{ color: g.color }}><MapIcon size={16} /></div> <div className="zone-card-emoji" style={{ color: idle ? '#94a3b8' : g.color }}><Store size={16} /></div>
<div className="zone-card-titles"> <div className="zone-card-titles">
<div className="zone-card-name">{g.name}</div> <div className="zone-card-name">{g.name}</div>
<div className="zone-card-sub">{g.riders.size} rider{g.riders.size === 1 ? '' : 's'} · {g.orders.length} orders</div> <div className="zone-card-sub">
{idle
? 'No deliveries today'
: `${g.riders.size} rider${g.riders.size === 1 ? '' : 's'} · ${g.orders.length} ${g.orders.length === 1 ? 'delivery' : 'deliveries'}`}
</div>
</div> </div>
<span className="zone-card-arrow" aria-hidden="true"></span> <span className="zone-card-arrow" aria-hidden="true"></span>
</div> </div>
{g.orders.length > 0 && ( {!idle && (
<div className="zone-progress-row"> <>
<div className="zone-status-bar"> <div className="zone-progress-row">
{Object.entries(g.statusCounts).map(([s, c]) => ( <div className="zone-status-bar">
<div key={s} className="zone-status-seg" style={{ flex: c, background: STATUS_HEX[s] || '#cbd5e1' }} title={`${s}: ${c}`} /> {Object.entries(g.statusCounts).map(([s, c]) => (
))} <div key={s} className="zone-status-seg" style={{ flex: c, background: STATUS_HEX[s] || '#cbd5e1' }} title={`${s}: ${c}`} />
))}
</div>
<div className="zone-progress-label">{g.delivered}/{g.orders.length}</div>
</div> </div>
<div className="zone-progress-label">{g.delivered}/{g.orders.length}</div> <div className="zone-stat-pills">
</div> {/* Only render a stat the feed can actually populate — the areas and
)} profit pills used to sit at a permanent 0 for every store. */}
<div className="zone-stat-pills"> {g.suburbs.size > 0 && (
<span className="zone-stat-pill"> <span className="zone-stat-pill">
<span className="zone-stat-icon"><MapPin size={12} /></span> <span className="zone-stat-icon"><MapPin size={12} /></span>
<span className="zone-stat-value">{g.suburbs.size}</span> <span className="zone-stat-value">{g.suburbs.size}</span>
<span className="zone-stat-label">areas</span> <span className="zone-stat-label">{g.suburbs.size === 1 ? 'area' : 'areas'}</span>
</span> </span>
<span className="zone-stat-pill"> )}
<span className="zone-stat-icon"><Ruler size={12} /></span> {g.totalKm > 0 && (
<span className="zone-stat-value">{g.totalKm.toFixed(0)}</span> <span className="zone-stat-pill">
<span className="zone-stat-label">km</span> <span className="zone-stat-icon"><Ruler size={12} /></span>
</span> <span className="zone-stat-value">{g.totalKm.toFixed(0)}</span>
{g.profit > 0 && ( <span className="zone-stat-label">km</span>
<span className="zone-stat-pill"> </span>
<span className="zone-stat-icon"><Wallet size={12} /></span> )}
<span className="zone-stat-value">{g.profit.toLocaleString('en-IN')}</span> {g.profit > 0 && (
<span className="zone-stat-label">profit</span> <span className="zone-stat-pill">
</span> <span className="zone-stat-icon"><Wallet size={12} /></span>
)} <span className="zone-stat-value">{g.profit.toLocaleString('en-IN')}</span>
</div> <span className="zone-stat-label">profit</span>
{suburbs.length > 0 && ( </span>
<div className="zone-card-suburbs"> )}
<span className="zone-card-suburbs-text">{suburbs.slice(0, 3).join(' · ')}</span> </div>
{suburbs.length > 3 && <span className="zone-card-suburbs-more">+{suburbs.length - 3}</span>} {suburbs.length > 0 && (
</div> <div className="zone-card-suburbs">
<span className="zone-card-suburbs-text">{suburbs.slice(0, 3).join(' · ')}</span>
{suburbs.length > 3 && <span className="zone-card-suburbs-more">+{suburbs.length - 3}</span>}
</div>
)}
</>
)} )}
</div> </div>
); );
@@ -602,23 +607,17 @@ function ZoneCard({ g, onClick }: { g: Group; onClick: () => void }) {
function FocusedDetail({ function FocusedDetail({
focused, focused,
tripBlocks, tripBlocks,
groupedByRider,
tripSort, tripSort,
setTripSort, setTripSort,
onBack, onBack,
fmtTime, fmtTime,
riderLogs,
riderLogsLoading,
}: { }: {
focused: Group; focused: Group;
tripBlocks: Array<{ label: string; color: string; orders: Row[] }>; tripBlocks: Array<{ label: string; color: string; orders: Row[] }>;
groupedByRider: boolean;
tripSort: 'planned' | 'time'; tripSort: 'planned' | 'time';
setTripSort: (v: 'planned' | 'time') => void; setTripSort: (v: 'planned' | 'time') => void;
onBack?: () => void; onBack?: () => void;
fmtTime: (raw: unknown) => string; fmtTime: (raw: unknown) => string;
riderLogs?: Row[];
riderLogsLoading?: boolean;
}) { }) {
return ( return (
<> <>
@@ -628,21 +627,13 @@ function FocusedDetail({
</button> </button>
)} )}
{riderLogs && riderLogs.length > 0 && (
<RiderTelemetryPanel
logs={riderLogs}
riderName={focused.name}
isLoading={riderLogsLoading}
/>
)}
{tripBlocks.length === 0 && ( {tripBlocks.length === 0 && (
<div className="flex flex-col items-center justify-center p-8 text-center h-48 border-2 border-dashed border-slate-200 rounded-xl mt-4 bg-slate-50/50"> <div className="flex flex-col items-center justify-center p-8 text-center h-48 border-2 border-dashed border-slate-200 rounded-xl mt-4 bg-slate-50/50">
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center text-slate-400 mb-3"> <div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center text-slate-400 mb-3">
<Package size={20} /> <Package size={20} />
</div> </div>
<p className="font-bold text-slate-700 text-sm">No orders to display</p> <p className="font-bold text-slate-700 text-sm">No deliveries for {focused.name}</p>
<p className="text-xs text-slate-500 mt-1">There are no deliveries matching this selection for the current date.</p> <p className="text-xs text-slate-500 mt-1">Nothing has been dispatched from this store on the selected date.</p>
</div> </div>
)} )}
@@ -679,7 +670,7 @@ function FocusedDetail({
<div className="zone-order-num" style={{ background: `${blk.color}15`, color: blk.color }}>{step}</div> <div className="zone-order-num" style={{ background: `${blk.color}15`, color: blk.color }}>{step}</div>
<div className="zone-order-id-block"> <div className="zone-order-id-block">
<div className="zone-order-id">Order #{fstr(o.orderid) || fstr(o.deliveryid)}</div> <div className="zone-order-id">Order #{fstr(o.orderid) || fstr(o.deliveryid)}</div>
{groupedByRider && fstr(o.ridername) && ( {fstr(o.ridername) && (
<div className="zone-order-rider"><Bike size={10} /> {fstr(o.ridername)}</div> <div className="zone-order-rider"><Bike size={10} /> {fstr(o.ridername)}</div>
)} )}
</div> </div>
@@ -693,12 +684,12 @@ function FocusedDetail({
</div> </div>
</div> </div>
<div className="zone-order-customer"><Mailbox size={11} /> {fstr(o.deliverycustomer) || 'Customer'}</div> <div className="zone-order-customer"><Mailbox size={11} /> {customerName(o) || 'Customer'}</div>
{fstr(o.pickupcustomer) && ( {fstr(o.pickupcustomer) && (
<div className="zone-order-line"><Utensils size={11} /> {fstr(o.pickupcustomer)}</div> <div className="zone-order-line"><Utensils size={11} /> {fstr(o.pickupcustomer)}</div>
)} )}
{(fstr(o.deliverysuburb) || fstr(o.deliveryaddress)) && ( {fstr(o.deliveryaddress) && (
<div className="zone-order-line"><MapPin size={11} /> {fstr(o.deliverysuburb) || fstr(o.deliveryaddress)}</div> <div className="zone-order-line"><MapPin size={11} /> {fstr(o.deliveryaddress)}</div>
)} )}
{fstr(o.ordernotes) && ( {fstr(o.ordernotes) && (
<div className="zone-order-line zone-order-notes"><StickyNote size={11} /> {fstr(o.ordernotes)}</div> <div className="zone-order-line zone-order-notes"><StickyNote size={11} /> {fstr(o.ordernotes)}</div>
@@ -727,8 +718,12 @@ function FocusedDetail({
// ── Customer card ─────────────────────────────────────────────────────────────────── // ── Customer card ───────────────────────────────────────────────────────────────────
function CustomerCard({ g, onClick, isSelected }: { g: Group; onClick: () => void; isSelected?: boolean }) { function CustomerCard({ g, onClick, isSelected }: { g: Group; onClick: () => void; isSelected?: boolean }) {
const customer = g.raw; const customer = g.raw;
const name = customer ? fstr(customer.customername) || fstr(customer.name) : g.name; // g.name is already resolved through customerName(); reading `customername` off
// the raw row (a column gettenantcustomers doesn't return) made every card in
// the list read "Unknown Customer".
const name = g.name;
const phone = customer ? fstr(customer.contactno) || fstr(customer.phone) : ''; const phone = customer ? fstr(customer.contactno) || fstr(customer.phone) : '';
const deliveries = g.orders.length;
return ( return (
<div <div
@@ -757,6 +752,20 @@ function CustomerCard({ g, onClick, isSelected }: { g: Group; onClick: () => voi
<Phone size={10} /> {phone} <Phone size={10} /> {phone}
</div> </div>
)} )}
{(g.storeName || deliveries > 0) && (
<div className="mt-1.5 flex items-center gap-1.5 flex-wrap">
{g.storeName && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded">
<Store size={9} /> {g.storeName}
</span>
)}
{deliveries > 0 && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
<Truck size={9} /> {deliveries} today
</span>
)}
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -88,11 +88,27 @@ export default function Header({
/> />
</span> </span>
{/* Sidebar toggle (Burger Menu) */} {/* Sidebar toggle (Burger Menu).
ml-auto when open pins it to the trailing edge of the brand cell,
and since that cell's width tracks the sidebar exactly (256px open
/ 64px collapsed) the button lands on the sidebar's own edge — the
seam between the panel and the header. Left to sit inline after
the wordmark it floated in the middle of the open panel instead,
and pushed outside the cell it drifted past the seam by the flex
gap. Anchoring to the cell needs no pixel offsets and holds if
either width changes.
Collapsed the cell is only 64px and centred, so the natural
inline position already sits beside the icon logo, which is itself
aligned over the sidebar's icon rail. */}
<button <button
onClick={onToggleSidebar} onClick={onToggleSidebar}
title="Toggle sidebar" title={isSidebarOpen ? 'Close sidebar' : 'Open sidebar'}
className={`rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white ${isSidebarOpen ? 'p-1.5 sm:p-2' : 'p-1.5'}`} aria-label={isSidebarOpen ? 'Close sidebar' : 'Open sidebar'}
aria-expanded={isSidebarOpen}
className={`rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white shrink-0 ${
isSidebarOpen ? 'md:ml-auto p-1.5 sm:p-2' : 'p-1.5'
}`}
> >
<Menu size={18} /> <Menu size={18} />
</button> </button>

View File

@@ -49,12 +49,13 @@ import {
} from '../services/fiestaQueries'; } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr } from '../services/fiestaApi'; import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr } from '../services/fiestaApi';
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers'; import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
import { useStoreCatalogue } from '../services/storeCatalogue'; import { useStoreCatalogue, isPublishedItem } from '../services/storeCatalogue';
import BulkCartDrawer from './BulkCartDrawer'; import BulkCartDrawer from './BulkCartDrawer';
import AwaitingApi from './AwaitingApi'; 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) ───────────────────────────────
@@ -169,13 +171,17 @@ export default function InventoryView({
stockStatus: 'Healthy', stockStatus: 'Healthy',
trend: 'flat', trend: 'flat',
exposure: 'All Outlets', exposure: 'All Outlets',
verified: item.status === 'Active', // Active means it's fully published with price // "Fully published with price" — tested on the price itself, not on
// status. Status is not a publish flag: the backend overwrites our
// 'Active' with an availability value, which used to flip published
// products back to unverified. See storeCatalogue.isPublishedItem.
verified: item.price > 0,
}); });
} else { } else {
// If it exists but we have curated price in storeCat, we could apply it here if needed // If it exists but we have curated price in storeCat, we could apply it here if needed
const existing = byId.get(id)!; const existing = byId.get(id)!;
existing.price = item.price || existing.price; existing.price = item.price || existing.price;
existing.verified = existing.verified || item.status === 'Active'; existing.verified = existing.verified || item.price > 0;
} }
}); });
@@ -524,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"
@@ -557,7 +570,10 @@ export default function InventoryView({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 ${!isLocalSidebarOpen ? (!isSidebarOpen ? 'xl:grid-cols-5 2xl:grid-cols-6' : 'xl:grid-cols-4 2xl:grid-cols-5') : (!isSidebarOpen ? 'xl:grid-cols-4 2xl:grid-cols-5' : 'xl:grid-cols-3 2xl:grid-cols-4')} gap-4`}> <div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 ${!isLocalSidebarOpen ? (!isSidebarOpen ? 'xl:grid-cols-5 2xl:grid-cols-6' : 'xl:grid-cols-4 2xl:grid-cols-5') : (!isSidebarOpen ? 'xl:grid-cols-4 2xl:grid-cols-5' : 'xl:grid-cols-3 2xl:grid-cols-4')} gap-4`}>
{filteredProducts.map((prod) => { {filteredProducts.map((prod) => {
const isPublished = storeCat.has(prod.id) && storeCat.items.find(i => i.productid === prod.id)?.status === 'Active'; // Presence in the store catalogue IS publication — the row
// stays until the admin removes it. Don't gate on status;
// the backend rewrites it to 'available'/'outofstock'.
const isPublished = isPublishedItem(storeCat.items.find(i => i.productid === prod.id));
return ( return (
<div key={prod.id} onClick={() => setSelectedAdminProduct(prod)} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-none flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden cursor-pointer"> <div key={prod.id} onClick={() => setSelectedAdminProduct(prod)} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-none flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden cursor-pointer">
@@ -1091,6 +1107,18 @@ export default function InventoryView({
})()} })()}
</SlideDrawer> </SlideDrawer>
{/* Offline (counter) sales import. No locationId is passed: the admin gets
one workbook covering every branch, and each row's own locationid
routes its sale to the right store. This view operates on the tenant's
first outlet elsewhere, which would have been the wrong store to
credit for most of these sales. */}
{showOfflineSales && (
<OfflineSalesUpload
tenantId={tenantId}
onClose={() => setShowOfflineSales(false)}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,625 @@
/**
* @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 how that stock gets deducted: download a spreadsheet
* pre-filled with the 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.
*
* ONE file covers EVERY branch. There is no store picker: each row of the sheet
* carries its own tenantid and locationid, and that row's locationid decides
* which branch the sale comes out of. A merchant with six outlets fills in rows
* for all six and uploads once. Nothing here has to know or choose a store.
*
* 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 and locationid in so nobody has to know them.
*
* Used by both surfaces. The admin console passes no locationId, so the file
* routes itself. The store user's page passes theirs, which pins the upload to
* their branch and rejects rows for any other — enforced again server-side.
*/
import { useCallback, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
CheckCircle2,
Download,
FileSpreadsheet,
Loader2,
RotateCcw,
Store,
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;
/**
* Pins the upload to a single branch. Passed by the store user's page so they
* can only ever import for their own store. Omitted by the admin console, so
* the workbook spans every branch and each row routes itself.
*/
locationId?: number;
/** Shown in the header when the upload is pinned to one store. */
storeName?: string;
userId?: number;
onClose: () => void;
}
const money = (n: number) =>
`${n.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
export default function OfflineSalesUpload({
tenantId,
locationId,
storeName,
userId,
onClose,
}: OfflineSalesUploadProps) {
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
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 pinned = (locationId ?? 0) > 0;
const templateQuery = useQuery({
queryKey: ['saleTemplate', tenantId, locationId ?? 0],
queryFn: () => getSaleTemplate({ tenantid: tenantId, locationid: locationId ?? 0 }),
enabled: tenantId > 0,
// Always refetched on open: a template is only useful if its stock figures
// and product list match the outlets right now.
staleTime: 0,
});
const template = templateQuery.data;
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,
// Sent only when pinned. Left at 0, the backend routes each bill by the
// locationid the sheet gave it.
locationid: locationId ?? 0,
userid: userId,
bills: toBills(parsed.rows),
});
},
onSuccess: (res) => {
setResult(res);
setUploadError('');
// Stock has moved at potentially several branches, 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: locationId,
allowedLocationIds: template?.locations.map((l) => l.locationid),
}),
);
} catch {
setParsed({
tenantid: null,
rows: [],
skipped: 0,
fatal: ['That file could not be read. Upload the .xlsx template you downloaded.'],
});
}
},
[tenantId, locationId, template],
);
const reset = () => {
setParsed(null);
setFileName('');
setResult(null);
setUploadError('');
if (fileInputRef.current) fileInputRef.current.value = '';
};
const storeCount = template?.locations.length ?? 0;
const scopeLabel = pinned
? storeName || template?.locations[0]?.locationname || `Outlet ${locationId}`
: storeCount === 1
? template?.locations[0]?.locationname || 'your store'
: `all ${storeCount} stores`;
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">{scopeLabel}</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} />
) : (
<>
{/* 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
</h3>
<p className="mt-1.5 text-xs leading-relaxed text-slate-600">
{templateQuery.isLoading
? 'Loading your catalogue…'
: templateQuery.isError
? 'Could not load your catalogue.'
: pinned || storeCount <= 1
? `${template?.products.length ?? 0} products stocked. Fill in the qtysold column and upload the file back.`
: `${template?.products.length ?? 0} rows covering all ${storeCount} stores. Every row already says which store it belongs to — fill in qtysold wherever you sold something and upload the one file.`}
</p>
</div>
<button
onClick={() => template && downloadSaleTemplate(template)}
disabled={!template || 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>
{/* What the one file covers. Shown so it is obvious up front
that no store has to be chosen anywhere. */}
{!pinned && storeCount > 1 && (
<div className="mt-4 flex flex-wrap gap-2 border-t border-slate-200 pt-3">
{template?.locations.map((l) => (
<span
key={l.locationid}
className="flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] font-medium text-slate-700"
>
<Store size={11} className="text-[#662582]" />
{l.locationname}
<span className="text-slate-400">#{l.locationid}</span>
<span className="text-slate-400">· {l.productcount}</span>
</span>
))}
</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-6">
<Stat label="Stores" value={String(summary.stores)} />
<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>
{/* Per-store totals. With one file covering the whole
business, the single figure above is not enough to
sanity-check what is about to be deducted where. */}
{summary.stores > 1 && (
<div className="mb-4 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">Store</th>
<th className="px-3 py-2 text-right font-semibold">Lines</th>
<th className="px-3 py-2 text-right font-semibold">Units</th>
<th className="px-3 py-2 text-right font-semibold">Amount</th>
<th className="px-3 py-2 text-right font-semibold">Problems</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{summary.byStore.map((s) => (
<tr key={s.locationid} className={s.errors > 0 ? 'bg-red-50' : 'bg-white'}>
<td className="px-3 py-2 font-medium text-slate-800">
{s.locationname || `Outlet ${s.locationid}`}
<span className="ml-1.5 font-mono text-[10px] text-slate-400">#{s.locationid}</span>
</td>
<td className="px-3 py-2 text-right text-slate-600">{s.lines}</td>
<td className="px-3 py-2 text-right text-slate-600">{s.units}</td>
<td className="px-3 py-2 text-right font-semibold text-slate-800">{money(s.amount)}</td>
<td
className={`px-3 py-2 text-right font-semibold ${
s.errors > 0 ? 'text-red-700' : 'text-emerald-700'
}`}
>
{s.errors}
</td>
</tr>
))}
</tbody>
</table>
</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-[920px] 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">Store</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 text-slate-700">
{r.locationname || `#${r.locationid}`}
</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">
Each sale is deducted from the store named on its own row, and appears 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">Store</th>
<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.locationid}-${r.billno}-${i}`}
className={
r.status === 'imported' ? 'bg-white' : r.status === 'duplicate' ? 'bg-slate-50' : 'bg-red-50'
}
>
<td className="px-3 py-2 text-slate-700">{r.locationname || (r.locationid ? `#${r.locationid}` : '—')}</td>
<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>
);
}

View File

@@ -14,8 +14,11 @@
import React, { useMemo, useState, useRef, useEffect } from 'react'; import React, { useMemo, useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Loader2, Download, UserCheck, ClipboardList, ArrowLeft } from 'lucide-react'; import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Loader2, Download, UserCheck, ClipboardList, ArrowLeft } from 'lucide-react';
import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails, useFiestaRiders, useFiestaAssignRider, useFiestaUsers } from '../services/fiestaQueries'; import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails, useFiestaRiders, useFiestaAssignRider, useFiestaNotifyRider } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi'; import {
FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, RIDER_MESSAGES, RiderNotReachableError,
num as fnum, str as fstr, ymd, type Row,
} from '../services/fiestaApi';
import { shortTime } from '../services/fiestaMappers'; import { shortTime } from '../services/fiestaMappers';
import { import {
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE, GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE,
@@ -27,6 +30,12 @@ interface OrdersViewProps {
locationid?: number; locationid?: number;
/** Merchant tenant to scope to; defaults to the shared constant. */ /** Merchant tenant to scope to; defaults to the shared constant. */
tenantId?: number; tenantId?: number;
/**
* App-location to source assignable riders from. Riders are scoped by
* app-location rather than tenant, so without one the rider list falls back
* to whatever the visible orders name. Defaults to the platform constant.
*/
applocationid?: number;
date?: string; date?: string;
} }
@@ -40,7 +49,13 @@ const STATUS_TABS: Array<{ key: StatusKey; label: string }> = [
]; ];
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
export default function OrdersView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID, date }: OrdersViewProps) { export default function OrdersView({
searchQuery = '',
locationid,
tenantId = FIESTA_TENANT_ID,
applocationid = FIESTA_APPLOCATION_ID,
date,
}: OrdersViewProps) {
const today = new Date(); const today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const [fromdate, setFromdate] = useState<string>(date || ymd(today)); const [fromdate, setFromdate] = useState<string>(date || ymd(today));
@@ -81,6 +96,7 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
const [assignMsg, setAssignMsg] = useState(''); const [assignMsg, setAssignMsg] = useState('');
const [showSelected, setShowSelected] = useState(false); // full-page review of selection const [showSelected, setShowSelected] = useState(false); // full-page review of selection
const assignMut = useFiestaAssignRider(); const assignMut = useFiestaAssignRider();
const notifyMut = useFiestaNotifyRider();
// Ctrl/Cmd+K focuses search; Escape blurs it (parity with the ops console). // Ctrl/Cmd+K focuses search; Escape blurs it (parity with the ops console).
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
@@ -105,7 +121,10 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
setShowSelected(false); setShowSelected(false);
}, [fromdate, todate, status, branch, pageno, locationid]); }, [fromdate, todate, status, branch, pageno, locationid]);
const [riderSource, setRiderSource] = useState<'own' | 'partner'>('own'); // 'all' lists every rider on duty at this app-location; 'partner' narrows to
// the partner already carrying the selected orders. Defaults to 'all' — the
// old default filtered to riders with no partnerid, which no on-duty rider has.
const [riderSource, setRiderSource] = useState<'all' | 'partner'>('all');
// Scope to the user's store when a locationid is supplied (server-side per the // Scope to the user's store when a locationid is supplied (server-side per the
// backend's getordersummary/getorders locationid param); tenant-wide otherwise. // backend's getordersummary/getorders locationid param); tenant-wide otherwise.
@@ -114,51 +133,54 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
const summary = summaryQ.data; const summary = summaryQ.data;
const rawRows = ordersQ.data ?? []; const rawRows = ordersQ.data ?? [];
// Riders must share the orders' tenant + partner to be assignable (the backend // Riders are scoped by app-location, NOT by tenant. A rider record carries a
// rejects cross-tenant/partner riders), so derive the partner/app-location from // partnerid and an applocationid but leaves app_users.tenantid unset, so
// the live order rows and scope the rider list to them. An out-of-tenant rider // /partners/getriders?tenantid=… returns an empty list for every tenant
// simply won't appear — the intended guard. // which is what the assign dropdown used to show. The app-location is taken
// from the live order rows, falling back to the signed-in user's own.
const orderPartnerId = useMemo(() => fnum(rawRows.find((r) => fnum(r.partnerid))?.partnerid), [rawRows]); const orderPartnerId = useMemo(() => fnum(rawRows.find((r) => fnum(r.partnerid))?.partnerid), [rawRows]);
const orderApplocationId = useMemo(() => fnum(rawRows.find((r) => fnum(r.applocationid))?.applocationid), [rawRows]); const orderApplocationId = useMemo(() => fnum(rawRows.find((r) => fnum(r.applocationid))?.applocationid), [rawRows]);
const riderApplocationId = orderApplocationId || applocationid || 0;
// /partners/getriders is already a live-presence query, not a roster: the
// backend filters on status='Active', onduty=1 and a riderlog dated today
// with logstatus=0, joined to each rider's most recent GPS ping. So this
// returns riders who are on shift and logged in right now, and it carries the
// userfcmtoken needed to notify them.
const ridersQ = useFiestaRiders({ const ridersQ = useFiestaRiders({
tenantid: tenantId, applocationid: riderApplocationId || undefined,
applocationid: orderApplocationId || undefined,
// We omit partnerid here to fetch all partner riders for the location at once.
});
const internalRidersQ = useFiestaUsers({
tenantid: tenantId,
roleid: 5, // 5 = Rider role
pagesize: 500
}); });
// The previous build also merged in getallusers?roleid=5 as an "own fleet".
// There is no rider role: app_roles only defines 1-6 as Super admin /
// Operations / Admin / Manager per configid, and riders are identified by
// configid=6 inside getriders. roleid=5 matched a single user with two
// deliveries in the platform's history, while the 29 users who actually drive
// the bulk of deliveries carry roleid 0. Every on-duty rider also has
// partnerid > 0, so the "own fleet" tab — the default — filtered that list
// down to nothing and the dropdown was empty on open.
const riderOptions = useMemo( const riderOptions = useMemo(
() => { () =>
const externalRiders = ridersQ.data ?? []; (ridersQ.data ?? [])
const internalRiders = internalRidersQ.data ?? []; .filter((r) => {
const allRiders = [...externalRiders, ...internalRiders]; if (riderSource === 'all') return true;
// Restrict to the partner already carrying these orders, when the
const filtered = allRiders.filter((r) => { // rows name one; otherwise there is nothing to narrow to.
const pId = fnum(r.partnerid); const pId = fnum(r.partnerid);
if (riderSource === 'own') { return !orderPartnerId || pId === orderPartnerId;
// Store fleet riders are internal users (they have no partner id) })
return !pId || pId === 0;
} else {
// Partner riders belong to a 3rd party (partnerid > 0)
// If the order already has a specific partnerid, we only show riders from that partner.
return pId > 0 && (!orderPartnerId || pId === orderPartnerId);
}
});
return filtered
.map((r) => ({ .map((r) => ({
id: fnum(r.userid), id: fnum(r.userid),
label: `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() + (fstr(r.contactno) ? ` · ${fstr(r.contactno)}` : ''), label:
(fstr(r.fullname) || `${fstr(r.firstname)} ${fstr(r.lastname)}`).trim() +
(fstr(r.contactno) ? ` · ${fstr(r.contactno)}` : ''),
// Carried so the assign can notify the rider, and so a rider with no
// registered device can be called out rather than silently skipped.
token: fstr(r.userfcmtoken),
vehicle: fstr(r.vehiclename),
})) }))
.filter((o) => o.id > 0 && o.label); .filter((o) => o.id > 0 && o.label),
}, [ridersQ.data, riderSource, orderPartnerId],
[ridersQ.data, internalRidersQ.data, riderSource, orderPartnerId],
); );
// Branches (app-locations) present in the data — drives the branch filter so the // Branches (app-locations) present in the data — drives the branch filter so the
@@ -271,16 +293,42 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
const handleAssign = async () => { const handleAssign = async () => {
if (!assignRiderId || selected.size === 0) return; if (!assignRiderId || selected.size === 0) return;
const toAssign = rows.filter((r) => selected.has(rowKey(r))); const toAssign = rows.filter((r) => selected.has(rowKey(r)));
const rider = riderOptions.find((o) => o.id === assignRiderId)?.label ?? 'rider'; const option = riderOptions.find((o) => o.id === assignRiderId);
const rider = option?.label ?? 'rider';
try { try {
const res = await assignMut.mutateAsync({ userid: assignRiderId, orders: toAssign }); const res = await assignMut.mutateAsync({ userid: assignRiderId, orders: toAssign });
setAssignMsg( const assigned =
res.failed res.failed
? `Assigned ${res.ok}/${res.total} to ${rider} · ${res.failed} failed` ? `Assigned ${res.ok}/${res.total} to ${rider} · ${res.failed} failed`
: `Assigned ${res.ok} order${res.ok === 1 ? '' : 's'} to ${rider}`, : `Assigned ${res.ok} order${res.ok === 1 ? '' : 's'} to ${rider}`;
);
setSelected(new Set()); setSelected(new Set());
setShowSelected(false); // return to the board with the result shown in the bar setShowSelected(false); // return to the board with the result shown in the bar
// Notify only for work that actually landed. The push runs after the
// write and is reported separately: the deliveries exist either way, so a
// failed notification must not read as a failed assignment — but it must
// still be visible, because a rider who was never told has work sitting
// unseen.
if (res.ok === 0) {
setAssignMsg(assigned);
return;
}
setAssignMsg(`${assigned} · notifying…`);
try {
await notifyMut.mutateAsync({
token: option?.token ?? '',
body: RIDER_MESSAGES.assigned(res.ok),
});
setAssignMsg(`${assigned} · rider notified`);
} catch (err) {
setAssignMsg(
`${assigned} · NOT notified — ${
err instanceof RiderNotReachableError
? 'this rider has no device registered'
: 'the push failed, tell them another way'
}`,
);
}
} catch { } catch {
setAssignMsg('Assignment failed — please retry.'); setAssignMsg('Assignment failed — please retry.');
} }
@@ -371,18 +419,21 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
</span> </span>
<div className="flex bg-white rounded-full p-0.5 ml-2 border" style={{ borderColor: edge(BRAND) }}> <div className="flex bg-white rounded-full p-0.5 ml-2 border" style={{ borderColor: edge(BRAND) }}>
<button <button
onClick={() => setRiderSource('own')} onClick={() => setRiderSource('all')}
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors ${riderSource === 'own' ? '' : 'text-slate-500 hover:bg-slate-50'}`} title="Every rider on duty at this branch right now"
style={riderSource === 'own' ? { background: tint(BRAND), color: BRAND } : undefined} className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors ${riderSource === 'all' ? '' : 'text-slate-500 hover:bg-slate-50'}`}
style={riderSource === 'all' ? { background: tint(BRAND), color: BRAND } : undefined}
> >
Store Fleet On Duty
</button> </button>
<button <button
onClick={() => setRiderSource('partner')} onClick={() => setRiderSource('partner')}
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors ${riderSource === 'partner' ? '' : 'text-slate-500 hover:bg-slate-50'}`} disabled={!orderPartnerId}
title={orderPartnerId ? 'Only riders from the partner carrying these orders' : 'These orders name no partner'}
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors disabled:opacity-40 ${riderSource === 'partner' ? '' : 'text-slate-500 hover:bg-slate-50'}`}
style={riderSource === 'partner' ? { background: tint(BRAND), color: BRAND } : undefined} style={riderSource === 'partner' ? { background: tint(BRAND), color: BRAND } : undefined}
> >
Partners This Partner
</button> </button>
</div> </div>
<select <select
@@ -393,7 +444,15 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
className="rounded-full font-bold text-xs outline-none cursor-pointer disabled:opacity-50" className="rounded-full font-bold text-xs outline-none cursor-pointer disabled:opacity-50"
style={{ padding: '7px 12px', border: `1.5px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 260 }} style={{ padding: '7px 12px', border: `1.5px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 260 }}
> >
<option value={0}>{ridersQ.isLoading ? 'Loading riders…' : riderOptions.length ? 'Select rider…' : `No ${riderSource === 'own' ? 'store' : 'partner'} riders available`}</option> <option value={0}>
{ridersQ.isLoading
? 'Loading riders…'
: riderOptions.length
? 'Select rider…'
: riderSource === 'partner'
? 'No riders on duty for this partner'
: 'No riders on duty right now'}
</option>
{riderOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)} {riderOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select> </select>
<button <button

View File

@@ -13,20 +13,24 @@
* and chooses which products they need, each with their own quantity. * and chooses which products they need, each with their own quantity.
* 3. Those picks are the user's request for their store. * 3. Those picks are the user's request for their store.
* *
* The catalogue source is the shared store catalogue (localStorage bridge for now; * All three tabs render database rows — nothing here is mock or local-only:
* backend: GET /products/getlocationproducts). The user's picks persist per store * • Catalogue → GET /products/getlocationproducts (via services/storeCatalogue)
* and `commitSelectionToStore()` is the single backend integration point * • Inventory → GET /products/getstockstatement (the stock ledger)
* (POST /products/createproductlocation / a stock-request endpoint). * • Requests → GET /products/getstockrequests
* Requests are written with POST /products/createstockrequest and confirmed
* with PUT /products/updatestockrequest. Every call is scoped by the logged-in
* tenant + outlet; see `storeLocationId`.
*/ */
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, FIESTA_PRIMARY_LOCATION_ID } 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';
@@ -44,6 +48,25 @@ function stockStatus(closing: number): { label: string; color: string } {
return { label: 'Healthy', color: '#10b981' }; return { label: 'Healthy', color: '#10b981' };
} }
/**
* Has stock ever actually moved for this product at this outlet?
*
* getstockstatement returns a row for every product in the outlet's catalogue,
* so a product the admin published a minute ago comes back with all four ledger
* columns at 0. That is a catalogue entry, not inventory. A row only counts as
* inventory once any of opening/credit/debit/closing is non-zero — which also
* keeps a product that was stocked and has since sold down to 0, so it stays
* listed as "Out of stock" instead of disappearing.
*/
function hasStockHistory(r: Row): boolean {
return (
fnum(r.opening) !== 0 ||
fnum(r.credit) !== 0 ||
fnum(r.debit) !== 0 ||
fnum(r.closing) !== 0
);
}
/** Category → pill badge classes (mirrors the admin Global Catalogue card). */ /** Category → pill badge classes (mirrors the admin Global Catalogue card). */
function catBadgeClass(category: string): string { function catBadgeClass(category: string): string {
const c = String(category || '').toLowerCase(); const c = String(category || '').toLowerCase();
@@ -66,13 +89,26 @@ 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
// logged-in user is linked to. There is no safe default here: falling back to
// a hard-coded outlet made this page show — and write — another store's
// stock. 0 means "not resolved yet", and the queries stay parked until it is.
const storeLocationId = locationid ?? 0;
// The admin-curated catalogue (what the user is allowed to pick from). // The admin-curated catalogue (what the user is allowed to pick from).
const storeCat = useStoreCatalogue(tenantid, locationid ?? FIESTA_PRIMARY_LOCATION_ID); const storeCat = useStoreCatalogue(tenantid, storeLocationId);
// No status filter here. `status` is not a publish flag — we write 'Active'
// when the admin publishes, but the backend overwrites it with an
// availability value ('available' / 'outofstock') as soon as it recomputes.
// Filtering on 'Active' made a product show the moment it was added and then
// vanish from the store catalogue on the next refresh. useStoreCatalogue has
// already dropped genuinely removed rows, so everything left belongs here and
// stays until the admin deletes it.
const products = useMemo( const products = useMemo(
() => () =>
storeCat.items storeCat.items
.filter((it) => it.status === 'Active')
.map((it) => ({ .map((it) => ({
id: it.productid, id: it.productid,
name: it.name, name: it.name,
@@ -86,7 +122,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
[storeCat.items], [storeCat.items],
); );
const stockRequestsQ = useFiestaGetStockRequests({ tenantid, locationid: locationid ?? 0, pagesize: 500, date: requestDate }); const stockRequestsQ = useFiestaGetStockRequests({ tenantid, locationid: storeLocationId, pagesize: 500, date: requestDate });
const [picks, setPicks] = useState<Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected' | 'Cancelled' | 'Received'; requestedAt: string; resolvedAt?: string }>>({}); const [picks, setPicks] = useState<Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected' | 'Cancelled' | 'Received'; requestedAt: string; resolvedAt?: string }>>({});
useEffect(() => { useEffect(() => {
@@ -109,37 +145,37 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
const createRequestMutation = useFiestaCreateStockRequest(); const createRequestMutation = useFiestaCreateStockRequest();
const updateRequestMutation = useFiestaUpdateStockRequest(); const updateRequestMutation = useFiestaUpdateStockRequest();
const stockInMutation = useFiestaCreateProductLocation();
// User confirms an approved request actually arrived — this is the only /**
// point a request turns into real, live stock. It first writes the stock-in * User confirms an approved request actually arrived.
// movement, then marks the request "Received" once that succeeds. *
// * This ONLY flips the request to "Received". The backend credits the stock
// createproductlocation sets the product's quantity rather than incrementing * itself off that transition — do not also post a stock movement here.
// it (same assumption the publish-to-catalogue flow relies on), so this must *
// send the new total on hand, not just the newly-received amount, or a * Previously this fired createproductlocation with `stocktype: 'in'` first
// receive here would wipe out whatever was already in stock. * and marked the request received afterwards, which credited the same goods
* twice. Measured on outlet 1185: 20 units received, ledger `credit` 40; 25
* units received, ledger `credit` 50 — exactly double, every time. The
* inventory list then added the request qty a third time on top, which is
* how 10 requested became 30 shown.
*/
const handleMarkReceived = (data: any) => { const handleMarkReceived = (data: any) => {
const existingQty = storeCat.items.find(i => i.productid === String(data.productid))?.qty ?? 0; // Guard against a double submit re-applying the same receipt.
stockInMutation.mutate( if (picks[String(data.productid)]?.status === 'Received') return;
if (updateRequestMutation.isPending) return;
if (!storeLocationId) {
alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be received.');
return;
}
updateRequestMutation.mutate(
{ {
tenantid, tenantid,
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID, locationid: storeLocationId,
productid: Number(data.productid), productid: Number(data.productid),
quantity: existingQty + (Number(data.qty) || 0), requestid: data.requestid,
stocktype: 'in', status: 'Received',
status: 'Active',
}, },
{ {
onSuccess: () => {
updateRequestMutation.mutate({
tenantid,
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID,
productid: Number(data.productid),
requestid: data.requestid,
status: 'Received',
});
},
onError: (err: any) => { onError: (err: any) => {
alert(err.message || 'Failed to record received stock.'); alert(err.message || 'Failed to record received stock.');
}, },
@@ -153,10 +189,14 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
if (existing != null && existing.status !== 'Cancelled') { if (existing != null && existing.status !== 'Cancelled') {
setPicks(prev => ({ ...prev, [id]: { ...prev[id], status: 'Cancelled', resolvedAt: new Date().toISOString() } })); setPicks(prev => ({ ...prev, [id]: { ...prev[id], status: 'Cancelled', resolvedAt: new Date().toISOString() } }));
} else { } else {
if (!storeLocationId) {
alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.');
return;
}
setPicks(prev => ({ ...prev, [id]: { qty: 1, status: 'Pending', requestedAt: new Date().toISOString() } })); setPicks(prev => ({ ...prev, [id]: { qty: 1, status: 'Pending', requestedAt: new Date().toISOString() } }));
createRequestMutation.mutate({ createRequestMutation.mutate({
tenantid, tenantid,
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID, locationid: storeLocationId,
productid: Number(id), productid: Number(id),
qty: 1, qty: 1,
status: 'Pending', status: 'Pending',
@@ -167,6 +207,10 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
const setPickQty = (id: string, qty: number) => { const setPickQty = (id: string, qty: number) => {
const safeQty = Math.max(1, Math.round(qty) || 1); const safeQty = Math.max(1, Math.round(qty) || 1);
if (!storeLocationId) {
alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.');
return;
}
setPicks((prev) => { setPicks((prev) => {
const existing = prev[id] || {}; const existing = prev[id] || {};
return { return {
@@ -181,7 +225,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
}); });
createRequestMutation.mutate({ createRequestMutation.mutate({
tenantid, tenantid,
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID, locationid: storeLocationId,
productid: Number(id), productid: Number(id),
qty: safeQty, qty: safeQty,
status: 'Pending', status: 'Pending',
@@ -191,68 +235,57 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
const pickCount = Object.keys(picks).length; const pickCount = Object.keys(picks).length;
// Store inventory (live stock) for the "My Store Inventory" tab + "In Store" tags. // Store inventory (live stock) for the "My Store Inventory" tab + "In Store" tags.
const stockQ = useFiestaStockStatement({ tenantid, locationid: locationid ?? 0, pagesize: 200 }); const stockQ = useFiestaStockStatement({ tenantid, locationid: storeLocationId, pagesize: 200 });
const inStore = useMemo(() => { /**
const set = new Set((stockQ.data ?? []).map((r) => fstr(r.productid))); * The stock statement is the ONE source of truth for quantity on hand.
// Only "Received" requests represent real, confirmed stock — "Approved" *
// just means the admin signed off, not that it has physically arrived. * Received stock deliberately is not added on top of it any more. The backend
Object.entries(picks).forEach(([pid, data]: [string, any]) => { * credits the ledger when a request flips to "Received", so anything this
if (data.status === 'Received') set.add(pid); * page adds is a second count of the same goods — measured on outlet 1185,
}); * 20 units received read back as `credit: 40, closing: 40`, and the old
return set; * overlay pushed the card to 60.
}, [stockQ.data, picks]); */
const inStore = useMemo(
() => new Set((stockQ.data ?? []).filter(hasStockHistory).map((r) => fstr(r.productid))),
[stockQ.data],
);
const inventory = useMemo( const inventory = useMemo(
() => { () => {
const baseInventory = (stockQ.data ?? []).map((r: Row) => { // The stock statement lists every product in the outlet's catalogue,
// including ones the admin only just published and that have never been
// stocked (all four ledger columns 0). Those don't belong in inventory —
// a product earns its place here once stock has actually moved for it,
// and from then on it stays, showing "Out of stock" when it hits 0.
const baseInventory = (stockQ.data ?? []).filter(hasStockHistory).map((r: Row) => {
const closing = fnum(r.closing) ?? 0; const closing = fnum(r.closing) ?? 0;
const productid = fstr(r.productid);
// The stock statement carries productname/productunit/retailprice but no
// sku or image, so fill those from the catalogue row for the same
// product. The price used to be `Math.floor(Math.random() * 50) + 10` —
// a fabricated number the product drawer rendered as real, changing on
// every render.
const catItem = products.find((p) => p.id === productid);
return { return {
id: fstr(r.productid), id: productid,
name: fstr(r.productname) || 'Unnamed product', name: fstr(r.productname) || catItem?.name || 'Unnamed product',
sku: fstr(r.sku) || `SKU-${fstr(r.productid)}`, sku: catItem?.sku || `SKU-${productid}`,
image: fstr(r.productimage) || PLACEHOLDER, image: fstr(r.productimage) || catItem?.image || PLACEHOLDER,
category: fstr(r.categoryname) || 'General', category: fstr(r.categoryname) || catItem?.category || 'General',
unit: fstr(r.productunit) || catItem?.unit || '',
closing, closing,
...stockStatus(closing), ...stockStatus(closing),
price: Math.floor(Math.random() * 50) + 10, // mock price // Per-store price first (the catalogue row carries productlocations
// .price); the stock statement only knows the master retailprice.
price: catItem?.price || fnum(r.retailprice) || 0,
qty: closing, // fallback if actual qty not mapped qty: closing, // fallback if actual qty not mapped
}; };
}); });
// Merge received picks into the inventory — stock only actually lands return baseInventory;
// once the user confirms receipt, not merely once the admin approves.
const inventoryMap = new Map(baseInventory.map(item => [item.id, item]));
Object.entries(picks).forEach(([pid, data]: [string, any]) => {
if (data.status === 'Received') {
if (inventoryMap.has(pid)) {
const item = inventoryMap.get(pid)!;
item.qty += data.qty;
item.closing += data.qty;
Object.assign(item, stockStatus(item.closing));
} else {
// Find product info from catalogue
const prod = products.find(p => p.id === pid);
if (prod) {
inventoryMap.set(pid, {
id: prod.id,
name: prod.name,
sku: prod.sku,
image: prod.image,
category: prod.category,
...stockStatus(data.qty),
price: prod.price,
qty: data.qty,
closing: data.qty
});
}
}
}
});
return Array.from(inventoryMap.values());
}, },
[stockQ.data, picks, products], [stockQ.data, products],
); );
const filteredInventory = useMemo(() => { const filteredInventory = useMemo(() => {
@@ -290,7 +323,9 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
}, [filteredInventory, selectedCategories, stockHealthFilter]); }, [filteredInventory, selectedCategories, stockHealthFilter]);
// ── Integration point ────────────────────────────────────────────────────────── // ── Integration point ──────────────────────────────────────────────────────────
// The request is saved to localStorage automatically via the useEffect on `picks`. // Requests are persisted server-side via POST /products/createstockrequest
// (see togglePick / setPickQty); `picks` is only the local mirror of the rows
// that getstockrequests returns.
return ( return (
<div className="animate-in fade-in duration-300 font-sans pb-28"> <div className="animate-in fade-in duration-300 font-sans pb-28">
@@ -325,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>
@@ -641,7 +693,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
e.stopPropagation(); e.stopPropagation();
handleMarkReceived(data); handleMarkReceived(data);
}} }}
disabled={stockInMutation.isPending || updateRequestMutation.isPending} disabled={updateRequestMutation.isPending}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold text-white bg-indigo-600 hover:bg-indigo-700 transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed" className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold text-white bg-indigo-600 hover:bg-indigo-700 transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
> >
<CheckCircle2 size={14} /> Mark as Received <CheckCircle2 size={14} /> Mark as Received
@@ -666,7 +718,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
) : !locationid ? ( ) : !locationid ? (
<CenterState icon={<Store size={34} />} title="No store linked yet" sub="Your account isn't linked to a store outlet, so there's no inventory to show." /> <CenterState icon={<Store size={34} />} title="No store linked yet" sub="Your account isn't linked to a store outlet, so there's no inventory to show." />
) : inventory.length === 0 ? ( ) : inventory.length === 0 ? (
<CenterState icon={<PackageSearch size={34} />} title="No products stocked yet" sub="Add products from the catalogue and they'll appear here with live stock levels." /> <CenterState icon={<PackageSearch size={34} />} title="No products stocked yet" sub="Products appear here once stock actually arrives — request stock from the catalogue, then confirm it as received." />
) : finalFilteredInventory.length === 0 ? ( ) : finalFilteredInventory.length === 0 ? (
<CenterState <CenterState
icon={<Boxes size={34} />} icon={<Boxes size={34} />}
@@ -925,7 +977,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
setSelectedProduct(null); setSelectedProduct(null);
handleMarkReceived({ productid: selectedProduct.id, qty: pick.qty, requestid: pick.requestid }); handleMarkReceived({ productid: selectedProduct.id, qty: pick.qty, requestid: pick.requestid });
}} }}
disabled={stockInMutation.isPending || updateRequestMutation.isPending} disabled={updateRequestMutation.isPending}
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl bg-indigo-600 text-white hover:bg-indigo-700 font-semibold text-sm transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed" className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl bg-indigo-600 text-white hover:bg-indigo-700 font-semibold text-sm transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
> >
<CheckCircle2 size={18} /> Mark as Received <CheckCircle2 size={18} /> Mark as Received
@@ -973,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>
); );
} }

View File

@@ -273,7 +273,9 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
name: fstr(c.fullname) || `${fstr(c.firstname)} ${fstr(c.lastname)}`.trim() || 'Customer', name: fstr(c.fullname) || `${fstr(c.firstname)} ${fstr(c.lastname)}`.trim() || 'Customer',
phone: fstr(c.contactno) || '—', phone: fstr(c.contactno) || '—',
email: fstr(c.email), email: fstr(c.email),
address: fstr(c.address) || 'Coimbatore', // Was `|| 'Coimbatore'`, which invented an address for every customer
// whose record has none — and for every tenant outside Coimbatore.
address: fstr(c.address) || '—',
ordersCount: Number(c.orderscount) || 0, ordersCount: Number(c.orderscount) || 0,
totalSpent: spent > 0 ? `${spent.toLocaleString('en-IN')}` : '—' totalSpent: spent > 0 ? `${spent.toLocaleString('en-IN')}` : '—'
}; };

View File

@@ -106,6 +106,30 @@ export function num(v: unknown): number {
export const str = (v: unknown): string => (v == null ? '' : String(v)); export const str = (v: unknown): string => (v == null ? '' : String(v));
/**
* Display name for a customer row. `gettenantcustomers` returns `firstname` /
* `lastname` and has NO `customername` or `name` column, so code that read those
* fell through to "Unknown Customer" for every customer on the platform. Delivery
* rows spell the same person `deliverycustomer`, hence the extra fallbacks.
*/
export function customerName(r: Row): string {
const full = `${str(r.firstname).trim()} ${str(r.lastname).trim()}`.trim();
return (
full ||
str(r.deliverycustomer).trim() ||
str(r.customername).trim() ||
str(r.name).trim()
);
}
/**
* The store a customer belongs to. `gettenantcustomers` aliases the
* `tenantcustomers.locationid` link as `tenantlocationid` — plain `locationid`
* is NOT in the response, so filtering on it matched nothing. `deliverylocationid`
* is the saved-address id, a different thing entirely, and must not be used here.
*/
export const customerStoreId = (r: Row): number => num(r.tenantlocationid) || num(r.locationid);
/** Fiesta date params want a bare `YYYY-MM-DD`. */ /** Fiesta date params want a bare `YYYY-MM-DD`. */
export const ymd = (d: Date) => export const ymd = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -352,27 +376,48 @@ export async function getDeliveries(opts: {
pageno?: number; pageno?: number;
pagesize?: number; pagesize?: number;
}): Promise<Row[]> { }): Promise<Row[]> {
const rows = toRows( const pagesize = opts.pagesize ?? 200;
await fiestaGet('deliveries/getdeliveries', { const fetchPage = async (pageno: number) =>
tenantid: opts.tenantid, toRows(
// NOTE: do NOT send `locationid` to getdeliveries — the backend's locationid await fiestaGet('deliveries/getdeliveries', {
// filter on THIS endpoint is broken: passing a real outlet id returns [] tenantid: opts.tenantid,
// (it doesn't match against the row's own `locationid`), even though // NOTE: do NOT send `locationid` to getdeliveries — the backend's locationid
// deliverysummary honours the same id and the rows clearly carry it. So we // filter on THIS endpoint is broken: passing a real outlet id returns []
// fetch tenant-wide here and scope by locationid client-side below; the KPI // (it doesn't match against the row's own `locationid`), even though
// strip (deliverysummary) keeps using the working server-side filter. // deliverysummary honours the same id and the rows clearly carry it. So we
applocationid: opts.applocationid, // fetch tenant-wide here and scope by locationid client-side below; the KPI
// The backend treats `status` as a LITERAL orderstatus filter — passing // strip (deliverysummary) keeps using the working server-side filter.
// 'all' matches nothing (returns []). Send empty to fetch every status and applocationid: opts.applocationid,
// let the board filter client-side by its status tabs. // The backend treats `status` as a LITERAL orderstatus filter — passing
status: !opts.status || opts.status === 'all' ? '' : opts.status, // 'all' matches nothing (returns []). Send empty to fetch every status and
fromdate: opts.fromdate, // let the board filter client-side by its status tabs.
todate: opts.todate, status: !opts.status || opts.status === 'all' ? '' : opts.status,
keyword: opts.keyword, fromdate: opts.fromdate,
pageno: opts.pageno ?? 1, todate: opts.todate,
pagesize: opts.pagesize ?? 200, keyword: opts.keyword,
}), pageno,
); pagesize,
}),
);
let rows: Row[];
if (opts.pageno) {
// An explicit page was asked for — honour it and don't walk the rest.
rows = await fetchPage(opts.pageno);
} else {
// Walk every page. The endpoint has no total-count field, so a short page is
// the only end-of-data signal. Previously this fetched page 1 only, which
// silently dropped delivery 201+ for a busy tenant-wide day — the dispatch
// board looked complete while missing stops. MAX_PAGES caps a runaway loop
// if the backend ever ignores `pageno` and keeps returning full pages.
const MAX_PAGES = 25;
rows = [];
for (let page = 1; page <= MAX_PAGES; page++) {
const batch = await fetchPage(page);
rows.push(...batch);
if (batch.length < pagesize) break;
}
}
return opts.locationid ? rows.filter((r) => num(r.locationid) === opts.locationid) : rows; return opts.locationid ? rows.filter((r) => num(r.locationid) === opts.locationid) : rows;
} }
@@ -530,25 +575,97 @@ export async function assignRiderToOrders(
return { ok, failed, total: orders.length }; return { ok, failed, total: orders.length };
} }
// ════════════════════════════════════════════════════════════════════════════
// RIDER PUSH NOTIFICATION
// ════════════════════════════════════════════════════════════════════════════
/**
* Thrown when the rider has no registered device. Distinct from a transport
* failure because the remedy is different — the rider must open the app and
* sign in, not retry. Without this the operator sees a generic "notification
* failed" and assumes the network is at fault.
*/
export class RiderNotReachableError extends Error {
constructor(message = 'This rider has no device registered, so they were not notified.') {
super(message);
this.name = 'RiderNotReachableError';
}
}
export interface NotifyRiderInput {
token: string;
title?: string;
body: string;
/** Silent payload the rider app switches on, e.g. `{ type: 'cancel' }`. */
data?: Record<string, string>;
}
/**
* POST /utils/notifyuser — relays an FCM push to a rider through the backend,
* which holds the Firebase service account.
*
* Fire-and-forget by design: there is no delivery receipt and no retry. The
* delivery row is already committed by the time this runs, so a failure here
* means the rider has work they have not been told about — which is why it is
* surfaced to the operator rather than swallowed.
*/
export async function notifyRider(input: NotifyRiderInput): Promise<Row> {
const token = (input.token ?? '').trim();
// Checked before the request: posting an empty token returns a generic FCM
// "invalid argument", which reads as a server fault rather than a rider who
// has never opened the app.
if (!token) throw new RiderNotReachableError();
return fiestaSend<Row>('utils/notifyuser', 'POST', {
token,
notification: {
title: input.title ?? 'NearleXpress',
body: input.body,
sound: 'ring',
image: '',
},
...(input.data ? { data: input.data } : {}),
});
}
/** Standard message bodies, kept together so the wording stays consistent. */
export const RIDER_MESSAGES = {
assigned: (count: number) =>
count === 1
? 'An order has been assigned to you. Kindly accept and process the delivery.'
: `${count} orders have been assigned to you. Kindly accept and process the deliveries.`,
reassigned: 'A delivery has been assigned to you. Kindly accept and process it.',
reminder: 'You have deliveries waiting. Kindly accept and process them.',
cancelled: (orderid: string) => `${orderid} has been cancelled.`,
} as const;
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// PARTNERS / RIDERS // PARTNERS / RIDERS
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
/** /**
* /partners/getriders?applocationid=&tenantid=&partnerid= — active rider fleet. * /partners/getriders?applocationid=&partnerid=&tenantid= — riders on duty NOW.
* Scoped by tenant AND partner: a rider belongs to one tenant/partner, so an *
* order can only be assigned to a rider sharing its partnerid. Passing the * Despite the name this is a presence query, not a roster. The backend requires
* order's partnerid keeps the assignable list correct (an out-of-tenant rider * status='Active', onduty=1, and a riderlog dated today with logstatus=0, then
* simply won't appear, which is the intended guard). * joins each rider's most recent GPS ping. So it answers "who is working right
* now", and the rows carry userfcmtoken for notifying them.
*
* Scope by applocationid or partnerid. NOT by tenantid: a rider record leaves
* app_users.tenantid unset (riders belong to a partner and an app-location), so
* a tenant-scoped call returns an empty list for every tenant. The backend
* checks applocationid first, then partnerid, then tenantid, so passing an
* app-location alongside anything else wins.
*/ */
export async function getRiders(opts: { export async function getRiders(opts: {
applocationid?: number; applocationid?: number;
tenantid: number; tenantid?: number;
partnerid?: number; partnerid?: number;
}): Promise<Row[]> { }): Promise<Row[]> {
const scoped = opts.applocationid || opts.partnerid || opts.tenantid;
return toRows( return toRows(
await fiestaGet('partners/getriders', { await fiestaGet('partners/getriders', {
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID, applocationid: scoped ? opts.applocationid : FIESTA_APPLOCATION_ID,
tenantid: opts.tenantid, tenantid: opts.tenantid,
partnerid: opts.partnerid, partnerid: opts.partnerid,
}), }),
@@ -686,7 +803,14 @@ export async function getTenantCustomers(opts: {
locationid: opts.locationid, locationid: opts.locationid,
keyword: opts.keyword ?? '', keyword: opts.keyword ?? '',
pageno: opts.pageno ?? 1, pageno: opts.pageno ?? 1,
pagesize: opts.pagesize ?? 20, // When a store is named the backend joins `customerlocations`, so it
// returns one row per SAVED ADDRESS and applies LIMIT to those rows — not
// to customers. Live: locationid=1185 → 12 rows → 2 customers (11 of them
// one person's addresses). The old default of 20 therefore showed a store
// roughly three customers. Ask for enough rows that dedupe still has every
// customer to work with; the backend's DISTINCT ON fix makes this generous
// rather than load-bearing.
pagesize: opts.pagesize ?? 500,
}), }),
)); ));
} }
@@ -800,7 +924,13 @@ export interface CreateProductLocationInput {
/** POST /products/createproductlocation — Add a product to a store catalogue / inventory. (Expects array payload) */ /** POST /products/createproductlocation — Add a product to a store catalogue / inventory. (Expects array payload) */
export async function createProductLocation(input: CreateProductLocationInput): Promise<Row> { export async function createProductLocation(input: CreateProductLocationInput): Promise<Row> {
const payload = { // The selling price field is `price` — it binds to models.Productlocations
// .Price, and CreateProductLocation's upsert lists "price" in its
// DoUpdates, so this both inserts and updates the per-store price.
// (`retailprice` is the MASTER price on the products table; that struct
// ignores it, so sending it here persisted nothing.) Only send when a price
// was supplied, so a quantity-only write can't blank an existing one.
const payload: Record<string, unknown> = {
tenantid: input.tenantid, tenantid: input.tenantid,
locationid: input.locationid, locationid: input.locationid,
productid: input.productid, productid: input.productid,
@@ -808,6 +938,7 @@ export async function createProductLocation(input: CreateProductLocationInput):
stocktype: input.stocktype || 'in', stocktype: input.stocktype || 'in',
status: input.status || 'Active', status: input.status || 'Active',
}; };
if (num(input.price) > 0) payload.price = input.price;
return fiestaSend<Row>('products/createproductlocation', 'POST', [payload]); return fiestaSend<Row>('products/createproductlocation', 'POST', [payload]);
} }
@@ -1037,6 +1168,8 @@ export interface CreateTenantLocationPayload {
city?: string; city?: string;
state?: string; state?: string;
postcode?: string; postcode?: string;
latitude?: string;
longitude?: string;
applocationid?: number; applocationid?: number;
} }
@@ -1050,6 +1183,8 @@ export interface CreateTenantInput {
city?: string; city?: string;
state?: string; state?: string;
postcode?: string; postcode?: string;
latitude?: string;
longitude?: string;
approved?: number; approved?: number;
status?: string; status?: string;
applocationid?: number; applocationid?: number;
@@ -1199,12 +1334,48 @@ export async function updateDelivery(deliveryid: number, updates: Row): Promise<
}); });
} }
/** POST /riders/reassigndeliveries — Batch-reassign multiple deliveries to a new rider. */ /**
* Move one delivery to a different rider.
*
* Goes through PUT /deliveries/updatedelivery. There is no batch reassign
* endpoint: /riders/reassigndeliveries, which this used to POST to, is not
* registered on the backend and answers 404 — it had no callers, so the failure
* was never observed.
*
* Re-assigning resets orderstatus to 'pending', discarding any accepted/arrived
* progress the previous rider had made. That is the backend's existing
* behaviour and callers should gate the action accordingly.
*/
export async function changeDeliveryRider(opts: {
deliveryid: number;
orderheaderid: number;
userid: number;
}): Promise<Row> {
return fiestaSend<Row>('deliveries/updatedelivery', 'PUT', {
deliveryid: opts.deliveryid,
orderheaderid: opts.orderheaderid,
userid: opts.userid,
orderstatus: 'pending',
assigntime: nowStamp(),
});
}
/** Reassign several deliveries to one rider, one call each. Tolerates partial
* failure and reports it, the same contract as assignRiderToOrders. */
export async function reassignDeliveries(opts: { export async function reassignDeliveries(opts: {
userid: number; userid: number;
deliveryids: number[]; deliveries: { deliveryid: number; orderheaderid: number }[];
}): Promise<Row> { }): Promise<{ ok: number; failed: number; total: number }> {
return fiestaSend<Row>('riders/reassigndeliveries', 'POST', opts); const results = await Promise.allSettled(
opts.deliveries.map((d) =>
changeDeliveryRider({ deliveryid: d.deliveryid, orderheaderid: d.orderheaderid, userid: opts.userid }),
),
);
return {
ok: results.filter((r) => r.status === 'fulfilled').length,
failed: results.filter((r) => r.status === 'rejected').length,
total: opts.deliveries.length,
};
} }
/** POST /v1/web/tenants/createlocation — Create a new tenant location (outlet). */ /** POST /v1/web/tenants/createlocation — Create a new tenant location (outlet). */
@@ -1276,6 +1447,143 @@ export async function getSalesSummary(opts: {
return res.details; return res.details;
} }
// ════════════════════════════════════════════════════════════════════════════
// OFFLINE (IN-STORE) SALES
// ════════════════════════════════════════════════════════════════════════════
export interface SaleTemplateRow {
tenantid: number;
locationid: number;
locationname: string;
productid: number;
productname: string;
productunit: string;
unitvalue: string;
categoryname: string;
currentstock: number;
price: number;
taxpercent: number;
}
export interface SaleTemplateLocation {
locationid: number;
locationname: string;
productcount: number;
}
export interface SaleTemplate {
tenantid: number;
/** 0 when the template spans every branch of the tenant. */
locationid: number;
locations: SaleTemplateLocation[];
products: SaleTemplateRow[];
}
/**
* GET /products/getsaletemplate — products stocked across the tenant's
* branches, each with its live ledger balance and price.
*
* `locationid` is optional and defaults to every branch, which is the normal
* case: one workbook covers the whole business and each row carries the branch
* its stock belongs to. Pass a locationid to narrow it to a single store.
*
* 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 and locationid removes both
* problems at once.
*/
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 ?? 0,
});
if (!res?.details) throw new Error('No products are stocked at any of your outlets yet.');
return res.details;
}
export interface OfflineSaleItemInput {
productid: number;
productname?: string;
qtysold: number;
unitprice?: number;
discountamount?: number;
taxpercent?: number;
}
export interface OfflineSaleBillInput {
/** Branch this bill was rung up at, taken from the spreadsheet row. */
locationid: number;
billno?: string;
saledate?: string;
paymentmode?: string;
customername?: string;
customermobile?: string;
remarks?: string;
items: OfflineSaleItemInput[];
}
export interface OfflineSaleResult {
locationid: number;
locationname: string;
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.
*
* `locationid` is a scope constraint, not the destination. Omit it and each
* bill goes to the branch named on its own rows — the multi-branch case. Set it
* and the upload is pinned to that branch, with any bill naming another one
* refused; that is how a store user is held to their own store regardless of
* what the spreadsheet was edited to say.
*/
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 ?? 0,
userid: input.userid ?? 0,
bills: input.bills,
},
);
return res.details;
}
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// GLOBAL CATALOGUE // GLOBAL CATALOGUE
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════

View File

@@ -56,6 +56,9 @@ import {
updateUser, updateUser,
setUserPassword, setUserPassword,
assignRiderToOrders, assignRiderToOrders,
changeDeliveryRider,
notifyRider,
NotifyRiderInput,
CreateUserInput, CreateUserInput,
createTenantUser, createTenantUser,
createTenantLocation, createTenantLocation,
@@ -350,12 +353,53 @@ export function useFiestaAssignRider() {
}); });
} }
/**
* Move a delivery to a different rider. Refreshes the deliveries board and its
* KPI cards, plus the orders list, since the order's rider is shown there too.
*/
export function useFiestaChangeRider() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: { deliveryid: number; orderheaderid: number; userid: number }) =>
changeDeliveryRider(input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['fiesta', 'deliveries'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'deliverySummary'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'orders'] });
},
});
}
/**
* Push a message to a rider's device.
*
* Deliberately NOT chained into the assign mutation's onSuccess: the delivery
* is already committed by then, so a failed push must not read as a failed
* assignment. Callers fire this after the write and report the two outcomes
* separately.
*/
export function useFiestaNotifyRider() {
return useMutation({
mutationFn: (input: NotifyRiderInput) => notifyRider(input),
});
}
// ── Partners / Riders ───────────────────────────────────────────────────────── // ── Partners / Riders ─────────────────────────────────────────────────────────
export function useFiestaRiders(opts: { applocationid?: number; tenantid: number; partnerid?: number }) { /**
* Riders on duty right now — see getRiders for why this is presence, not a
* roster. Enabled on ANY scope: gating on tenantid alone kept the query off for
* callers that legitimately scope by app-location, which is the only scope that
* actually returns riders.
*/
export function useFiestaRiders(opts: { applocationid?: number; tenantid?: number; partnerid?: number }) {
return useQuery({ return useQuery({
queryKey: fiestaKeys.riders(opts), queryKey: fiestaKeys.riders(opts),
queryFn: () => getRiders(opts), queryFn: () => getRiders(opts),
enabled: Boolean(opts.tenantid), enabled: Boolean(opts.applocationid || opts.partnerid || opts.tenantid),
// Presence goes stale quickly — a rider logging off mid-shift should drop
// out of the assign list rather than linger for the whole session.
staleTime: 60_000,
refetchInterval: 120_000,
}); });
} }
@@ -379,7 +423,11 @@ export function useFiestaRiderPeriodicLogs(opts: {
return useQuery({ return useQuery({
queryKey: fiestaKeys.riderPeriodicLogs(opts), queryKey: fiestaKeys.riderPeriodicLogs(opts),
queryFn: () => getRiderPeriodicLogs(opts), queryFn: () => getRiderPeriodicLogs(opts),
enabled: Boolean(opts.fromdate && opts.todate), // A rider is required. Without this guard the query fired on every page load
// with no rider selected — and `riders/getriderperiodiclogs` 404s in both the
// riders/ and partners/ namespaces (no such backend route exists), so every
// load spent a request on a guaranteed failure.
enabled: Boolean((opts.userid || opts.riderid) && opts.fromdate && opts.todate),
}); });
} }
@@ -425,14 +473,20 @@ export function useFiestaUpdateDelivery() {
}); });
} }
/**
* Move several deliveries to one rider. Each needs its orderheaderid as well as
* its deliveryid — updatedelivery keys on both — so this takes delivery rows
* rather than the bare id list it used to.
*/
export function useFiestaReassignDeliveries() { export function useFiestaReassignDeliveries() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (input: { userid: number; deliveryids: number[] }) => mutationFn: (input: { userid: number; deliveries: { deliveryid: number; orderheaderid: number }[] }) =>
reassignDeliveries(input), reassignDeliveries(input),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['fiesta', 'deliveries'] }); qc.invalidateQueries({ queryKey: ['fiesta', 'deliveries'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'deliverySummary'] }); qc.invalidateQueries({ queryKey: ['fiesta', 'deliverySummary'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'orders'] });
}, },
}); });
} }
@@ -757,7 +811,12 @@ export function useFiestaGetStockRequests(opts: {
return useQuery({ return useQuery({
queryKey: fiestaKeys.stockRequests(opts), queryKey: fiestaKeys.stockRequests(opts),
queryFn: () => getStockRequests(opts), queryFn: () => getStockRequests(opts),
enabled: Boolean(opts.tenantid), // Omitting locationid is a deliberate tenant-wide query (the admin console
// needs every outlet's requests). But passing an EMPTY one is not: the
// backend treats locationid=0 as "no filter" and returns every outlet, so a
// store user whose outlet hasn't resolved yet would see other stores'
// requests. Wait for a real id in that case.
enabled: Boolean(opts.tenantid) && (opts.locationid === undefined || Boolean(opts.locationid)),
}); });
} }

View File

@@ -0,0 +1,561 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* The spreadsheet half of offline-sales import: turning a merchant's catalogue
* into a workbook the stores fill in, and turning that workbook back into bills
* the API can take.
*
* ONE workbook covers EVERY branch. Each row carries its own `tenantid` and
* `locationid`, and that row's `locationid` is what decides which branch the
* sale is deducted from. A merchant running six outlets downloads one file, and
* rows for all six can be filled in and uploaded together — nobody picks a
* store in the UI, because the sheet already says which store each line is for.
*
* Parsing happens in the browser rather than on the server so the operator sees
* every problem laid out against their own rows and can fix the file before
* anything is written. The backend validates all of it again, and re-checks
* that each locationid belongs to the tenant; 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 = 2;
/**
* Column headers, in the order they appear. The first six are locked reference
* data — `locationid` among them, since it routes the sale — and `qtysold` is
* the one the user is expected to type in.
*/
const COLUMNS = [
'tenantid',
'locationid',
'locationname',
'productid',
'productname',
'currentstock',
'qtysold',
'unitprice',
'discountamount',
'taxpercent',
'billno',
'saledate',
'paymentmode',
'customername',
'customermobile',
'remarks',
] as const;
const COLUMN_WIDTHS = [10, 11, 22, 11, 38, 13, 10, 11, 15, 11, 14, 13, 13, 18, 15, 24];
/** Rendered above the table so the sheet explains itself without the help tab. */
const HEADER_LABELS: Record<string, string> = {
tenantid: 'tenantid (do not edit)',
locationid: 'locationid (do not edit)',
locationname: 'store (do not edit)',
productid: 'productid (do not edit)',
productname: 'productname (do not edit)',
currentstock: 'currentstock (info)',
qtysold: 'qtysold *',
unitprice: 'unitprice',
discountamount: 'discountamount',
taxpercent: 'taxpercent',
billno: 'billno',
saledate: 'saledate',
paymentmode: 'paymentmode',
customername: 'customername',
customermobile: 'customermobile',
remarks: 'remarks',
};
const INSTRUCTIONS: string[][] = [
['How to record offline (counter) sales'],
[''],
['This ONE file covers every one of your stores.'],
['Each row already says which store it belongs to, in the locationid and store columns.'],
['Fill in rows for as many stores as you like and upload the file once —'],
['each sale is deducted from the store named on its own row.'],
[''],
['1.', 'Fill in the "qtysold" column for whatever was sold at the counter.'],
['', 'Leave the row blank or 0 if the product did not sell — blank rows are ignored.'],
['2.', 'Do NOT edit tenantid, locationid, store, productid or productname.'],
['', 'They identify the store and the product, and must match.'],
['', 'If a product is missing, add it to that store catalogue first, then download a'],
['', 'fresh template.'],
['3.', 'unitprice defaults to that store price. 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.'],
['', 'Bill numbers only need to be unique WITHIN a store — the same number at two'],
['', 'different stores is treated as two separate sales.'],
['', 'Leave billno empty and each store gets one bill for all its rows.'],
['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 per store, 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. Every stocked product at every branch gets a row, even
* when its stock is zero — the sheet is a worksheet to fill in, and hiding rows
* would mean an operator could not record a sale they actually made.
*
* Rows arrive already ordered by store then product, so a store's rows sit
* together and can be filled in as one block.
*/
export function buildSaleTemplateWorkbook(template: SaleTemplate): XLSX.WorkBook {
const header = COLUMNS.map((c) => HEADER_LABELS[c] ?? c);
const body = template.products.map((p) => [
p.tenantid,
p.locationid,
p.locationname,
p.productid,
p.productname,
p.currentstock,
// qtysold onwards are the operator's columns, left empty: 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' };
// Excel's filter dropdowns, so a store can isolate its own rows in a file
// that spans the whole business.
sales['!autofilter'] = { ref: XLSX.utils.encode_range({ s: { r: 0, c: 0 }, e: { r: body.length, c: COLUMNS.length - 1 } }) };
const info = XLSX.utils.aoa_to_sheet([
['Field', 'Value'],
['tenantid', template.tenantid],
['generatedon', new Date().toISOString()],
['templateversion', TEMPLATE_VERSION],
['stores in this file', template.locations.length],
[''],
['Stores covered', 'Products'],
...template.locations.map((l) => [`${l.locationid}${l.locationname}`, l.productcount]),
[''],
['Do not edit this sheet.'],
['Each sale is routed by the locationid on its own row in the Sales sheet.'],
]);
info['!cols'] = [{ wch: 34 }, { 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;
}
/** Named for what it spans: one store by name, or "all-stores" for the full
* business, plus the date so a folder of these stays sortable. */
export function saleTemplateFilename(template: SaleTemplate): string {
const single = template.locations.length === 1 ? template.locations[0].locationname : '';
const slug =
(single || 'all-stores')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'all-stores';
return `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;
tenantid: number | null;
locationid: number;
locationname: string;
productid: number;
productname: string;
currentstock: number | null;
qtysold: number;
unitprice: number | null;
discountamount: number;
taxpercent: number | null;
billno: string;
saledate: string;
paymentmode: string;
customername: string;
customermobile: string;
remarks: string;
errors: string[];
warnings: string[];
}
export interface ParsedSheet {
/** Tenant read from the Store Info sheet, when the file still has it. */
tenantid: number | null;
/** Rows with a quantity — blank ones are dropped, not reported. */
rows: ParsedSaleRow[];
/** Rows skipped for having no quantity. Counted so an empty column can be
* told 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']);
export interface ParseScope {
tenantid: number;
/** When set, the upload is pinned to this branch and rows for any other are
* rejected — the store-user case. Omit for a multi-branch upload. */
locationid?: number;
/** Branches the uploader may write to, for naming an unknown locationid in a
* useful way. Absence of this list disables the check, since the backend
* authorises every branch anyway. */
allowedLocationIds?: number[];
}
/**
* 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, scope: ParseScope): ParsedSheet {
const out: ParsedSheet = { tenantid: null, 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;
}
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) {
if (toStr(r?.[0]).toLowerCase() === 'tenantid') out.tenantid = toNum(r?.[1]);
}
}
if (out.tenantid !== null && out.tenantid !== scope.tenantid) {
out.fatal.push(
`This file was generated for a different account (tenant ${out.tenantid}). Download a fresh template.`,
);
}
const 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;
}
if (index.locationid === undefined && !scope.locationid) {
// Without a locationid column there is nothing to route a sale by, and
// guessing a branch would silently move the wrong store's stock.
out.fatal.push(
'The Sales sheet is missing the "locationid" column, so there is no way to tell which store each sale belongs to. Download a fresh template.',
);
return out;
}
const allowed = scope.allowedLocationIds?.length ? new Set(scope.allowedLocationIds) : null;
const cell = (row: unknown[], key: string): unknown => {
const 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 of every store, and most rows are expected to be empty.
if (qty === null || qty === 0) {
out.skipped++;
continue;
}
const productid = toNum(cell(raw, 'productid'));
const rowLocation = toNum(cell(raw, 'locationid'));
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'));
// A pinned upload (store user) supplies the branch, so a sheet without the
// column still works for them.
const locationid = rowLocation ?? scope.locationid ?? 0;
const row: ParsedSaleRow = {
excelRow,
tenantid: toNum(cell(raw, 'tenantid')),
locationid,
locationname: toStr(cell(raw, 'locationname')),
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 (!locationid || locationid <= 0) {
row.errors.push('locationid is missing — this row does not say which store it belongs to');
} else if (row.tenantid !== null && row.tenantid !== scope.tenantid) {
row.errors.push(`tenantid ${row.tenantid} is not your account`);
} else if (scope.locationid && locationid !== scope.locationid) {
// The store-user guard. The backend enforces this too; saying it here
// means they see it before uploading rather than as a rejected bill.
row.errors.push('this row is for another store, which you cannot upload for');
} else if (allowed && !allowed.has(locationid)) {
row.errors.push(`locationid ${locationid} is not one of your stores`);
}
if (qty < 0) {
row.errors.push('qtysold cannot be negative');
}
// 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.
*
* Bills are keyed on BRANCH first and bill number second, so the same bill
* number at two stores stays two separate sales rather than colliding — which
* matters now that one file spans the whole business, and counter books at
* different outlets routinely restart numbering from 1.
*
* Rows with no billno collapse into a single unnumbered bill per branch 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.locationid}::${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 {
locationid: group[0].locationid,
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,
})),
};
});
}
export interface SaleSummary {
lines: number;
units: number;
amount: number;
errors: number;
warnings: number;
bills: number;
stores: number;
/** Per-branch breakdown, so a multi-store upload can be checked store by
* store before it is committed. */
byStore: { locationid: number; locationname: string; lines: number; units: number; amount: number; errors: number }[];
}
/** 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[]): SaleSummary {
let units = 0;
let amount = 0;
let errors = 0;
let warnings = 0;
const bills = new Set<string>();
const stores = new Map<number, SaleSummary['byStore'][number]>();
for (const r of rows) {
const lineAmount = Math.max(0, (r.unitprice ?? 0) * r.qtysold - r.discountamount);
units += r.qtysold;
amount += lineAmount;
if (r.errors.length) errors++;
if (r.warnings.length) warnings++;
bills.add(`${r.locationid}::${r.billno.trim().toUpperCase() || '__nobill__'}`);
let store = stores.get(r.locationid);
if (!store) {
store = { locationid: r.locationid, locationname: r.locationname, lines: 0, units: 0, amount: 0, errors: 0 };
stores.set(r.locationid, store);
}
store.lines++;
store.units += r.qtysold;
store.amount += lineAmount;
if (r.errors.length) store.errors++;
if (!store.locationname && r.locationname) store.locationname = r.locationname;
}
return {
lines: rows.length,
units,
amount,
errors,
warnings,
bills: bills.size,
stores: stores.size,
byStore: Array.from(stores.values()).sort((a, b) => b.amount - a.amount),
};
}

View File

@@ -9,12 +9,16 @@
* users** see and pick from. It's the design-stage bridge between the admin * users** see and pick from. It's the design-stage bridge between the admin
* catalogue page (InventoryView) and the user catalogue page (StoreCatalogView). * catalogue page (InventoryView) and the user catalogue page (StoreCatalogView).
* *
* Persisted in localStorage so the flow is fully demonstrable on one device; it * Backed entirely by the database — there is no localStorage here (an earlier
* syncs live across tabs/pages via a storage event. The backend equivalents * revision of this comment described a localStorage bridge that no longer
* (once built) are: * exists). Every read and write goes to Fiesta, scoped by tenant + outlet:
* • admin curates → POST /products/createproductlocation (productid, qty, …) * • read → GET /products/getlocationproducts (useFiestaProductLocations)
* • user reads → GET /products/getlocationproducts * • add / update qty / update price
* Swap `read`/`write` for those calls when the API is ready; the hook API stays. * → POST /products/createproductlocation
* • remove → DELETE /products/deleteproductlocation
*
* Mutations invalidate the React Query cache, so admin edits show up on the
* store user's catalogue on its next fetch.
*/ */
import { useFiestaProductLocations, useFiestaCreateProductLocation, useFiestaDeleteProductLocation } from './fiestaQueries'; import { useFiestaProductLocations, useFiestaCreateProductLocation, useFiestaDeleteProductLocation } from './fiestaQueries';
@@ -30,9 +34,41 @@ export interface StoreCatalogueItem {
unit: string; unit: string;
/** Quantity the admin intends to stock for this product. */ /** Quantity the admin intends to stock for this product. */
qty: number; qty: number;
/**
* The row's status as the backend reports it — NOT a publish/unpublish flag.
*
* This field is overloaded. We write 'Active' when publishing, but the
* backend later overwrites it with a stock-availability value ('available',
* 'outofstock'), so a published product does not stay 'Active'. Membership of
* this list IS publication; treat status as display metadata only and never
* as a visibility gate. Use `isPublishedItem` / `isRemovedStatus` instead.
*/
status: string; status: string;
} }
/**
* Statuses that mean the row is no longer part of the store catalogue.
*
* Deliberately a small deny-list rather than an 'Active' allow-list: the
* backend mixes lifecycle values ('Active') with availability values
* ('available', 'outofstock') in the same field, so anything not explicitly
* removed is still catalogued — an out-of-stock product is still on the menu.
*/
const REMOVED_STATUSES = new Set(['inactive', 'deleted', 'removed', 'archived']);
export function isRemovedStatus(status: unknown): boolean {
return REMOVED_STATUSES.has(String(status ?? '').trim().toLowerCase());
}
/**
* Is this product published to the store catalogue? A product-location row
* exists for it and has not been removed — that is the whole test. It stays
* published, whatever its stock level, until the admin deletes it.
*/
export function isPublishedItem(item: StoreCatalogueItem | undefined | null): boolean {
return Boolean(item) && !isRemovedStatus(item!.status);
}
/** /**
* Live view of the store catalogue + curation helpers. Re-renders whenever the * Live view of the store catalogue + curation helpers. Re-renders whenever the
* catalogue changes via React Query invalidation. * catalogue changes via React Query invalidation.
@@ -44,14 +80,20 @@ export function useStoreCatalogue(tenantid: number = FIESTA_TENANT_ID, locationi
pagesize: 500, pagesize: 500,
}); });
const items: StoreCatalogueItem[] = (q.data || []).filter((r: any) => r.status !== 'Inactive').map((r: any) => ({ const items: StoreCatalogueItem[] = (q.data || []).filter((r: any) => !isRemovedStatus(r.status)).map((r: any) => ({
productid: String(r.productid), productid: String(r.productid),
name: String(r.name || r.productname || ''), name: String(r.name || r.productname || ''),
image: String(r.image || r.productimage || ''), image: String(r.image || r.productimage || ''),
category: String(r.category || r.categoryname || 'General'), category: String(r.category || r.categoryname || 'General'),
sku: String(r.sku || ''), // The row's columns are `productsku` / `productunit` — the bare `sku` /
price: Number(r.price || 0), // `unit` names don't exist on it, so a real SKU "PEPSIC-CHE-100-002"
unit: String(r.unit || ''), // rendered as "SKU-7075" and a 100g unit rendered as "Pc".
sku: String(r.productsku || r.sku || ''),
// Per-store price (productlocations.price) wins; `retailprice` is the
// master price on the products row and is the fallback for an outlet that
// hasn't set its own.
price: Number(r.price || r.retailprice || 0),
unit: String(r.productunit || r.unit || ''),
qty: Number(r.quantity ?? r.qty ?? 0), qty: Number(r.quantity ?? r.qty ?? 0),
status: String(r.status || 'Draft').charAt(0).toUpperCase() + String(r.status || 'Draft').slice(1).toLowerCase(), status: String(r.status || 'Draft').charAt(0).toUpperCase() + String(r.status || 'Draft').slice(1).toLowerCase(),
})); }));