Compare commits
7 Commits
02b5258000
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 13db63e219 | |||
| 8deaf8513b | |||
| cc08f2f6c5 | |||
| 9990355e19 | |||
| eb6e750b6a | |||
| 8885c55817 | |||
| 6a6221f9f3 |
106
package-lock.json
generated
106
package-lock.json
generated
@@ -25,7 +25,8 @@
|
|||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.2.0",
|
"react-router-dom": "^7.2.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"vite": "^6.2.3"
|
"vite": "^6.2.3",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
@@ -1810,6 +1811,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
@@ -2040,6 +2050,19 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/claude": {
|
"node_modules/claude": {
|
||||||
"version": "0.1.1",
|
"version": "0.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/claude/-/claude-0.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/claude/-/claude-0.1.1.tgz",
|
||||||
@@ -2055,6 +2078,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -2097,6 +2129,18 @@
|
|||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-array": {
|
"node_modules/d3-array": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
@@ -2619,6 +2663,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "5.3.4",
|
"version": "5.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
@@ -4074,6 +4127,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -4821,6 +4886,24 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.21.0",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
@@ -4842,6 +4925,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.2.0",
|
"react-router-dom": "^7.2.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"vite": "^6.2.3"
|
"vite": "^6.2.3",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
|||||||
13
src/App.tsx
13
src/App.tsx
@@ -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)}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
625
src/components/OfflineSalesUpload.tsx
Normal file
625
src/components/OfflineSalesUpload.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')}` : '—'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
561
src/services/offlineSalesSheet.ts
Normal file
561
src/services/offlineSalesSheet.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user