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",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -933,9 +934,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -949,9 +947,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -965,9 +960,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -981,9 +973,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -997,9 +986,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1013,9 +999,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1029,9 +1012,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1045,9 +1025,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1061,9 +1038,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1077,9 +1051,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1093,9 +1064,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1109,9 +1077,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1125,9 +1090,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1337,9 +1299,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1356,9 +1315,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1375,9 +1331,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1394,9 +1347,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1861,6 +1811,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -2840,9 +2791,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2863,9 +2811,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2886,9 +2831,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2909,9 +2851,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3251,6 +3190,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3277,6 +3217,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -3374,6 +3315,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -3383,6 +3325,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -4265,6 +4208,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
349
src/App.tsx
349
src/App.tsx
@@ -7,7 +7,6 @@ import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Network,
|
||||
Truck,
|
||||
Users,
|
||||
Sliders,
|
||||
Calendar,
|
||||
AlertTriangle,
|
||||
@@ -34,10 +33,8 @@ import { MainSection } from './types';
|
||||
import {
|
||||
useFiestaTenantLocations,
|
||||
useFiestaLocationSummary,
|
||||
useFiestaUsers,
|
||||
useFiestaCreateUser,
|
||||
} 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 Header from './components/Header';
|
||||
import DashboardView from './components/DashboardView';
|
||||
@@ -64,19 +61,10 @@ export default function App() {
|
||||
|
||||
// ── Live data for the secondary sections (Fiesta) ─────────────────────────
|
||||
// Stores ← tenant locations + per-location order summary (seeded into local
|
||||
// state so the "Add Store" handler keeps working). Users come straight from
|
||||
// the live Users API and render directly from the query, with the "Add User"
|
||||
// form posting back through the create-user mutation.
|
||||
// state so the "Add Store" handler keeps working). Users management now lives
|
||||
// under Settings → Users & Access (see UsersPanel).
|
||||
const locationsQ = useFiestaTenantLocations(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 = [
|
||||
'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];
|
||||
};
|
||||
|
||||
// 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).
|
||||
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
|
||||
const [showAddStoreModal, setShowAddStoreModal] = useState(false);
|
||||
const [showAddUserModal, setShowAddUserModal] = useState(false);
|
||||
|
||||
// New forms states
|
||||
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
|
||||
const handleCreateStore = (e: React.FormEvent) => {
|
||||
@@ -229,30 +173,6 @@ export default function App() {
|
||||
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
|
||||
const [showCalendarModal, setShowCalendarModal] = useState(false);
|
||||
|
||||
@@ -550,141 +470,6 @@ export default function App() {
|
||||
</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':
|
||||
return <SettingsView tenantId={FIESTA_TENANT_ID} />;
|
||||
|
||||
@@ -742,8 +527,8 @@ export default function App() {
|
||||
<ReportsView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} />
|
||||
)}
|
||||
|
||||
{/* Handle alternative sections: Stores, Logistics, Staffing, Settings */}
|
||||
{['stores', 'users', 'settings'].includes(currentSection) &&
|
||||
{/* Handle alternative sections: Stores, Settings */}
|
||||
{['stores', 'settings'].includes(currentSection) &&
|
||||
renderSecondarySection()
|
||||
}
|
||||
</div>
|
||||
@@ -900,130 +685,6 @@ export default function App() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,16 +45,23 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) {
|
||||
const s = summaryQ.data;
|
||||
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
|
||||
// invoice records we show "—" rather than a misleading ₹0.
|
||||
// Revenue + profit come from the live invoice/financial insight. The endpoint
|
||||
// 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 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 locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
||||
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) => {
|
||||
if (!searchQuery) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
@@ -75,8 +82,8 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) {
|
||||
|
||||
const kpis = [
|
||||
{ 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: "TODAY'S PROFIT", display: money(todaysProfit), icon: Wallet, chip: 'bg-sky-50 text-sky-600', loading: insightQ.isLoading },
|
||||
{ title: 'REGION FULFILLMENT', display: regionFulfillmentPct == null ? '—' : `${regionFulfillmentPct.toFixed(1)}%`, icon: Sparkles, chip: 'bg-emerald-50 text-emerald-600', loading: summaryQ.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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Layers,
|
||||
Search,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TrendingDown,
|
||||
Trash2,
|
||||
PackageCheck,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
Tag,
|
||||
UploadCloud,
|
||||
@@ -27,12 +28,15 @@ import {
|
||||
Info,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { ProductMatrixItem, InventoryItem, ImportLog } from '../types';
|
||||
import { ProductMatrixItem, ImportLog } from '../types';
|
||||
import { initialImportLogs } from '../data';
|
||||
import { useFiestaStockStatement, useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr } from '../services/fiestaApi';
|
||||
import { useFiestaTenantLocations, useFiestaStoresStock } from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi';
|
||||
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
||||
|
||||
type StockRow = Record<string, unknown>;
|
||||
const rowId = (r: StockRow) => String(r.productid ?? '') || String(r.productname ?? '');
|
||||
|
||||
interface InventoryViewProps {
|
||||
searchQuery: string;
|
||||
isCoimbatoreView: boolean;
|
||||
@@ -42,42 +46,51 @@ export default function InventoryView({
|
||||
searchQuery,
|
||||
isCoimbatoreView
|
||||
}: InventoryViewProps) {
|
||||
// ── Live stock data (Fiesta) ─────────────────────────────────────────────
|
||||
// The catalog grid and the hub-balance ledger are both derived from the live
|
||||
// stock statement for the tenant's primary outlet. We seed local state from
|
||||
// it once it loads so the existing add / CSV / replenish interactions keep
|
||||
// mutating in-session without losing the live baseline.
|
||||
// ── Live stock across every outlet (Fiesta) ───────────────────────────────
|
||||
// This page is the admin's command surface. The GLOBAL CATALOG is the deduped
|
||||
// union of products across all outlets the tenant owns (admin-only import adds
|
||||
// to it); the STORE STOCK section shows each outlet's live stock so the admin
|
||||
// can see all the stores under them at a glance.
|
||||
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
|
||||
const primaryLocation =
|
||||
(locationsQ.data ?? []).find((l) => Number(l.locationid) === FIESTA_PRIMARY_LOCATION_ID) ||
|
||||
(locationsQ.data ?? [])[0];
|
||||
const locationId = primaryLocation ? Number(primaryLocation.locationid) : FIESTA_PRIMARY_LOCATION_ID;
|
||||
const locationName = fstr(primaryLocation?.locationname) || 'Primary Outlet';
|
||||
const locations = useMemo(
|
||||
() =>
|
||||
(locationsQ.data ?? []).map((l) => ({
|
||||
locationid: Number(l.locationid),
|
||||
locationname: fstr(l.locationname) || `Outlet ${fstr(l.locationid)}`,
|
||||
status: fstr(l.status) || 'Active',
|
||||
})),
|
||||
[locationsQ.data],
|
||||
);
|
||||
|
||||
const stockQ = useFiestaStockStatement({
|
||||
tenantid: FIESTA_TENANT_ID,
|
||||
locationid: locationId,
|
||||
keyword: '',
|
||||
pageno: 1,
|
||||
pagesize: 100,
|
||||
});
|
||||
const storesStock = useFiestaStoresStock(
|
||||
FIESTA_TENANT_ID,
|
||||
locations.map(({ locationid, locationname }) => ({ locationid, locationname })),
|
||||
);
|
||||
const storesLoading = locationsQ.isLoading || storesStock.some((s) => s.isLoading);
|
||||
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 [inventory, setInventory] = useState<InventoryItem[]>([]);
|
||||
const [importLogs, setImportLogs] = useState<ImportLog[]>(initialImportLogs);
|
||||
const [seeded, setSeeded] = useState(false);
|
||||
|
||||
const allStoreRows = storesStock.flatMap((s) => s.rows);
|
||||
useEffect(() => {
|
||||
if (stockQ.data) {
|
||||
setProducts(stockQ.data.map(stockRowToProduct));
|
||||
setInventory(stockQ.data.map((r) => stockRowToInventory(r, locationName)));
|
||||
}
|
||||
// locationName is derived from the same query chain; safe to depend on data.
|
||||
}, [stockQ.data, locationName]);
|
||||
if (seeded || allStoreRows.length === 0) return;
|
||||
const byId = new Map<string, StockRow>();
|
||||
allStoreRows.forEach((r) => {
|
||||
const id = rowId(r);
|
||||
if (id && !byId.has(id)) byId.set(id, r);
|
||||
});
|
||||
setProducts(Array.from(byId.values()).map(stockRowToProduct));
|
||||
setSeeded(true);
|
||||
}, [allStoreRows, seeded]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'catalog' | 'import_branding'>('catalog');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('ALL');
|
||||
const [showAddProductModal, setShowAddProductModal] = useState(false);
|
||||
const [replenishmentList, setReplenishmentList] = useState<string[]>([]);
|
||||
|
||||
// CSV Textarea input
|
||||
const [csvText, setCsvText] = useState(
|
||||
@@ -109,29 +122,7 @@ export default function InventoryView({
|
||||
products.forEach((p) => categorySet.add(p.category));
|
||||
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 matchesSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.sku.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
@@ -161,20 +152,9 @@ export default function InventoryView({
|
||||
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]);
|
||||
setInventory([createdInv, ...inventory]);
|
||||
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({
|
||||
name: '',
|
||||
@@ -196,7 +176,6 @@ export default function InventoryView({
|
||||
|
||||
let parsedCount = 0;
|
||||
const newProds: ProductMatrixItem[] = [];
|
||||
const newInvs: InventoryItem[] = [];
|
||||
|
||||
lines.forEach(line => {
|
||||
const parts = line.split(',').map(p => p.trim());
|
||||
@@ -204,8 +183,6 @@ export default function InventoryView({
|
||||
const name = parts[0];
|
||||
const sku = parts[1];
|
||||
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)) {
|
||||
newProds.push({
|
||||
@@ -221,16 +198,6 @@ export default function InventoryView({
|
||||
exposure: 'All Outlets',
|
||||
verified: true
|
||||
});
|
||||
|
||||
newInvs.push({
|
||||
sku,
|
||||
name,
|
||||
warehouse: 'RS Puram Hub (CBE-01)',
|
||||
stockLevel: initialStock,
|
||||
maxCapacity: 1000,
|
||||
status: 'Optimal',
|
||||
region: 'CBE-NORTH'
|
||||
});
|
||||
parsedCount++;
|
||||
}
|
||||
}
|
||||
@@ -238,7 +205,6 @@ export default function InventoryView({
|
||||
|
||||
if (parsedCount > 0) {
|
||||
setProducts(prev => [...newProds, ...prev]);
|
||||
setInventory(prev => [...newInvs, ...prev]);
|
||||
|
||||
const logEntry: ImportLog = {
|
||||
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}>) => {
|
||||
let imported = 0;
|
||||
const newProds: ProductMatrixItem[] = [];
|
||||
const newInvs: InventoryItem[] = [];
|
||||
|
||||
itemsList.forEach((itm) => {
|
||||
if (!products.some(p => p.sku === itm.sku)) {
|
||||
@@ -276,23 +241,12 @@ export default function InventoryView({
|
||||
exposure: 'All Outlets',
|
||||
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++;
|
||||
}
|
||||
});
|
||||
|
||||
if (imported > 0) {
|
||||
setProducts(prev => [...newProds, ...prev]);
|
||||
setInventory(prev => [...newInvs, ...prev]);
|
||||
|
||||
const logEntry: ImportLog = {
|
||||
timestamp: new Date().toLocaleTimeString() + ' (IST)',
|
||||
@@ -331,23 +285,23 @@ export default function InventoryView({
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a] flex items-center gap-xs">
|
||||
<Layers className="text-[#581c87]" size={24} />
|
||||
Coimbatore Grocery Assortment & Catalogue Studio
|
||||
Product Catalog · Global Assortment
|
||||
</h1>
|
||||
<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>
|
||||
<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="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>
|
||||
) : stockQ.isError ? (
|
||||
) : storesError ? (
|
||||
<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 · {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>
|
||||
)}
|
||||
</div>
|
||||
@@ -362,7 +316,7 @@ export default function InventoryView({
|
||||
: 'bg-white hover:bg-zinc-50 text-zinc-700 border border-[#e2e8f0]'
|
||||
}`}
|
||||
>
|
||||
🌾 Catalog Grid & Ledger
|
||||
🌐 Global Catalog & Stocks
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -380,7 +334,23 @@ export default function InventoryView({
|
||||
|
||||
{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">
|
||||
{categories.map((cat) => (
|
||||
@@ -388,156 +358,178 @@ export default function InventoryView({
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
className={`px-4 py-2 rounded-lg font-sans text-xs font-semibold tracking-wide transition-all border outline-none cursor-pointer ${
|
||||
selectedCategory === cat
|
||||
selectedCategory === cat
|
||||
? 'bg-[#581c87] text-white border-[#581c87] shadow-sm'
|
||||
: '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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Manual SKU
|
||||
</button>
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Manual SKU
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi-Pane Layout: Left Catalog Grid, Right Stock balances */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter text-xs font-sans">
|
||||
|
||||
{/* Left Grid: Grocery Catalogue Items Showcase */}
|
||||
<div className="lg:col-span-2 space-y-md">
|
||||
<div className="bg-[#f8fafc]/50 border border-[#e2e8f0] p-md rounded-xl">
|
||||
<h3 className="font-sans font-bold text-sm text-[#0f172a] mb-xs">Active Assortment Items</h3>
|
||||
<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>
|
||||
{/* Global Catalog — master assortment grid (full width) */}
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-sm">
|
||||
{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 className="w-16 h-16 rounded-xl border border-zinc-100 shrink-0 overflow-hidden bg-zinc-50">
|
||||
<img
|
||||
src={prod.image}
|
||||
alt={prod.name}
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{storesLoading && products.length === 0 ? (
|
||||
<div className="text-center py-xl text-zinc-400 text-xs">Loading global catalog…</div>
|
||||
) : filteredProducts.length === 0 ? (
|
||||
<div className="text-center py-xl text-zinc-400 text-xs">No catalog products match your search or category.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-sm">
|
||||
{filteredProducts.map((prod) => (
|
||||
<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">
|
||||
<img src={prod.image} alt={prod.name} referrerPolicy="no-referrer" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-bold text-zinc-900 leading-tight text-xs truncate">{prod.name}</h4>
|
||||
<span className="text-[10px] text-zinc-400 font-bold tracking-tight">{prod.sku}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase bg-purple-50 text-purple-700">
|
||||
{prod.category.split(' / ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div>
|
||||
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-bold">Sold (Units)</span>
|
||||
<span className="font-bold text-zinc-800 font-mono">{prod.unitsSold.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-bold">Total revenue</span>
|
||||
<span className="font-bold text-emerald-600 font-mono">₹{prod.revenue.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div>
|
||||
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-bold">Sold (Units)</span>
|
||||
<span className="font-bold text-zinc-800 font-mono">{prod.unitsSold.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-bold">Total revenue</span>
|
||||
<span className="font-bold text-emerald-600 font-mono">₹{prod.revenue.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Store Stock — live per-outlet breakdown for every store under the admin */}
|
||||
<div className="space-y-md text-xs font-sans">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-2">
|
||||
<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>
|
||||
|
||||
{/* Right Pane: Stock level adjustment ledgers */}
|
||||
<div className="space-y-md">
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm space-y-md">
|
||||
<div>
|
||||
<h3 className="font-sans font-bold text-sm text-[#0f172a]">Hub Balances Ledger</h3>
|
||||
<p className="text-zinc-500 font-normal leading-relaxed text-[11px] mt-0.5">Physical checkout balances across localized Coimbatore warehouse locations.</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#f1f5f9] select-none">
|
||||
{inventory.map((item, idx) => {
|
||||
const percentage = (item.stockLevel / item.maxCapacity) * 100;
|
||||
return (
|
||||
<div key={idx} className="py-md space-y-xs">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-bold text-[#0f172a]">{item.name}</p>
|
||||
<p className="text-[10px] text-zinc-400 mt-1 font-medium">{item.warehouse}</p>
|
||||
<div className="flex gap-px pt-1 items-center">
|
||||
<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>
|
||||
<span className={`px-1.5 py-0.5 rounded text-[8px] font-bold tracking-wide uppercase ${
|
||||
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'
|
||||
}`}>
|
||||
● {item.status}
|
||||
</span>
|
||||
</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>
|
||||
)}
|
||||
{locations.length === 0 ? (
|
||||
<div className="text-center py-xl text-zinc-400 text-xs border border-dashed border-[#e2e8f0] rounded-xl bg-white">
|
||||
{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 (
|
||||
<div key={store.locationid} className="bg-white border border-[#e2e8f0] rounded-xl shadow-sm overflow-hidden flex flex-col">
|
||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc]">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-[#0f172a] truncate">{store.locationname}</p>
|
||||
<p className="text-[10px] text-zinc-400 font-medium mt-0.5">
|
||||
{store.isLoading ? 'Syncing…' : `${items.length} SKUs · ${totalUnits.toLocaleString('en-IN')} units on hand`}
|
||||
</p>
|
||||
</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'
|
||||
}`}>
|
||||
{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>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -23,18 +23,15 @@ import {
|
||||
Download,
|
||||
AlertOctagon,
|
||||
X,
|
||||
Calendar,
|
||||
FileSpreadsheet
|
||||
} from 'lucide-react';
|
||||
import { initialImportLogs } from '../data';
|
||||
import { InventoryItem, OrderItem } from '../types';
|
||||
import { InventoryItem } from '../types';
|
||||
import {
|
||||
useFiestaStockStatement,
|
||||
useFiestaDeliveries,
|
||||
useFiestaTenantLocations,
|
||||
} from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
|
||||
import { stockRowToProduct, stockRowToInventory, mapOrderStatus, shortTime } from '../services/fiestaMappers';
|
||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr } from '../services/fiestaApi';
|
||||
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
||||
|
||||
interface OperationsViewProps {
|
||||
searchQuery: string;
|
||||
@@ -43,29 +40,9 @@ interface OperationsViewProps {
|
||||
|
||||
export default function OperationsView({ searchQuery, isCoimbatoreView }: OperationsViewProps) {
|
||||
// Sub-tabs state
|
||||
const [activeSubTab, setActiveSubTab] = useState<'inventory' | 'catalogue' | 'orders' | 'import'>('inventory');
|
||||
const [activeSubTab, setActiveSubTab] = useState<'inventory' | 'catalogue' | 'import'>('inventory');
|
||||
|
||||
// ── 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 primaryLocation =
|
||||
(locationsQ.data ?? []).find((l) => Number(l.locationid) === FIESTA_PRIMARY_LOCATION_ID) ||
|
||||
@@ -80,8 +57,6 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
pageno: 1,
|
||||
pagesize: 100,
|
||||
});
|
||||
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
||||
|
||||
// Total inventory value = Σ closing × unit cost across the live stock statement.
|
||||
const inventoryValue = (stockQ.data ?? []).reduce(
|
||||
(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).
|
||||
const [inventoryList, setInventoryList] = useState<InventoryItem[]>([]);
|
||||
const [productList, setProductList] = useState<ReturnType<typeof stockRowToProduct>[]>([]);
|
||||
const [orderList, setOrderList] = useState<OrderItem[]>([]);
|
||||
const [importLogs, setImportLogs] = useState(initialImportLogs);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -101,23 +75,6 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
}
|
||||
}, [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
|
||||
const [showAddSkuModal, setShowAddSkuModal] = useState(false);
|
||||
const [showTransferModal, setShowTransferModal] = useState(false);
|
||||
@@ -153,11 +110,6 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
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
|
||||
const handleAddSku = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -239,7 +191,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
|
||||
{/* Dynamic Nav Sub-Tabs */}
|
||||
<nav className="flex gap-lg">
|
||||
{(['inventory', 'catalogue', 'orders', 'import'] as const).map((tab) => (
|
||||
{(['inventory', 'catalogue', 'import'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveSubTab(tab)}
|
||||
@@ -594,107 +546,6 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
</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' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-gutter animate-in slide-in-from-right-5">
|
||||
{/* Upload panel zone */}
|
||||
|
||||
@@ -483,7 +483,11 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{selectedOrder ? (
|
||||
</div>
|
||||
|
||||
{/* Right column — Order Details, shown parallel to the orders feed */}
|
||||
<div className="lg:col-span-1 space-y-md">
|
||||
{selectedOrder ? (
|
||||
<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]">
|
||||
Order Details: {selectedOrder.id}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Truck,
|
||||
CreditCard,
|
||||
SlidersHorizontal,
|
||||
Users,
|
||||
MapPin,
|
||||
Phone,
|
||||
Mail,
|
||||
@@ -19,12 +20,13 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useFiestaAllTenants, useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||
import UsersPanel from './UsersPanel';
|
||||
|
||||
interface SettingsViewProps {
|
||||
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). */
|
||||
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 }> = [
|
||||
{ key: 'profile', label: 'Business Profile', icon: Building2 },
|
||||
{ key: 'outlets', label: 'Outlets', icon: Store },
|
||||
{ key: 'users', label: 'Users & Access', icon: Users },
|
||||
{ key: 'delivery', label: 'Delivery', icon: Truck },
|
||||
{ key: 'payment', label: 'Payment & Tax', icon: CreditCard },
|
||||
{ key: 'preferences', label: 'Preferences', icon: SlidersHorizontal },
|
||||
@@ -366,6 +369,10 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'users' && (
|
||||
<UsersPanel tenantId={tenantId} defaultNewUserRole={form.defaultNewUserRole} />
|
||||
)}
|
||||
|
||||
{activeTab === 'delivery' && (
|
||||
<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]">
|
||||
@@ -474,8 +481,9 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
{/* Save / Reset — lives with the settings card, not pinned to the screen.
|
||||
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'}`}>
|
||||
{dirty ? '● You have unsaved changes' : 'All changes saved'}
|
||||
</span>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Store,
|
||||
Layers,
|
||||
ShoppingBag,
|
||||
Users,
|
||||
Settings
|
||||
} from 'lucide-react';
|
||||
import { MainSection } from '../types';
|
||||
@@ -33,8 +32,7 @@ export default function Sidebar({
|
||||
const navItems = [
|
||||
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ id: 'stores' as MainSection, label: 'Stores', icon: Store },
|
||||
{ id: 'inventory' as MainSection, label: 'Inventory Catalog', icon: Layers },
|
||||
{ id: 'users' as MainSection, label: 'Users', icon: Users },
|
||||
{ id: 'inventory' as MainSection, label: 'Product Catalog', icon: Layers },
|
||||
{ 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`.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query';
|
||||
import type { Row } from './fiestaApi';
|
||||
import {
|
||||
FIESTA_TENANT_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 ─────────────────────────────────────────────────────────────────────
|
||||
export function useFiestaUsers(opts: {
|
||||
tenantid: number;
|
||||
|
||||
Reference in New Issue
Block a user