new changes
This commit is contained in:
70
package-lock.json
generated
70
package-lock.json
generated
@@ -59,6 +59,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.7",
|
"@babel/generator": "^7.29.7",
|
||||||
@@ -933,9 +934,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -949,9 +947,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -965,9 +960,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -981,9 +973,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -997,9 +986,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1013,9 +999,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1029,9 +1012,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1045,9 +1025,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1061,9 +1038,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1077,9 +1051,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1093,9 +1064,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1109,9 +1077,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1125,9 +1090,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1337,9 +1299,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1356,9 +1315,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1375,9 +1331,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1394,9 +1347,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1861,6 +1811,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@@ -2840,9 +2791,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2863,9 +2811,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2886,9 +2831,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2909,9 +2851,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3251,6 +3190,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3277,6 +3217,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -3374,6 +3315,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -3383,6 +3325,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
||||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -4265,6 +4208,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|||||||
349
src/App.tsx
349
src/App.tsx
@@ -7,7 +7,6 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import {
|
import {
|
||||||
Network,
|
Network,
|
||||||
Truck,
|
Truck,
|
||||||
Users,
|
|
||||||
Sliders,
|
Sliders,
|
||||||
Calendar,
|
Calendar,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -34,10 +33,8 @@ import { MainSection } from './types';
|
|||||||
import {
|
import {
|
||||||
useFiestaTenantLocations,
|
useFiestaTenantLocations,
|
||||||
useFiestaLocationSummary,
|
useFiestaLocationSummary,
|
||||||
useFiestaUsers,
|
|
||||||
useFiestaCreateUser,
|
|
||||||
} from './services/fiestaQueries';
|
} from './services/fiestaQueries';
|
||||||
import { FIESTA_TENANT_ID, str as fstr, roleName } from './services/fiestaApi';
|
import { FIESTA_TENANT_ID, str as fstr } from './services/fiestaApi';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import Header from './components/Header';
|
import Header from './components/Header';
|
||||||
import DashboardView from './components/DashboardView';
|
import DashboardView from './components/DashboardView';
|
||||||
@@ -64,19 +61,10 @@ export default function App() {
|
|||||||
|
|
||||||
// ── Live data for the secondary sections (Fiesta) ─────────────────────────
|
// ── Live data for the secondary sections (Fiesta) ─────────────────────────
|
||||||
// Stores ← tenant locations + per-location order summary (seeded into local
|
// Stores ← tenant locations + per-location order summary (seeded into local
|
||||||
// state so the "Add Store" handler keeps working). Users come straight from
|
// state so the "Add Store" handler keeps working). Users management now lives
|
||||||
// the live Users API and render directly from the query, with the "Add User"
|
// under Settings → Users & Access (see UsersPanel).
|
||||||
// form posting back through the create-user mutation.
|
|
||||||
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
|
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
|
||||||
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
||||||
const usersQ = useFiestaUsers({ tenantid: FIESTA_TENANT_ID, pagesize: 100 });
|
|
||||||
const createUserMut = useFiestaCreateUser();
|
|
||||||
|
|
||||||
const USER_AVATARS = [
|
|
||||||
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=150&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=150&q=80',
|
|
||||||
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=150&q=80',
|
|
||||||
];
|
|
||||||
|
|
||||||
const STORE_COVERS = [
|
const STORE_COVERS = [
|
||||||
'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=600&q=80',
|
'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=600&q=80',
|
||||||
@@ -103,41 +91,6 @@ export default function App() {
|
|||||||
return STORE_COVERS[idx];
|
return STORE_COVERS[idx];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Live users mapped to display rows (rendered directly from the query).
|
|
||||||
const users = (usersQ.data ?? []).map((u, i) => {
|
|
||||||
const shift = fstr(u.shiftname).trim();
|
|
||||||
return {
|
|
||||||
userid: Number(u.userid),
|
|
||||||
name:
|
|
||||||
fstr(u.fullname).trim() ||
|
|
||||||
`${fstr(u.firstname)} ${fstr(u.lastname)}`.trim() ||
|
|
||||||
fstr(u.authname) ||
|
|
||||||
'User',
|
|
||||||
email: fstr(u.email) || fstr(u.authname) || '—',
|
|
||||||
contact: fstr(u.contactno) || '—',
|
|
||||||
roleid: Number(u.roleid),
|
|
||||||
role: roleName(Number(u.roleid)),
|
|
||||||
shift: shift && shift !== '-' ? shift : '—',
|
|
||||||
location: fstr(u.applocation) || fstr(u.city) || 'Coimbatore',
|
|
||||||
status: fstr(u.status) || 'Active',
|
|
||||||
avatar: USER_AVATARS[i % USER_AVATARS.length],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Role filter for the Users section ('ALL' or a numeric roleid).
|
|
||||||
const [userRoleFilter, setUserRoleFilter] = useState<number | 'ALL'>('ALL');
|
|
||||||
const filteredUsers = users.filter((u) => {
|
|
||||||
const q = searchQuery.toLowerCase();
|
|
||||||
const matchesSearch =
|
|
||||||
!q ||
|
|
||||||
u.name.toLowerCase().includes(q) ||
|
|
||||||
u.email.toLowerCase().includes(q) ||
|
|
||||||
u.contact.toLowerCase().includes(q);
|
|
||||||
const matchesRole = userRoleFilter === 'ALL' || u.roleid === userRoleFilter;
|
|
||||||
return matchesSearch && matchesRole;
|
|
||||||
});
|
|
||||||
const roleOptions = Array.from(new Set(users.map((u) => u.roleid)));
|
|
||||||
|
|
||||||
// Dynamic Secondary Modules list states (seeded from live data once it loads).
|
// Dynamic Secondary Modules list states (seeded from live data once it loads).
|
||||||
const [storesList, setStoresList] = useState<Array<{ locationid?: number; name: string; zone: string; deliveries: number; sales: string; orders: number; staff: string; color: string; status: string }>>([]);
|
const [storesList, setStoresList] = useState<Array<{ locationid?: number; name: string; zone: string; deliveries: number; sales: string; orders: number; staff: string; color: string; status: string }>>([]);
|
||||||
|
|
||||||
@@ -193,18 +146,9 @@ export default function App() {
|
|||||||
|
|
||||||
// Secondary sub-sections modals triggers
|
// Secondary sub-sections modals triggers
|
||||||
const [showAddStoreModal, setShowAddStoreModal] = useState(false);
|
const [showAddStoreModal, setShowAddStoreModal] = useState(false);
|
||||||
const [showAddUserModal, setShowAddUserModal] = useState(false);
|
|
||||||
|
|
||||||
// New forms states
|
// New forms states
|
||||||
const [newStore, setNewStore] = useState({ name: '', zone: '', lead: '', sales: '₹1,50,000' });
|
const [newStore, setNewStore] = useState({ name: '', zone: '', lead: '', sales: '₹1,50,000' });
|
||||||
const [newUser, setNewUser] = useState({
|
|
||||||
firstname: '',
|
|
||||||
lastname: '',
|
|
||||||
email: '',
|
|
||||||
contactno: '',
|
|
||||||
password: '',
|
|
||||||
roleid: 4,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Form submission handles for secondary sections
|
// Form submission handles for secondary sections
|
||||||
const handleCreateStore = (e: React.FormEvent) => {
|
const handleCreateStore = (e: React.FormEvent) => {
|
||||||
@@ -229,30 +173,6 @@ export default function App() {
|
|||||||
alert(`Node outlet "${newStore.name}" commissioned to live operations feed successfully.`);
|
alert(`Node outlet "${newStore.name}" commissioned to live operations feed successfully.`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateUser = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!newUser.firstname || !newUser.email || !newUser.contactno || !newUser.password) {
|
|
||||||
alert('Please provide first name, email, contact number, and a password.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await createUserMut.mutateAsync({
|
|
||||||
firstname: newUser.firstname,
|
|
||||||
lastname: newUser.lastname,
|
|
||||||
email: newUser.email,
|
|
||||||
contactno: newUser.contactno,
|
|
||||||
password: newUser.password,
|
|
||||||
roleid: Number(newUser.roleid),
|
|
||||||
tenantid: FIESTA_TENANT_ID,
|
|
||||||
});
|
|
||||||
setShowAddUserModal(false);
|
|
||||||
setNewUser({ firstname: '', lastname: '', email: '', contactno: '', password: '', roleid: 4 });
|
|
||||||
alert(`User "${newUser.firstname}" created successfully and synced to the live Users directory.`);
|
|
||||||
} catch (err) {
|
|
||||||
alert(`Could not create user: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calendar Event Modal state
|
// Calendar Event Modal state
|
||||||
const [showCalendarModal, setShowCalendarModal] = useState(false);
|
const [showCalendarModal, setShowCalendarModal] = useState(false);
|
||||||
|
|
||||||
@@ -550,141 +470,6 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'users':
|
|
||||||
return (
|
|
||||||
<div className="space-y-lg animate-in fade-in duration-300">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-md border-b border-[#e2e8f0] pb-xl">
|
|
||||||
<div>
|
|
||||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
|
|
||||||
Users & Access
|
|
||||||
</h1>
|
|
||||||
<p className="text-zinc-500 font-sans text-xs mt-1">
|
|
||||||
Tenant staff accounts, roles, assigned shifts, and account status — live from the Users API.
|
|
||||||
</p>
|
|
||||||
<div className="mt-1.5">
|
|
||||||
{usersQ.isLoading ? (
|
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 uppercase tracking-wide">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading live users…
|
|
||||||
</span>
|
|
||||||
) : usersQ.isError ? (
|
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-rose-600 uppercase tracking-wide">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" /> Live data unavailable
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 uppercase tracking-wide">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {users.length} users
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddUserModal(true)}
|
|
||||||
className="bg-[#581c87] text-white px-xl py-2.5 rounded-lg text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-xs cursor-pointer hover:bg-purple-800 transition"
|
|
||||||
>
|
|
||||||
Add User
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Role filter pills */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setUserRoleFilter('ALL')}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold border transition-all cursor-pointer ${
|
|
||||||
userRoleFilter === 'ALL'
|
|
||||||
? 'bg-[#581c87] text-white border-[#581c87] shadow-sm'
|
|
||||||
: 'bg-white text-zinc-700 border-[#e2e8f0] hover:bg-zinc-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
All Roles
|
|
||||||
</button>
|
|
||||||
{roleOptions.map((rid) => (
|
|
||||||
<button
|
|
||||||
key={rid}
|
|
||||||
onClick={() => setUserRoleFilter(rid)}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold border transition-all cursor-pointer ${
|
|
||||||
userRoleFilter === rid
|
|
||||||
? 'bg-[#581c87] text-white border-[#581c87] shadow-sm'
|
|
||||||
: 'bg-white text-zinc-700 border-[#e2e8f0] hover:bg-zinc-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{roleName(rid)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Users table */}
|
|
||||||
<div className="bg-white border border-[#e2e8f0] rounded-xl overflow-hidden shadow-sm">
|
|
||||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center">
|
|
||||||
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
|
|
||||||
Directory ({filteredUsers.length})
|
|
||||||
</h4>
|
|
||||||
<span className="text-[10px] text-zinc-400 font-medium uppercase tracking-wider">Tenant {FIESTA_TENANT_ID}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto text-xs font-sans">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead className="bg-[#f8fafc] border-b border-[#e2e8f0] text-zinc-500 text-[10px] uppercase font-bold tracking-wider">
|
|
||||||
<tr>
|
|
||||||
<th className="px-md py-sm">User</th>
|
|
||||||
<th className="px-md py-sm">Role</th>
|
|
||||||
<th className="px-md py-sm">Contact</th>
|
|
||||||
<th className="px-md py-sm">Shift</th>
|
|
||||||
<th className="px-md py-sm">Location</th>
|
|
||||||
<th className="px-md py-sm text-right">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-[#f1f5f9]">
|
|
||||||
{filteredUsers.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="text-center py-10 text-zinc-400">
|
|
||||||
{usersQ.isLoading ? 'Loading live users…' : 'No users match this filter.'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
filteredUsers.map((u) => (
|
|
||||||
<tr key={u.userid} className="hover:bg-[#f2f4f6]/50 transition-colors">
|
|
||||||
<td className="px-md py-md">
|
|
||||||
<div className="flex items-center gap-sm">
|
|
||||||
<img
|
|
||||||
src={u.avatar}
|
|
||||||
alt={u.name}
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
className="w-9 h-9 object-cover rounded-full border border-zinc-200 shrink-0"
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-bold text-[#0f172a] truncate">{u.name}</p>
|
|
||||||
<p className="text-[10px] text-zinc-400 font-medium truncate">{u.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-md py-md">
|
|
||||||
<span className="px-2 py-0.5 rounded text-[9px] font-bold uppercase bg-purple-50 text-[#581c87] border border-purple-100">
|
|
||||||
{u.role}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-md py-md font-mono text-zinc-600 font-medium">{u.contact}</td>
|
|
||||||
<td className="px-md py-md text-zinc-500 font-medium">{u.shift}</td>
|
|
||||||
<td className="px-md py-md text-zinc-500 font-medium">{u.location}</td>
|
|
||||||
<td className="px-md py-md text-right">
|
|
||||||
<span className={`px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
|
|
||||||
u.status.toLowerCase() === 'active'
|
|
||||||
? 'text-emerald-700 bg-emerald-100'
|
|
||||||
: 'text-zinc-500 bg-zinc-200'
|
|
||||||
}`}>
|
|
||||||
{u.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return <SettingsView tenantId={FIESTA_TENANT_ID} />;
|
return <SettingsView tenantId={FIESTA_TENANT_ID} />;
|
||||||
|
|
||||||
@@ -742,8 +527,8 @@ export default function App() {
|
|||||||
<ReportsView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} />
|
<ReportsView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Handle alternative sections: Stores, Logistics, Staffing, Settings */}
|
{/* Handle alternative sections: Stores, Settings */}
|
||||||
{['stores', 'users', 'settings'].includes(currentSection) &&
|
{['stores', 'settings'].includes(currentSection) &&
|
||||||
renderSecondarySection()
|
renderSecondarySection()
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -900,130 +685,6 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CREATE NEW USER MODAL */}
|
|
||||||
{showAddUserModal && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md"
|
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) setShowAddUserModal(false); }}
|
|
||||||
>
|
|
||||||
<div className="bg-white border border-[#e2e8f0] rounded-xl w-full max-w-[26rem] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-xs font-sans cursor-default">
|
|
||||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center shrink-0">
|
|
||||||
<h4 className="font-bold text-[#0f172a] flex items-center gap-xs">
|
|
||||||
<Users size={15} className="text-[#581c87]" />
|
|
||||||
Create User Account
|
|
||||||
</h4>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddUserModal(false)}
|
|
||||||
className="p-1 hover:bg-zinc-200 rounded-full text-zinc-400 cursor-pointer transition-colors"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleCreateUser} className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
|
||||||
<div className="p-md space-y-md overflow-y-auto flex-1">
|
|
||||||
<p className="text-zinc-500 leading-relaxed">
|
|
||||||
Creates a real user against the live Users API for tenant {FIESTA_TENANT_ID}.
|
|
||||||
</p>
|
|
||||||
<div className="space-y-sm">
|
|
||||||
<div className="grid grid-cols-2 gap-sm">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">FIRST NAME (*)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. Harini"
|
|
||||||
value={newUser.firstname}
|
|
||||||
onChange={(e) => setNewUser({ ...newUser, firstname: e.target.value })}
|
|
||||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">LAST NAME</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. Rajan"
|
|
||||||
value={newUser.lastname}
|
|
||||||
onChange={(e) => setNewUser({ ...newUser, lastname: e.target.value })}
|
|
||||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">EMAIL (*)</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
placeholder="e.g. harini@store.com"
|
|
||||||
value={newUser.email}
|
|
||||||
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
|
|
||||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-sm">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">CONTACT NO (*)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="9988776655"
|
|
||||||
value={newUser.contactno}
|
|
||||||
onChange={(e) => setNewUser({ ...newUser, contactno: e.target.value })}
|
|
||||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">ROLE</label>
|
|
||||||
<select
|
|
||||||
value={newUser.roleid}
|
|
||||||
onChange={(e) => setNewUser({ ...newUser, roleid: Number(e.target.value) })}
|
|
||||||
className="w-full border border-[#e2e8f0] rounded-lg p-2 bg-[#f8fafc] focus:bg-white outline-none"
|
|
||||||
>
|
|
||||||
<option value={1}>Owner</option>
|
|
||||||
<option value={2}>Manager</option>
|
|
||||||
<option value={3}>Admin</option>
|
|
||||||
<option value={4}>Staff</option>
|
|
||||||
<option value={6}>Cashier</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">TEMPORARY PASSWORD (*)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Set an initial password"
|
|
||||||
value={newUser.password}
|
|
||||||
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
|
|
||||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87] font-mono"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-md border-t border-[#f1f5f9] flex justify-end gap-sm bg-[#f8fafc] shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAddUserModal(false)}
|
|
||||||
className="px-4 py-2 border border-[#e2e8f0] rounded-lg font-semibold text-zinc-500 hover:bg-zinc-50 cursor-pointer"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={createUserMut.isPending}
|
|
||||||
className="px-4 py-2 bg-[#581c87] text-white rounded-lg font-bold hover:bg-purple-800 cursor-pointer shadow-sm disabled:opacity-60 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{createUserMut.isPending ? 'Creating…' : 'Create User'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,16 +45,23 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) {
|
|||||||
const s = summaryQ.data;
|
const s = summaryQ.data;
|
||||||
const tenantName = str(tenantQ.data?.tenantname) || s?.tenantname || `Tenant ${DEFAULT_TENANT_ID}`;
|
const tenantName = str(tenantQ.data?.tenantname) || s?.tenantname || `Tenant ${DEFAULT_TENANT_ID}`;
|
||||||
|
|
||||||
// Profit comes from the live invoice/financial insight. When the tenant has no
|
// Revenue + profit come from the live invoice/financial insight. The endpoint
|
||||||
// invoice records we show "—" rather than a misleading ₹0.
|
// returns two distinct figures (revenue and profit); we surface both rather than
|
||||||
|
// repeating one. When the tenant has no invoice records we show "—" instead of a
|
||||||
|
// misleading ₹0.
|
||||||
const insight = insightQ.data;
|
const insight = insightQ.data;
|
||||||
const money = (v: number | null) => (v == null ? '—' : `₹${Math.round(v).toLocaleString('en-IN')}`);
|
const money = (v: number | null) => (v == null ? '—' : `₹${Math.round(v).toLocaleString('en-IN')}`);
|
||||||
const todaysProfit = insight ? insight.profit : null;
|
const monthlyRevenue = insight ? insight.revenue : null;
|
||||||
const monthlyProfit = insight ? insight.profit : null;
|
const monthlyProfit = insight ? insight.profit : null;
|
||||||
|
|
||||||
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
||||||
const summaries = locSummaryQ.data ?? [];
|
const summaries = locSummaryQ.data ?? [];
|
||||||
|
|
||||||
|
// Region fulfillment — live month-to-date delivered ÷ total orders for the tenant.
|
||||||
|
const ordersTotal = s?.total ?? 0;
|
||||||
|
const ordersDelivered = s?.delivered ?? 0;
|
||||||
|
const regionFulfillmentPct = ordersTotal > 0 ? (ordersDelivered / ordersTotal) * 100 : null;
|
||||||
|
|
||||||
const locations = (locationsQ.data ?? []).filter((loc) => {
|
const locations = (locationsQ.data ?? []).filter((loc) => {
|
||||||
if (!searchQuery) return true;
|
if (!searchQuery) return true;
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
@@ -75,8 +82,8 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) {
|
|||||||
|
|
||||||
const kpis = [
|
const kpis = [
|
||||||
{ title: 'ACTIVE OUTLETS', display: `${activeStoresCount} / ${totalStoresCount}`, icon: Store, chip: 'bg-purple-50 text-[#581c87]', loading: locationsQ.isLoading },
|
{ title: 'ACTIVE OUTLETS', display: `${activeStoresCount} / ${totalStoresCount}`, icon: Store, chip: 'bg-purple-50 text-[#581c87]', loading: locationsQ.isLoading },
|
||||||
{ title: 'REGION FULFILLMENT', display: '98.2%', icon: Sparkles, chip: 'bg-emerald-50 text-emerald-600', loading: false },
|
{ title: 'REGION FULFILLMENT', display: regionFulfillmentPct == null ? '—' : `${regionFulfillmentPct.toFixed(1)}%`, icon: Sparkles, chip: 'bg-emerald-50 text-emerald-600', loading: summaryQ.isLoading },
|
||||||
{ title: "TODAY'S PROFIT", display: money(todaysProfit), icon: Wallet, chip: 'bg-sky-50 text-sky-600', loading: insightQ.isLoading },
|
{ title: 'MONTHLY REVENUE', display: money(monthlyRevenue), icon: Wallet, chip: 'bg-sky-50 text-sky-600', loading: insightQ.isLoading },
|
||||||
{ title: 'MONTHLY PROFIT', display: money(monthlyProfit), icon: TrendingUp, chip: 'bg-emerald-50 text-emerald-600', loading: insightQ.isLoading },
|
{ title: 'MONTHLY PROFIT', display: money(monthlyProfit), icon: TrendingUp, chip: 'bg-emerald-50 text-emerald-600', loading: insightQ.isLoading },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Layers,
|
Layers,
|
||||||
Search,
|
Search,
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
TrendingDown,
|
TrendingDown,
|
||||||
Trash2,
|
Trash2,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
|
ShieldCheck,
|
||||||
Zap,
|
Zap,
|
||||||
Tag,
|
Tag,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
@@ -27,12 +28,15 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
X
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ProductMatrixItem, InventoryItem, ImportLog } from '../types';
|
import { ProductMatrixItem, ImportLog } from '../types';
|
||||||
import { initialImportLogs } from '../data';
|
import { initialImportLogs } from '../data';
|
||||||
import { useFiestaStockStatement, useFiestaTenantLocations } from '../services/fiestaQueries';
|
import { useFiestaTenantLocations, useFiestaStoresStock } from '../services/fiestaQueries';
|
||||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi';
|
||||||
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
||||||
|
|
||||||
|
type StockRow = Record<string, unknown>;
|
||||||
|
const rowId = (r: StockRow) => String(r.productid ?? '') || String(r.productname ?? '');
|
||||||
|
|
||||||
interface InventoryViewProps {
|
interface InventoryViewProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
isCoimbatoreView: boolean;
|
isCoimbatoreView: boolean;
|
||||||
@@ -42,42 +46,51 @@ export default function InventoryView({
|
|||||||
searchQuery,
|
searchQuery,
|
||||||
isCoimbatoreView
|
isCoimbatoreView
|
||||||
}: InventoryViewProps) {
|
}: InventoryViewProps) {
|
||||||
// ── Live stock data (Fiesta) ─────────────────────────────────────────────
|
// ── Live stock across every outlet (Fiesta) ───────────────────────────────
|
||||||
// The catalog grid and the hub-balance ledger are both derived from the live
|
// This page is the admin's command surface. The GLOBAL CATALOG is the deduped
|
||||||
// stock statement for the tenant's primary outlet. We seed local state from
|
// union of products across all outlets the tenant owns (admin-only import adds
|
||||||
// it once it loads so the existing add / CSV / replenish interactions keep
|
// to it); the STORE STOCK section shows each outlet's live stock so the admin
|
||||||
// mutating in-session without losing the live baseline.
|
// can see all the stores under them at a glance.
|
||||||
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
|
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
|
||||||
const primaryLocation =
|
const locations = useMemo(
|
||||||
(locationsQ.data ?? []).find((l) => Number(l.locationid) === FIESTA_PRIMARY_LOCATION_ID) ||
|
() =>
|
||||||
(locationsQ.data ?? [])[0];
|
(locationsQ.data ?? []).map((l) => ({
|
||||||
const locationId = primaryLocation ? Number(primaryLocation.locationid) : FIESTA_PRIMARY_LOCATION_ID;
|
locationid: Number(l.locationid),
|
||||||
const locationName = fstr(primaryLocation?.locationname) || 'Primary Outlet';
|
locationname: fstr(l.locationname) || `Outlet ${fstr(l.locationid)}`,
|
||||||
|
status: fstr(l.status) || 'Active',
|
||||||
|
})),
|
||||||
|
[locationsQ.data],
|
||||||
|
);
|
||||||
|
|
||||||
const stockQ = useFiestaStockStatement({
|
const storesStock = useFiestaStoresStock(
|
||||||
tenantid: FIESTA_TENANT_ID,
|
FIESTA_TENANT_ID,
|
||||||
locationid: locationId,
|
locations.map(({ locationid, locationname }) => ({ locationid, locationname })),
|
||||||
keyword: '',
|
);
|
||||||
pageno: 1,
|
const storesLoading = locationsQ.isLoading || storesStock.some((s) => s.isLoading);
|
||||||
pagesize: 100,
|
const storesError =
|
||||||
});
|
locationsQ.isError || (storesStock.length > 0 && storesStock.every((s) => s.isError));
|
||||||
|
|
||||||
|
// Global catalog = deduped union of every outlet's products, plus anything the
|
||||||
|
// admin adds/imports in-session. Seeded once from the live data.
|
||||||
const [products, setProducts] = useState<ProductMatrixItem[]>([]);
|
const [products, setProducts] = useState<ProductMatrixItem[]>([]);
|
||||||
const [inventory, setInventory] = useState<InventoryItem[]>([]);
|
|
||||||
const [importLogs, setImportLogs] = useState<ImportLog[]>(initialImportLogs);
|
const [importLogs, setImportLogs] = useState<ImportLog[]>(initialImportLogs);
|
||||||
|
const [seeded, setSeeded] = useState(false);
|
||||||
|
|
||||||
|
const allStoreRows = storesStock.flatMap((s) => s.rows);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stockQ.data) {
|
if (seeded || allStoreRows.length === 0) return;
|
||||||
setProducts(stockQ.data.map(stockRowToProduct));
|
const byId = new Map<string, StockRow>();
|
||||||
setInventory(stockQ.data.map((r) => stockRowToInventory(r, locationName)));
|
allStoreRows.forEach((r) => {
|
||||||
}
|
const id = rowId(r);
|
||||||
// locationName is derived from the same query chain; safe to depend on data.
|
if (id && !byId.has(id)) byId.set(id, r);
|
||||||
}, [stockQ.data, locationName]);
|
});
|
||||||
|
setProducts(Array.from(byId.values()).map(stockRowToProduct));
|
||||||
|
setSeeded(true);
|
||||||
|
}, [allStoreRows, seeded]);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'catalog' | 'import_branding'>('catalog');
|
const [activeTab, setActiveTab] = useState<'catalog' | 'import_branding'>('catalog');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('ALL');
|
const [selectedCategory, setSelectedCategory] = useState<string>('ALL');
|
||||||
const [showAddProductModal, setShowAddProductModal] = useState(false);
|
const [showAddProductModal, setShowAddProductModal] = useState(false);
|
||||||
const [replenishmentList, setReplenishmentList] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// CSV Textarea input
|
// CSV Textarea input
|
||||||
const [csvText, setCsvText] = useState(
|
const [csvText, setCsvText] = useState(
|
||||||
@@ -109,28 +122,6 @@ export default function InventoryView({
|
|||||||
products.forEach((p) => categorySet.add(p.category));
|
products.forEach((p) => categorySet.add(p.category));
|
||||||
const categories: string[] = ['ALL', ...Array.from(categorySet)];
|
const categories: string[] = ['ALL', ...Array.from(categorySet)];
|
||||||
|
|
||||||
// Handle SKU quantity change
|
|
||||||
const handleUpdateStock = (sku: string, delta: number) => {
|
|
||||||
setInventory(prev => prev.map(item => {
|
|
||||||
if (item.sku === sku) {
|
|
||||||
const newLevel = Math.max(0, item.stockLevel + delta);
|
|
||||||
const status = newLevel < 25 ? 'Critical' : newLevel < 120 ? 'Low Stock' : 'Optimal';
|
|
||||||
return { ...item, stockLevel: newLevel, status };
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Trigger quick reorder recommendation
|
|
||||||
const handleReplenishSku = (sku: string) => {
|
|
||||||
if (replenishmentList.includes(sku)) return;
|
|
||||||
setReplenishmentList(prev => [...prev, sku]);
|
|
||||||
handleUpdateStock(sku, 500); // Add 500 units to stock
|
|
||||||
setTimeout(() => {
|
|
||||||
alert(`Auto-Replenish complete! 500 units ordered and allocated directly to corresponding hub for SKU ${sku}`);
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter criteria
|
// Filter criteria
|
||||||
const filteredProducts = products.filter(p => {
|
const filteredProducts = products.filter(p => {
|
||||||
const matchesSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
const matchesSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
@@ -161,20 +152,9 @@ export default function InventoryView({
|
|||||||
verified: true
|
verified: true
|
||||||
};
|
};
|
||||||
|
|
||||||
const createdInv: InventoryItem = {
|
|
||||||
sku: newProduct.sku,
|
|
||||||
name: newProduct.name,
|
|
||||||
warehouse: 'RS Puram Hub (CBE-01)',
|
|
||||||
stockLevel: newProduct.initialStock,
|
|
||||||
maxCapacity: 1000,
|
|
||||||
status: 'Optimal',
|
|
||||||
region: 'CBE-NORTH'
|
|
||||||
};
|
|
||||||
|
|
||||||
setProducts([createdProd, ...products]);
|
setProducts([createdProd, ...products]);
|
||||||
setInventory([createdInv, ...inventory]);
|
|
||||||
setShowAddProductModal(false);
|
setShowAddProductModal(false);
|
||||||
alert(`Fresh product "${createdProd.name}" incorporated into Master Grocery Catalog and standard ledger!`);
|
alert(`Fresh product "${createdProd.name}" added to the Global Catalog. It is now available to roll out to all outlets.`);
|
||||||
|
|
||||||
setNewProduct({
|
setNewProduct({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -196,7 +176,6 @@ export default function InventoryView({
|
|||||||
|
|
||||||
let parsedCount = 0;
|
let parsedCount = 0;
|
||||||
const newProds: ProductMatrixItem[] = [];
|
const newProds: ProductMatrixItem[] = [];
|
||||||
const newInvs: InventoryItem[] = [];
|
|
||||||
|
|
||||||
lines.forEach(line => {
|
lines.forEach(line => {
|
||||||
const parts = line.split(',').map(p => p.trim());
|
const parts = line.split(',').map(p => p.trim());
|
||||||
@@ -204,8 +183,6 @@ export default function InventoryView({
|
|||||||
const name = parts[0];
|
const name = parts[0];
|
||||||
const sku = parts[1];
|
const sku = parts[1];
|
||||||
const category = parts[2] || 'Staples / Rice';
|
const category = parts[2] || 'Staples / Rice';
|
||||||
const price = Number(parts[3]) || 120;
|
|
||||||
const initialStock = Number(parts[4]) || 150;
|
|
||||||
|
|
||||||
if (!products.some(p => p.sku === sku)) {
|
if (!products.some(p => p.sku === sku)) {
|
||||||
newProds.push({
|
newProds.push({
|
||||||
@@ -221,16 +198,6 @@ export default function InventoryView({
|
|||||||
exposure: 'All Outlets',
|
exposure: 'All Outlets',
|
||||||
verified: true
|
verified: true
|
||||||
});
|
});
|
||||||
|
|
||||||
newInvs.push({
|
|
||||||
sku,
|
|
||||||
name,
|
|
||||||
warehouse: 'RS Puram Hub (CBE-01)',
|
|
||||||
stockLevel: initialStock,
|
|
||||||
maxCapacity: 1000,
|
|
||||||
status: 'Optimal',
|
|
||||||
region: 'CBE-NORTH'
|
|
||||||
});
|
|
||||||
parsedCount++;
|
parsedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,7 +205,6 @@ export default function InventoryView({
|
|||||||
|
|
||||||
if (parsedCount > 0) {
|
if (parsedCount > 0) {
|
||||||
setProducts(prev => [...newProds, ...prev]);
|
setProducts(prev => [...newProds, ...prev]);
|
||||||
setInventory(prev => [...newInvs, ...prev]);
|
|
||||||
|
|
||||||
const logEntry: ImportLog = {
|
const logEntry: ImportLog = {
|
||||||
timestamp: new Date().toLocaleTimeString() + ' (IST)',
|
timestamp: new Date().toLocaleTimeString() + ' (IST)',
|
||||||
@@ -259,7 +225,6 @@ export default function InventoryView({
|
|||||||
const handleImportPreset = (presetName: string, itemsList: Array<{name: string, sku: string, cat: string, price: number, stock: number, img: string}>) => {
|
const handleImportPreset = (presetName: string, itemsList: Array<{name: string, sku: string, cat: string, price: number, stock: number, img: string}>) => {
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
const newProds: ProductMatrixItem[] = [];
|
const newProds: ProductMatrixItem[] = [];
|
||||||
const newInvs: InventoryItem[] = [];
|
|
||||||
|
|
||||||
itemsList.forEach((itm) => {
|
itemsList.forEach((itm) => {
|
||||||
if (!products.some(p => p.sku === itm.sku)) {
|
if (!products.some(p => p.sku === itm.sku)) {
|
||||||
@@ -276,23 +241,12 @@ export default function InventoryView({
|
|||||||
exposure: 'All Outlets',
|
exposure: 'All Outlets',
|
||||||
verified: true
|
verified: true
|
||||||
});
|
});
|
||||||
|
|
||||||
newInvs.push({
|
|
||||||
sku: itm.sku,
|
|
||||||
name: itm.name,
|
|
||||||
warehouse: 'Peelamedu Sort Center',
|
|
||||||
stockLevel: itm.stock,
|
|
||||||
maxCapacity: 800,
|
|
||||||
status: 'Optimal',
|
|
||||||
region: 'CBE-EAST'
|
|
||||||
});
|
|
||||||
imported++;
|
imported++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (imported > 0) {
|
if (imported > 0) {
|
||||||
setProducts(prev => [...newProds, ...prev]);
|
setProducts(prev => [...newProds, ...prev]);
|
||||||
setInventory(prev => [...newInvs, ...prev]);
|
|
||||||
|
|
||||||
const logEntry: ImportLog = {
|
const logEntry: ImportLog = {
|
||||||
timestamp: new Date().toLocaleTimeString() + ' (IST)',
|
timestamp: new Date().toLocaleTimeString() + ' (IST)',
|
||||||
@@ -331,23 +285,23 @@ export default function InventoryView({
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a] flex items-center gap-xs">
|
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a] flex items-center gap-xs">
|
||||||
<Layers className="text-[#581c87]" size={24} />
|
<Layers className="text-[#581c87]" size={24} />
|
||||||
Coimbatore Grocery Assortment & Catalogue Studio
|
Product Catalog · Global Assortment
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-zinc-500 font-sans text-xs mt-1">
|
<p className="text-zinc-500 font-sans text-xs mt-1">
|
||||||
Build regional catalogues, update localized stock balances, parse batch imports, and style brand bag templates.
|
The master product catalog for all your outlets. Import products into the global catalog and monitor live stock across every store under you.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-1.5">
|
<div className="mt-1.5">
|
||||||
{stockQ.isLoading ? (
|
{storesLoading ? (
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 uppercase tracking-wide">
|
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 uppercase tracking-wide">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading live stock…
|
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading live stock across outlets…
|
||||||
</span>
|
</span>
|
||||||
) : stockQ.isError ? (
|
) : storesError ? (
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-rose-600 uppercase tracking-wide">
|
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-rose-600 uppercase tracking-wide">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" /> Live data unavailable
|
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" /> Live data unavailable
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 uppercase tracking-wide">
|
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 uppercase tracking-wide">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {locationName} · {products.length} SKUs
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {locations.length} outlet{locations.length === 1 ? '' : 's'} · {products.length} catalog SKUs
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -362,7 +316,7 @@ export default function InventoryView({
|
|||||||
: 'bg-white hover:bg-zinc-50 text-zinc-700 border border-[#e2e8f0]'
|
: 'bg-white hover:bg-zinc-50 text-zinc-700 border border-[#e2e8f0]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
🌾 Catalog Grid & Ledger
|
🌐 Global Catalog & Stocks
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -380,7 +334,23 @@ export default function InventoryView({
|
|||||||
|
|
||||||
{activeTab === 'catalog' ? (
|
{activeTab === 'catalog' ? (
|
||||||
<>
|
<>
|
||||||
{/* Quick Category Tab Filter Row */}
|
{/* Admin access banner */}
|
||||||
|
<div className="bg-[#faf5ff] border border-purple-100 rounded-xl p-md flex flex-col sm:flex-row sm:items-center justify-between gap-sm">
|
||||||
|
<div className="flex items-start gap-sm">
|
||||||
|
<ShieldCheck size={16} className="text-[#581c87] shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-sans text-xs text-zinc-700 font-semibold">Global Catalog — Admin access</p>
|
||||||
|
<p className="text-[11px] text-zinc-500 mt-0.5 leading-relaxed">
|
||||||
|
As an admin you can import products into the global catalog. Store managers see it read-only. The stock below is live across every outlet under you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 self-start sm:self-center text-[9px] font-bold uppercase tracking-wider bg-[#581c87] text-white px-2 py-1 rounded">
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category filter + admin import actions */}
|
||||||
<div className="flex flex-wrap gap-2 py-1 items-center justify-between">
|
<div className="flex flex-wrap gap-2 py-1 items-center justify-between">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
@@ -393,11 +363,19 @@ export default function InventoryView({
|
|||||||
: 'bg-white text-zinc-700 border-[#e2e8f0] hover:bg-zinc-50'
|
: 'bg-white text-zinc-700 border-[#e2e8f0] hover:bg-zinc-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{cat === 'ALL' ? '🌾 All Catalog Items' : cat.replace('Groceries / ', '').replace('Staples / ', '').replace('Beverages / ', '').replace('Fresh Produce / ', '')}
|
{cat === 'ALL' ? '🌐 All Catalog Items' : cat.replace('Groceries / ', '').replace('Staples / ', '').replace('Beverages / ', '').replace('Fresh Produce / ', '')}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('import_branding')}
|
||||||
|
className="bg-white text-[#581c87] border border-purple-200 px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-xs cursor-pointer hover:bg-[#faf5ff] transition"
|
||||||
|
>
|
||||||
|
<UploadCloud size={14} />
|
||||||
|
Import to Global Catalog
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddProductModal(true)}
|
onClick={() => setShowAddProductModal(true)}
|
||||||
className="bg-[#581c87] text-white px-xl py-2 rounded-lg text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-xs cursor-pointer hover:bg-purple-800 transition shadow-sm"
|
className="bg-[#581c87] text-white px-xl py-2 rounded-lg text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-xs cursor-pointer hover:bg-purple-800 transition shadow-sm"
|
||||||
@@ -406,40 +384,41 @@ export default function InventoryView({
|
|||||||
Add Manual SKU
|
Add Manual SKU
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Multi-Pane Layout: Left Catalog Grid, Right Stock balances */}
|
{/* Global Catalog — master assortment grid (full width) */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter text-xs font-sans">
|
<div className="bg-[#f8fafc]/50 border border-[#e2e8f0] p-md rounded-xl text-xs font-sans">
|
||||||
|
<div className="flex items-center justify-between mb-xs">
|
||||||
|
<h3 className="font-sans font-bold text-sm text-[#0f172a]">Global Product Catalog</h3>
|
||||||
|
<span className="text-[10px] text-[#581c87] font-bold bg-purple-50 px-2 py-0.5 rounded border border-purple-100">
|
||||||
|
{filteredProducts.length} item{filteredProducts.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-zinc-500 font-normal mb-md leading-relaxed text-[11px]">
|
||||||
|
Master assortment available to roll out to every outlet — imported by the admin and synced to the customer booking apps.
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Left Grid: Grocery Catalogue Items Showcase */}
|
{storesLoading && products.length === 0 ? (
|
||||||
<div className="lg:col-span-2 space-y-md">
|
<div className="text-center py-xl text-zinc-400 text-xs">Loading global catalog…</div>
|
||||||
<div className="bg-[#f8fafc]/50 border border-[#e2e8f0] p-md rounded-xl">
|
) : filteredProducts.length === 0 ? (
|
||||||
<h3 className="font-sans font-bold text-sm text-[#0f172a] mb-xs">Active Assortment Items</h3>
|
<div className="text-center py-xl text-zinc-400 text-xs">No catalog products match your search or category.</div>
|
||||||
<p className="text-zinc-500 font-normal mb-md leading-relaxed text-[11px]">Primary catalog schema synchronized on customer booking apps. Total: {filteredProducts.length} items</p>
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-sm">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-sm">
|
{filteredProducts.map((prod) => (
|
||||||
{filteredProducts.map((prod) => {
|
|
||||||
return (
|
|
||||||
<div key={prod.id} className="bg-white border border-[#e2e8f0] rounded-xl overflow-hidden p-md flex gap-md shadow-sm hover:shadow-md transition-shadow relative">
|
<div key={prod.id} className="bg-white border border-[#e2e8f0] rounded-xl overflow-hidden p-md flex gap-md shadow-sm hover:shadow-md transition-shadow relative">
|
||||||
<div className="w-16 h-16 rounded-xl border border-zinc-100 shrink-0 overflow-hidden bg-zinc-50">
|
<div className="w-16 h-16 rounded-xl border border-zinc-100 shrink-0 overflow-hidden bg-zinc-50">
|
||||||
<img
|
<img src={prod.image} alt={prod.name} referrerPolicy="no-referrer" className="w-full h-full object-cover" />
|
||||||
src={prod.image}
|
|
||||||
alt={prod.name}
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 space-y-1 min-w-0">
|
||||||
<div className="flex-1 space-y-1">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex items-start justify-between">
|
<div className="min-w-0">
|
||||||
<div>
|
<h4 className="font-bold text-zinc-900 leading-tight text-xs truncate">{prod.name}</h4>
|
||||||
<h4 className="font-bold text-zinc-900 leading-tight text-xs">{prod.name}</h4>
|
|
||||||
<span className="text-[10px] text-zinc-400 font-bold tracking-tight">{prod.sku}</span>
|
<span className="text-[10px] text-zinc-400 font-bold tracking-tight">{prod.sku}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase bg-purple-50 text-purple-700">
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase bg-purple-50 text-purple-700 shrink-0">
|
||||||
{prod.category.split(' / ')[0]}
|
{prod.category.split(' / ')[0]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center pt-2">
|
<div className="flex justify-between items-center pt-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-bold">Sold (Units)</span>
|
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-bold">Sold (Units)</span>
|
||||||
@@ -452,92 +431,105 @@ export default function InventoryView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Pane: Stock level adjustment ledgers */}
|
{/* Store Stock — live per-outlet breakdown for every store under the admin */}
|
||||||
<div className="space-y-md">
|
<div className="space-y-md text-xs font-sans">
|
||||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm space-y-md">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-sans font-bold text-sm text-[#0f172a]">Hub Balances Ledger</h3>
|
<h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-2">
|
||||||
<p className="text-zinc-500 font-normal leading-relaxed text-[11px] mt-0.5">Physical checkout balances across localized Coimbatore warehouse locations.</p>
|
<PackageCheck size={16} className="text-[#581c87]" /> Store Stock · All Outlets Under You
|
||||||
|
</h3>
|
||||||
|
<p className="text-zinc-500 text-[11px] mt-0.5">Live on-hand balances for each store you manage.</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[#581c87] font-bold bg-purple-50 px-2 py-0.5 rounded border border-purple-100">
|
||||||
|
{locations.length} store{locations.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-[#f1f5f9] select-none">
|
{locations.length === 0 ? (
|
||||||
{inventory.map((item, idx) => {
|
<div className="text-center py-xl text-zinc-400 text-xs border border-dashed border-[#e2e8f0] rounded-xl bg-white">
|
||||||
const percentage = (item.stockLevel / item.maxCapacity) * 100;
|
{locationsQ.isLoading ? 'Loading outlets…' : 'No outlets found under this tenant.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-gutter">
|
||||||
|
{storesStock.map((store) => {
|
||||||
|
const items = store.rows
|
||||||
|
.map((r) => stockRowToInventory(r, store.locationname))
|
||||||
|
.filter((it) => !searchQuery || it.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||||
|
const totalUnits = items.reduce((a, it) => a + it.stockLevel, 0);
|
||||||
|
const lowCount = items.filter((it) => it.status !== 'Optimal').length;
|
||||||
|
const meta = locations.find((l) => l.locationid === store.locationid);
|
||||||
|
const status = meta?.status ?? 'Active';
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="py-md space-y-xs">
|
<div key={store.locationid} className="bg-white border border-[#e2e8f0] rounded-xl shadow-sm overflow-hidden flex flex-col">
|
||||||
<div className="flex justify-between items-start">
|
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc]">
|
||||||
<div>
|
<div className="flex justify-between items-start gap-2">
|
||||||
<p className="font-bold text-[#0f172a]">{item.name}</p>
|
<div className="min-w-0">
|
||||||
<p className="text-[10px] text-zinc-400 mt-1 font-medium">{item.warehouse}</p>
|
<p className="font-bold text-[#0f172a] truncate">{store.locationname}</p>
|
||||||
<div className="flex gap-px pt-1 items-center">
|
<p className="text-[10px] text-zinc-400 font-medium mt-0.5">
|
||||||
<span className="bg-[#f1f5f9] px-1 py-0.5 rounded text-[8px] font-bold text-zinc-500 font-mono tracking-tight mr-1">{item.region}</span>
|
{store.isLoading ? 'Syncing…' : `${items.length} SKUs · ${totalUnits.toLocaleString('en-IN')} units on hand`}
|
||||||
<span className={`px-1.5 py-0.5 rounded text-[8px] font-bold tracking-wide uppercase ${
|
</p>
|
||||||
item.status === 'Critical' ? 'bg-rose-50 text-rose-600 border border-rose-100 animate-pulse' : item.status === 'Low Stock' ? 'bg-amber-50 text-amber-600' : 'bg-emerald-50 text-emerald-600'
|
</div>
|
||||||
|
<span className={`shrink-0 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
|
||||||
|
status.toLowerCase() === 'active'
|
||||||
|
? 'text-emerald-600 bg-emerald-50 border border-emerald-100'
|
||||||
|
: 'text-zinc-500 bg-zinc-100'
|
||||||
}`}>
|
}`}>
|
||||||
● {item.status}
|
{status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{lowCount > 0 && !store.isLoading && (
|
||||||
|
<p className="text-[10px] text-amber-600 font-semibold mt-1.5 flex items-center gap-1">
|
||||||
|
<AlertTriangle size={11} /> {lowCount} low / critical SKU{lowCount === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-[#f1f5f9] max-h-72 overflow-y-auto">
|
||||||
|
{store.isLoading ? (
|
||||||
|
<div className="p-lg text-center text-zinc-400 text-[11px]">Loading store stock…</div>
|
||||||
|
) : store.isError ? (
|
||||||
|
<div className="p-lg text-center text-rose-500 text-[11px]">Couldn't load this store's stock.</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="p-lg text-center text-zinc-400 text-[11px]">No stock items{searchQuery ? ' match your search' : ''}.</div>
|
||||||
|
) : (
|
||||||
|
items.map((it, idx) => {
|
||||||
|
const pct = Math.min(100, (it.stockLevel / it.maxCapacity) * 100);
|
||||||
|
return (
|
||||||
|
<div key={idx} className="p-sm">
|
||||||
|
<div className="flex justify-between items-start gap-2">
|
||||||
|
<p className="font-semibold text-zinc-800 text-[11px] leading-tight min-w-0 truncate">{it.name}</p>
|
||||||
|
<span className="font-mono font-bold text-[#0f172a] text-[11px] shrink-0">{it.stockLevel.toLocaleString('en-IN')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<div className="flex-1 bg-[#eceef0] h-1.5 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${
|
||||||
|
it.status === 'Critical' ? 'bg-rose-500' : it.status === 'Low Stock' ? 'bg-amber-500' : 'bg-[#581c87]'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={`text-[8px] font-bold uppercase shrink-0 ${
|
||||||
|
it.status === 'Critical' ? 'text-rose-600' : it.status === 'Low Stock' ? 'text-amber-600' : 'text-emerald-600'
|
||||||
|
}`}>
|
||||||
|
{it.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
<div className="text-right space-y-1">
|
})
|
||||||
<span className="font-mono font-bold text-[#0f172a] block">{item.stockLevel.toLocaleString()} units</span>
|
|
||||||
|
|
||||||
<div className="flex gap-1 justify-end">
|
|
||||||
<button
|
|
||||||
className="bg-zinc-100 hover:bg-zinc-200 p-1 px-2 rounded font-bold cursor-pointer text-[10px]"
|
|
||||||
onClick={() => handleUpdateStock(item.sku, -5)}
|
|
||||||
title="Decrement 5 units"
|
|
||||||
>
|
|
||||||
-5
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="bg-zinc-100 hover:bg-zinc-200 p-1 px-2 rounded font-bold cursor-pointer text-[10px]"
|
|
||||||
onClick={() => handleUpdateStock(item.sku, 5)}
|
|
||||||
title="Increment 5 units"
|
|
||||||
>
|
|
||||||
+5
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gauge percentage */}
|
|
||||||
<div className="pt-1.5 space-y-1">
|
|
||||||
<div className="w-full bg-[#eceef0] h-1.5 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full transition-all duration-300 ${
|
|
||||||
item.status === 'Critical' ? 'bg-rose-500' : item.status === 'Low Stock' ? 'bg-amber-500' : 'bg-[#581c87]'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between text-[9px] text-zinc-400 font-bold">
|
|
||||||
<span>Verification Level: {Math.round(percentage)}%</span>
|
|
||||||
{item.status !== 'Optimal' && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleReplenishSku(item.sku)}
|
|
||||||
className="text-[#581c87] hover:underline flex items-center gap-px font-bold cursor-pointer"
|
|
||||||
>
|
|
||||||
<Zap size={11} className="text-amber-500 animate-bounce" />
|
|
||||||
Auto-Replenish
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -23,18 +23,15 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
AlertOctagon,
|
AlertOctagon,
|
||||||
X,
|
X,
|
||||||
Calendar,
|
|
||||||
FileSpreadsheet
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { initialImportLogs } from '../data';
|
import { initialImportLogs } from '../data';
|
||||||
import { InventoryItem, OrderItem } from '../types';
|
import { InventoryItem } from '../types';
|
||||||
import {
|
import {
|
||||||
useFiestaStockStatement,
|
useFiestaStockStatement,
|
||||||
useFiestaDeliveries,
|
|
||||||
useFiestaTenantLocations,
|
useFiestaTenantLocations,
|
||||||
} from '../services/fiestaQueries';
|
} from '../services/fiestaQueries';
|
||||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr } from '../services/fiestaApi';
|
||||||
import { stockRowToProduct, stockRowToInventory, mapOrderStatus, shortTime } from '../services/fiestaMappers';
|
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
||||||
|
|
||||||
interface OperationsViewProps {
|
interface OperationsViewProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -43,29 +40,9 @@ interface OperationsViewProps {
|
|||||||
|
|
||||||
export default function OperationsView({ searchQuery, isCoimbatoreView }: OperationsViewProps) {
|
export default function OperationsView({ searchQuery, isCoimbatoreView }: OperationsViewProps) {
|
||||||
// Sub-tabs state
|
// Sub-tabs state
|
||||||
const [activeSubTab, setActiveSubTab] = useState<'inventory' | 'catalogue' | 'orders' | 'import'>('inventory');
|
const [activeSubTab, setActiveSubTab] = useState<'inventory' | 'catalogue' | 'import'>('inventory');
|
||||||
|
|
||||||
// ── Live operations data (Fiesta) ─────────────────────────────────────────
|
// ── Live operations data (Fiesta) ─────────────────────────────────────────
|
||||||
const today = new Date();
|
|
||||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
||||||
|
|
||||||
// Date-range filter for the Orders sub-tab (drives the live deliveries query).
|
|
||||||
const [fromdate, setFromdate] = useState<string>(ymd(monthStart));
|
|
||||||
const [todate, setTodate] = useState<string>(ymd(today));
|
|
||||||
const dayOffset = (n: number) => {
|
|
||||||
const d = new Date();
|
|
||||||
d.setDate(d.getDate() - n);
|
|
||||||
return ymd(d);
|
|
||||||
};
|
|
||||||
const datePresets: Array<{ key: string; label: string; from: string; to: string }> = [
|
|
||||||
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
|
|
||||||
{ key: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) },
|
|
||||||
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
|
|
||||||
{ key: '30d', label: 'Last 30 Days', from: dayOffset(29), to: ymd(today) },
|
|
||||||
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: ymd(today) },
|
|
||||||
];
|
|
||||||
const activePreset = datePresets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
|
|
||||||
|
|
||||||
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
|
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
|
||||||
const primaryLocation =
|
const primaryLocation =
|
||||||
(locationsQ.data ?? []).find((l) => Number(l.locationid) === FIESTA_PRIMARY_LOCATION_ID) ||
|
(locationsQ.data ?? []).find((l) => Number(l.locationid) === FIESTA_PRIMARY_LOCATION_ID) ||
|
||||||
@@ -80,8 +57,6 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
|||||||
pageno: 1,
|
pageno: 1,
|
||||||
pagesize: 100,
|
pagesize: 100,
|
||||||
});
|
});
|
||||||
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
|
||||||
|
|
||||||
// Total inventory value = Σ closing × unit cost across the live stock statement.
|
// Total inventory value = Σ closing × unit cost across the live stock statement.
|
||||||
const inventoryValue = (stockQ.data ?? []).reduce(
|
const inventoryValue = (stockQ.data ?? []).reduce(
|
||||||
(sum, r) => sum + fnum(r.closing) * fnum(r.productcost),
|
(sum, r) => sum + fnum(r.closing) * fnum(r.productcost),
|
||||||
@@ -91,7 +66,6 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
|||||||
// Dynamic state arrays for interaction (seeded from live data once it loads).
|
// Dynamic state arrays for interaction (seeded from live data once it loads).
|
||||||
const [inventoryList, setInventoryList] = useState<InventoryItem[]>([]);
|
const [inventoryList, setInventoryList] = useState<InventoryItem[]>([]);
|
||||||
const [productList, setProductList] = useState<ReturnType<typeof stockRowToProduct>[]>([]);
|
const [productList, setProductList] = useState<ReturnType<typeof stockRowToProduct>[]>([]);
|
||||||
const [orderList, setOrderList] = useState<OrderItem[]>([]);
|
|
||||||
const [importLogs, setImportLogs] = useState(initialImportLogs);
|
const [importLogs, setImportLogs] = useState(initialImportLogs);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -101,23 +75,6 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
|||||||
}
|
}
|
||||||
}, [stockQ.data, locationName]);
|
}, [stockQ.data, locationName]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (deliveriesQ.data) {
|
|
||||||
setOrderList(
|
|
||||||
deliveriesQ.data.map((r): OrderItem => {
|
|
||||||
const cust = mapOrderStatus(fstr(r.orderstatus));
|
|
||||||
return {
|
|
||||||
id: fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`,
|
|
||||||
store: fstr(r.pickupcustomer) || fstr(r.pickuplocation) || `Location ${fstr(r.locationid)}`,
|
|
||||||
amount: fnum(r.deliveryamt) || fnum(r.orderamount),
|
|
||||||
time: shortTime(r.assigntime || r.deliverydate),
|
|
||||||
status: cust === 'DELIVERED' ? 'SHIPPED' : fstr(r.orderstatus).toLowerCase() === 'cancelled' ? 'FLAGGED' : 'PROCESSING',
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [deliveriesQ.data]);
|
|
||||||
|
|
||||||
// Modal open states
|
// Modal open states
|
||||||
const [showAddSkuModal, setShowAddSkuModal] = useState(false);
|
const [showAddSkuModal, setShowAddSkuModal] = useState(false);
|
||||||
const [showTransferModal, setShowTransferModal] = useState(false);
|
const [showTransferModal, setShowTransferModal] = useState(false);
|
||||||
@@ -153,11 +110,6 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
|||||||
prod.category.toLowerCase().includes(searchQuery.toLowerCase())
|
prod.category.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredOrders = orderList.filter(ord =>
|
|
||||||
ord.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
ord.store.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
// Form submit handles
|
// Form submit handles
|
||||||
const handleAddSku = (e: React.FormEvent) => {
|
const handleAddSku = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -239,7 +191,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
|||||||
|
|
||||||
{/* Dynamic Nav Sub-Tabs */}
|
{/* Dynamic Nav Sub-Tabs */}
|
||||||
<nav className="flex gap-lg">
|
<nav className="flex gap-lg">
|
||||||
{(['inventory', 'catalogue', 'orders', 'import'] as const).map((tab) => (
|
{(['inventory', 'catalogue', 'import'] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveSubTab(tab)}
|
onClick={() => setActiveSubTab(tab)}
|
||||||
@@ -594,107 +546,6 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeSubTab === 'orders' && (
|
|
||||||
<div className="space-y-md animate-in slide-in-from-right-5">
|
|
||||||
{/* Day-wise date filter — drives the live deliveries/orders query */}
|
|
||||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm flex flex-col lg:flex-row lg:items-center justify-between gap-md">
|
|
||||||
<div className="flex items-center gap-sm flex-wrap">
|
|
||||||
<span className="flex items-center gap-1.5 text-[10px] font-bold text-zinc-400 uppercase tracking-widest pr-1">
|
|
||||||
<Calendar size={13} className="text-[#581c87]" /> View
|
|
||||||
</span>
|
|
||||||
{datePresets.map((p) => (
|
|
||||||
<button
|
|
||||||
key={p.key}
|
|
||||||
onClick={() => { setFromdate(p.from); setTodate(p.to); }}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border cursor-pointer ${
|
|
||||||
activePreset === p.key
|
|
||||||
? 'bg-[#581c87] text-white border-[#581c87] shadow-sm'
|
|
||||||
: 'bg-white text-zinc-600 border-[#e2e8f0] hover:bg-zinc-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{p.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-sm text-xs">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<label className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider">From</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={fromdate}
|
|
||||||
max={todate}
|
|
||||||
onChange={(e) => setFromdate(e.target.value)}
|
|
||||||
className="border border-[#e2e8f0] rounded-lg px-2 py-1.5 text-zinc-700 font-medium outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-zinc-300">→</span>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<label className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider">To</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={todate}
|
|
||||||
min={fromdate}
|
|
||||||
max={ymd(today)}
|
|
||||||
onChange={(e) => setTodate(e.target.value)}
|
|
||||||
className="border border-[#e2e8f0] rounded-lg px-2 py-1.5 text-zinc-700 font-medium outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white border border-[#e2e8f0] rounded-xl overflow-hidden shadow-sm">
|
|
||||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center">
|
|
||||||
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
|
|
||||||
Orders ({filteredOrders.length})
|
|
||||||
</h4>
|
|
||||||
<span className="text-[10px] text-zinc-400 font-medium uppercase tracking-wider">
|
|
||||||
{fromdate === todate ? fromdate : `${fromdate} → ${todate}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<table className="w-full text-left font-sans text-xs">
|
|
||||||
<thead className="bg-[#f8fafc] border-b border-[#e2e8f0] text-zinc-500 text-[10px] uppercase font-bold tracking-wider">
|
|
||||||
<tr>
|
|
||||||
<th className="p-md">Order ID</th>
|
|
||||||
<th className="p-md">Origin Store Terminal</th>
|
|
||||||
<th className="p-md">Invoice Amount</th>
|
|
||||||
<th className="p-md">Committed Time (IST)</th>
|
|
||||||
<th className="p-md">System state status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-[#f1f5f9]">
|
|
||||||
{filteredOrders.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="text-center py-10 text-zinc-400">
|
|
||||||
{deliveriesQ.isLoading ? 'Loading live orders…' : 'No orders in this date range.'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
filteredOrders.map((ord) => (
|
|
||||||
<tr key={ord.id} className="hover:bg-[#f2f4f6]/50 transition-colors">
|
|
||||||
<td className="p-md font-mono font-bold text-[#581c87]">{ord.id}</td>
|
|
||||||
<td className="p-md text-[#0f172a] font-medium">{ord.store}</td>
|
|
||||||
<td className="p-md font-mono font-bold text-zinc-700">₹{ord.amount.toLocaleString()}</td>
|
|
||||||
<td className="p-md text-zinc-500 font-medium">{ord.time}</td>
|
|
||||||
<td className="p-md">
|
|
||||||
<span className={`px-2 py-0.5 rounded text-[9px] font-bold tracking-wider ${
|
|
||||||
ord.status === 'SHIPPED'
|
|
||||||
? 'bg-purple-100 text-[#581c87] border border-purple-200'
|
|
||||||
: ord.status === 'FLAGGED'
|
|
||||||
? 'bg-rose-100 text-rose-700 border border-rose-200'
|
|
||||||
: 'bg-zinc-100 text-zinc-650 border border-zinc-200'
|
|
||||||
}`}>
|
|
||||||
{ord.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeSubTab === 'import' && (
|
{activeSubTab === 'import' && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-gutter animate-in slide-in-from-right-5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-gutter animate-in slide-in-from-right-5">
|
||||||
{/* Upload panel zone */}
|
{/* Upload panel zone */}
|
||||||
|
|||||||
@@ -483,6 +483,10 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column — Order Details, shown parallel to the orders feed */}
|
||||||
|
<div className="lg:col-span-1 space-y-md">
|
||||||
{selectedOrder ? (
|
{selectedOrder ? (
|
||||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm space-y-md animate-in zoom-in-95 duration-150">
|
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm space-y-md animate-in zoom-in-95 duration-150">
|
||||||
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">
|
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Truck,
|
Truck,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
Users,
|
||||||
MapPin,
|
MapPin,
|
||||||
Phone,
|
Phone,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -19,12 +20,13 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useFiestaAllTenants, useFiestaTenantLocations } from '../services/fiestaQueries';
|
import { useFiestaAllTenants, useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||||
|
import UsersPanel from './UsersPanel';
|
||||||
|
|
||||||
interface SettingsViewProps {
|
interface SettingsViewProps {
|
||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabKey = 'profile' | 'outlets' | 'delivery' | 'payment' | 'preferences';
|
type TabKey = 'profile' | 'outlets' | 'users' | 'delivery' | 'payment' | 'preferences';
|
||||||
|
|
||||||
/** Locally-persisted merchant preferences (survive reload via localStorage). */
|
/** Locally-persisted merchant preferences (survive reload via localStorage). */
|
||||||
interface MerchantSettings {
|
interface MerchantSettings {
|
||||||
@@ -175,6 +177,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
|||||||
const tabs: Array<{ key: TabKey; label: string; icon: typeof Building2 }> = [
|
const tabs: Array<{ key: TabKey; label: string; icon: typeof Building2 }> = [
|
||||||
{ key: 'profile', label: 'Business Profile', icon: Building2 },
|
{ key: 'profile', label: 'Business Profile', icon: Building2 },
|
||||||
{ key: 'outlets', label: 'Outlets', icon: Store },
|
{ key: 'outlets', label: 'Outlets', icon: Store },
|
||||||
|
{ key: 'users', label: 'Users & Access', icon: Users },
|
||||||
{ key: 'delivery', label: 'Delivery', icon: Truck },
|
{ key: 'delivery', label: 'Delivery', icon: Truck },
|
||||||
{ key: 'payment', label: 'Payment & Tax', icon: CreditCard },
|
{ key: 'payment', label: 'Payment & Tax', icon: CreditCard },
|
||||||
{ key: 'preferences', label: 'Preferences', icon: SlidersHorizontal },
|
{ key: 'preferences', label: 'Preferences', icon: SlidersHorizontal },
|
||||||
@@ -366,6 +369,10 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'users' && (
|
||||||
|
<UsersPanel tenantId={tenantId} defaultNewUserRole={form.defaultNewUserRole} />
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'delivery' && (
|
{activeTab === 'delivery' && (
|
||||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl shadow-sm space-y-md">
|
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl shadow-sm space-y-md">
|
||||||
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">
|
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">
|
||||||
@@ -474,8 +481,9 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Save / Reset — lives with the settings card, not pinned to the screen */}
|
{/* Save / Reset — lives with the settings card, not pinned to the screen.
|
||||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm flex flex-col sm:flex-row sm:items-center justify-between gap-sm">
|
Hidden on the Users tab, which manages accounts via the live API. */}
|
||||||
|
<div className={`bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm flex-col sm:flex-row sm:items-center justify-between gap-sm ${activeTab === 'users' ? 'hidden' : 'flex'}`}>
|
||||||
<span className={`text-xs font-medium ${dirty ? 'text-amber-600' : 'text-zinc-400'}`}>
|
<span className={`text-xs font-medium ${dirty ? 'text-amber-600' : 'text-zinc-400'}`}>
|
||||||
{dirty ? '● You have unsaved changes' : 'All changes saved'}
|
{dirty ? '● You have unsaved changes' : 'All changes saved'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
Store,
|
Store,
|
||||||
Layers,
|
Layers,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
Users,
|
|
||||||
Settings
|
Settings
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { MainSection } from '../types';
|
import { MainSection } from '../types';
|
||||||
@@ -33,8 +32,7 @@ export default function Sidebar({
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
|
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ id: 'stores' as MainSection, label: 'Stores', icon: Store },
|
{ id: 'stores' as MainSection, label: 'Stores', icon: Store },
|
||||||
{ id: 'inventory' as MainSection, label: 'Inventory Catalog', icon: Layers },
|
{ id: 'inventory' as MainSection, label: 'Product Catalog', icon: Layers },
|
||||||
{ id: 'users' as MainSection, label: 'Users', icon: Users },
|
|
||||||
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }
|
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
378
src/components/UsersPanel.tsx
Normal file
378
src/components/UsersPanel.tsx
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Users & Access — tenant staff directory with role filtering and user creation.
|
||||||
|
* Rendered as a tab inside SettingsView (it used to be a standalone sidebar page).
|
||||||
|
* Self-contained: owns its search box, role filter, live query, and Add User modal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Users, Search, X } from 'lucide-react';
|
||||||
|
import { useFiestaUsers, useFiestaCreateUser } from '../services/fiestaQueries';
|
||||||
|
import { FIESTA_TENANT_ID, str as fstr, roleName } from '../services/fiestaApi';
|
||||||
|
|
||||||
|
interface UsersPanelProps {
|
||||||
|
tenantId?: number;
|
||||||
|
/** Pre-selected role in the Add User dialog (from workspace preferences). */
|
||||||
|
defaultNewUserRole?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const USER_AVATARS = [
|
||||||
|
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=150&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=150&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=150&q=80',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUserRole = 4 }: UsersPanelProps) {
|
||||||
|
const usersQ = useFiestaUsers({ tenantid: tenantId, pagesize: 100 });
|
||||||
|
const createUserMut = useFiestaCreateUser();
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [userRoleFilter, setUserRoleFilter] = useState<number | 'ALL'>('ALL');
|
||||||
|
const [showAddUserModal, setShowAddUserModal] = useState(false);
|
||||||
|
const [newUser, setNewUser] = useState({
|
||||||
|
firstname: '',
|
||||||
|
lastname: '',
|
||||||
|
email: '',
|
||||||
|
contactno: '',
|
||||||
|
password: '',
|
||||||
|
roleid: defaultNewUserRole,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live users mapped to display rows (rendered directly from the query).
|
||||||
|
const users = (usersQ.data ?? []).map((u, i) => {
|
||||||
|
const shift = fstr(u.shiftname).trim();
|
||||||
|
return {
|
||||||
|
userid: Number(u.userid),
|
||||||
|
name:
|
||||||
|
fstr(u.fullname).trim() ||
|
||||||
|
`${fstr(u.firstname)} ${fstr(u.lastname)}`.trim() ||
|
||||||
|
fstr(u.authname) ||
|
||||||
|
'User',
|
||||||
|
email: fstr(u.email) || fstr(u.authname) || '—',
|
||||||
|
contact: fstr(u.contactno) || '—',
|
||||||
|
roleid: Number(u.roleid),
|
||||||
|
role: roleName(Number(u.roleid)),
|
||||||
|
shift: shift && shift !== '-' ? shift : '—',
|
||||||
|
location: fstr(u.applocation) || fstr(u.city) || 'Coimbatore',
|
||||||
|
status: fstr(u.status) || 'Active',
|
||||||
|
avatar: USER_AVATARS[i % USER_AVATARS.length],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredUsers = users.filter((u) => {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
const matchesSearch =
|
||||||
|
!q ||
|
||||||
|
u.name.toLowerCase().includes(q) ||
|
||||||
|
u.email.toLowerCase().includes(q) ||
|
||||||
|
u.contact.toLowerCase().includes(q);
|
||||||
|
const matchesRole = userRoleFilter === 'ALL' || u.roleid === userRoleFilter;
|
||||||
|
return matchesSearch && matchesRole;
|
||||||
|
});
|
||||||
|
const roleOptions = Array.from(new Set(users.map((u) => u.roleid)));
|
||||||
|
|
||||||
|
const handleCreateUser = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newUser.firstname || !newUser.email || !newUser.contactno || !newUser.password) {
|
||||||
|
alert('Please provide first name, email, contact number, and a password.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createUserMut.mutateAsync({
|
||||||
|
firstname: newUser.firstname,
|
||||||
|
lastname: newUser.lastname,
|
||||||
|
email: newUser.email,
|
||||||
|
contactno: newUser.contactno,
|
||||||
|
password: newUser.password,
|
||||||
|
roleid: Number(newUser.roleid),
|
||||||
|
tenantid: tenantId,
|
||||||
|
});
|
||||||
|
setShowAddUserModal(false);
|
||||||
|
setNewUser({ firstname: '', lastname: '', email: '', contactno: '', password: '', roleid: defaultNewUserRole });
|
||||||
|
alert(`User "${newUser.firstname}" created successfully and synced to the live Users directory.`);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Could not create user: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-md">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-md">
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block">Users & Access</span>
|
||||||
|
<p className="text-zinc-500 text-[11px] mt-0.5">
|
||||||
|
Tenant staff accounts, roles, shifts, and status — live from the Users API.
|
||||||
|
</p>
|
||||||
|
<div className="mt-1.5">
|
||||||
|
{usersQ.isLoading ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 uppercase tracking-wide">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading live users…
|
||||||
|
</span>
|
||||||
|
) : usersQ.isError ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-rose-600 uppercase tracking-wide">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" /> Live data unavailable
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 uppercase tracking-wide">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {users.length} users
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddUserModal(true)}
|
||||||
|
className="bg-[#581c87] text-white px-xl py-2.5 rounded-lg text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-xs cursor-pointer hover:bg-purple-800 transition shrink-0"
|
||||||
|
>
|
||||||
|
Add User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative w-full md:max-w-md">
|
||||||
|
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-zinc-400">
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search users by name, email, or contact…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-zinc-50 border border-zinc-200 rounded-lg text-xs font-medium text-zinc-800 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[#581c87]/20 focus:border-[#581c87] transition-all"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center text-zinc-400 hover:text-zinc-600"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role filter pills */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setUserRoleFilter('ALL')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold border transition-all cursor-pointer ${
|
||||||
|
userRoleFilter === 'ALL'
|
||||||
|
? 'bg-[#581c87] text-white border-[#581c87] shadow-sm'
|
||||||
|
: 'bg-white text-zinc-700 border-[#e2e8f0] hover:bg-zinc-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All Roles
|
||||||
|
</button>
|
||||||
|
{roleOptions.map((rid) => (
|
||||||
|
<button
|
||||||
|
key={rid}
|
||||||
|
onClick={() => setUserRoleFilter(rid)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold border transition-all cursor-pointer ${
|
||||||
|
userRoleFilter === rid
|
||||||
|
? 'bg-[#581c87] text-white border-[#581c87] shadow-sm'
|
||||||
|
: 'bg-white text-zinc-700 border-[#e2e8f0] hover:bg-zinc-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{roleName(rid)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users table */}
|
||||||
|
<div className="bg-white border border-[#e2e8f0] rounded-xl overflow-hidden shadow-sm">
|
||||||
|
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center">
|
||||||
|
<h4 className="font-sans font-bold text-sm text-[#0f172a]">Directory ({filteredUsers.length})</h4>
|
||||||
|
<span className="text-[10px] text-zinc-400 font-medium uppercase tracking-wider">Tenant {tenantId}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto text-xs font-sans">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead className="bg-[#f8fafc] border-b border-[#e2e8f0] text-zinc-500 text-[10px] uppercase font-bold tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="px-md py-sm">User</th>
|
||||||
|
<th className="px-md py-sm">Role</th>
|
||||||
|
<th className="px-md py-sm">Contact</th>
|
||||||
|
<th className="px-md py-sm">Shift</th>
|
||||||
|
<th className="px-md py-sm">Location</th>
|
||||||
|
<th className="px-md py-sm text-right">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#f1f5f9]">
|
||||||
|
{filteredUsers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-center py-10 text-zinc-400">
|
||||||
|
{usersQ.isLoading ? 'Loading live users…' : 'No users match this filter.'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredUsers.map((u) => (
|
||||||
|
<tr key={u.userid} className="hover:bg-[#f2f4f6]/50 transition-colors">
|
||||||
|
<td className="px-md py-md">
|
||||||
|
<div className="flex items-center gap-sm">
|
||||||
|
<img
|
||||||
|
src={u.avatar}
|
||||||
|
alt={u.name}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="w-9 h-9 object-cover rounded-full border border-zinc-200 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-bold text-[#0f172a] truncate">{u.name}</p>
|
||||||
|
<p className="text-[10px] text-zinc-400 font-medium truncate">{u.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-md py-md">
|
||||||
|
<span className="px-2 py-0.5 rounded text-[9px] font-bold uppercase bg-purple-50 text-[#581c87] border border-purple-100">
|
||||||
|
{u.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-md py-md font-mono text-zinc-600 font-medium">{u.contact}</td>
|
||||||
|
<td className="px-md py-md text-zinc-500 font-medium">{u.shift}</td>
|
||||||
|
<td className="px-md py-md text-zinc-500 font-medium">{u.location}</td>
|
||||||
|
<td className="px-md py-md text-right">
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
|
||||||
|
u.status.toLowerCase() === 'active'
|
||||||
|
? 'text-emerald-700 bg-emerald-100'
|
||||||
|
: 'text-zinc-500 bg-zinc-200'
|
||||||
|
}`}>
|
||||||
|
{u.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CREATE NEW USER MODAL */}
|
||||||
|
{showAddUserModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) setShowAddUserModal(false); }}
|
||||||
|
>
|
||||||
|
<div className="bg-white border border-[#e2e8f0] rounded-xl w-full max-w-[26rem] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-xs font-sans cursor-default">
|
||||||
|
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center shrink-0">
|
||||||
|
<h4 className="font-bold text-[#0f172a] flex items-center gap-xs">
|
||||||
|
<Users size={15} className="text-[#581c87]" />
|
||||||
|
Create User Account
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddUserModal(false)}
|
||||||
|
className="p-1 hover:bg-zinc-200 rounded-full text-zinc-400 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleCreateUser} className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
|
<div className="p-md space-y-md overflow-y-auto flex-1">
|
||||||
|
<p className="text-zinc-500 leading-relaxed">
|
||||||
|
Creates a real user against the live Users API for tenant {tenantId}.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-sm">
|
||||||
|
<div className="grid grid-cols-2 gap-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">FIRST NAME (*)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Harini"
|
||||||
|
value={newUser.firstname}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, firstname: e.target.value })}
|
||||||
|
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">LAST NAME</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Rajan"
|
||||||
|
value={newUser.lastname}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, lastname: e.target.value })}
|
||||||
|
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">EMAIL (*)</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="e.g. harini@store.com"
|
||||||
|
value={newUser.email}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
|
||||||
|
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">CONTACT NO (*)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="9988776655"
|
||||||
|
value={newUser.contactno}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, contactno: e.target.value })}
|
||||||
|
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">ROLE</label>
|
||||||
|
<select
|
||||||
|
value={newUser.roleid}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, roleid: Number(e.target.value) })}
|
||||||
|
className="w-full border border-[#e2e8f0] rounded-lg p-2 bg-[#f8fafc] focus:bg-white outline-none"
|
||||||
|
>
|
||||||
|
<option value={1}>Owner</option>
|
||||||
|
<option value={2}>Manager</option>
|
||||||
|
<option value={3}>Admin</option>
|
||||||
|
<option value={4}>Staff</option>
|
||||||
|
<option value={6}>Cashier</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">TEMPORARY PASSWORD (*)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Set an initial password"
|
||||||
|
value={newUser.password}
|
||||||
|
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
|
||||||
|
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87] font-mono"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-md border-t border-[#f1f5f9] flex justify-end gap-sm bg-[#f8fafc] shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAddUserModal(false)}
|
||||||
|
className="px-4 py-2 border border-[#e2e8f0] rounded-lg font-semibold text-zinc-500 hover:bg-zinc-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createUserMut.isPending}
|
||||||
|
className="px-4 py-2 bg-[#581c87] text-white rounded-lg font-bold hover:bg-purple-800 cursor-pointer shadow-sm disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{createUserMut.isPending ? 'Creating…' : 'Create User'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
* continues to use the Hasura hooks in `./queries`.
|
* continues to use the Hasura hooks in `./queries`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query';
|
||||||
|
import type { Row } from './fiestaApi';
|
||||||
import {
|
import {
|
||||||
FIESTA_TENANT_ID,
|
FIESTA_TENANT_ID,
|
||||||
FIESTA_APPLOCATION_ID,
|
FIESTA_APPLOCATION_ID,
|
||||||
@@ -204,6 +205,39 @@ export function useFiestaProductsCount(opts: { tenantid: number; categoryid: num
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StoreStock {
|
||||||
|
locationid: number;
|
||||||
|
locationname: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
rows: Row[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live stock statement for every outlet under the tenant — powers the admin's
|
||||||
|
* "all stores' stock" view. One query per location (deduped/cached by React
|
||||||
|
* Query); the returned array stays aligned with the `locations` input.
|
||||||
|
*/
|
||||||
|
export function useFiestaStoresStock(
|
||||||
|
tenantid: number,
|
||||||
|
locations: Array<{ locationid: number; locationname: string }>,
|
||||||
|
): StoreStock[] {
|
||||||
|
const results = useQueries({
|
||||||
|
queries: locations.map((loc) => ({
|
||||||
|
queryKey: fiestaKeys.stockStatement({ scope: 'stores', tenantid, locationid: loc.locationid }),
|
||||||
|
queryFn: () => getStockStatement({ tenantid, locationid: loc.locationid, pagesize: 200 }),
|
||||||
|
enabled: Boolean(tenantid && loc.locationid),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return locations.map((loc, i) => ({
|
||||||
|
locationid: loc.locationid,
|
||||||
|
locationname: loc.locationname,
|
||||||
|
isLoading: results[i]?.isLoading ?? true,
|
||||||
|
isError: results[i]?.isError ?? false,
|
||||||
|
rows: (results[i]?.data as Row[]) ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Users ─────────────────────────────────────────────────────────────────────
|
// ── Users ─────────────────────────────────────────────────────────────────────
|
||||||
export function useFiestaUsers(opts: {
|
export function useFiestaUsers(opts: {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user