new changes

This commit is contained in:
2026-06-04 11:40:06 +05:30
parent 6eaeb5c4a7
commit a11a859761
10 changed files with 678 additions and 801 deletions

70
package-lock.json generated
View File

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

View File

@@ -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>
);
}

View File

@@ -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 },
];

View File

@@ -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>
</>
) : (

View File

@@ -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 */}

View File

@@ -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}

View File

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

View File

@@ -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 }
];

View 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>
);
}

View File

@@ -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;