/** * @license * SPDX-License-Identifier: Apache-2.0 */ /** * Users & Access — tenant staff directory with role filtering and user creation. * Rendered as a tab inside SettingsView. * Self-contained: search box, role filter, live query, and Add User modal. */ import React, { useState, useEffect } from 'react'; import { Users, Search, X, Plus, ShieldAlert, Shield, User, Mail, Phone, MapPin, UserCheck, Check, SlidersHorizontal, Coins, Store, Bike, Clock } from 'lucide-react'; import { useFiestaUsers, useFiestaCreateUser, useFiestaUpdateUser, useFiestaRiderShifts, useFiestaTenantLocations } from '../services/fiestaQueries'; import { useAppRoles } from '../services/queries'; import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi'; import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete'; 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', ]; const ROLE_THEMES: Record = { 1: { bg: 'bg-rose-50/75', text: 'text-rose-700', border: 'border-rose-100', label: 'Owner' }, 2: { bg: 'bg-amber-50/75', text: 'text-amber-700', border: 'border-amber-100', label: 'Admin' }, 3: { bg: 'bg-blue-50/75', text: 'text-blue-700', border: 'border-blue-100', label: 'Admin' }, 4: { bg: 'bg-emerald-50/75', text: 'text-emerald-700', border: 'border-emerald-100', label: 'Staff' }, 5: { bg: 'bg-purple-50/75', text: 'text-purple-700', border: 'border-purple-100', label: 'Rider' }, 6: { bg: 'bg-indigo-50/75', text: 'text-indigo-700', border: 'border-indigo-100', label: 'Cashier' }, }; /** Cosmetic icon + blurb per role id, used to keep the add-user role cards styled. */ const ROLE_META: Record = { 1: { icon: ShieldAlert, desc: 'Full business access' }, 2: { icon: Shield, desc: 'Operations control' }, 3: { icon: SlidersHorizontal, desc: 'Manage store settings' }, 4: { icon: User, desc: 'Standard staff duties' }, 6: { icon: Coins, desc: 'Checkout & registers' }, }; /** Fallback role choices when the app-roles API returns nothing. */ const FALLBACK_ROLE_CHOICES = [ { id: 1, label: 'Owner', desc: 'Full business access', icon: ShieldAlert }, { id: 2, label: 'Admin', desc: 'Operations control', icon: Shield }, { id: 3, label: 'Admin', desc: 'Manage store settings', icon: SlidersHorizontal }, { id: 4, label: 'Staff', desc: 'Standard staff duties', icon: User }, { id: 6, label: 'Cashier', desc: 'Checkout & registers', icon: Coins }, ]; export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUserRole = 4 }: UsersPanelProps) { const usersQ = useFiestaUsers({ tenantid: tenantId, pagesize: 100 }); const createUserMut = useFiestaCreateUser(); const updateUserMut = useFiestaUpdateUser(); const rolesQ = useAppRoles(); const [assignUserId, setAssignUserId] = useState(null); const [assignLocationId, setAssignLocationId] = useState(0); const [assignRoleId, setAssignRoleId] = useState(4); const handleToggleAssign = async (userid: number, currentRoleId: number) => { try { if (currentRoleId <= 0) { // Open the assign modal setAssignUserId(userid); if (storeOptions.length === 1) { setAssignLocationId(storeOptions[0].locationid); } } else { // Unassign immediately. We use -1 because the Go backend ignores 0 (zero value) during updates. await updateUserMut.mutateAsync({ userid, roleid: -1, locationid: -1, applocationid: -1 }); } } catch (err) { alert(`Could not update user role: ${err instanceof Error ? err.message : 'Unknown error'}`); } }; // Selectable roles for the Add User modal — driven by the live app-roles API, // matched to local icon/desc styling by roleid; falls back to the static list. // Selectable roles for the Add User modal - limited to Staff and Rider. const roleChoices = React.useMemo(() => { return [ { id: 4, label: 'Staff', desc: 'Standard store staff duties', icon: User, configid: 15 }, { id: 5, label: 'Rider', desc: 'Delivery fleet rider', icon: Bike, configid: 6 }, ]; }, []); const [search, setSearch] = useState(''); const [userRoleFilter, setUserRoleFilter] = useState('ALL'); const [showAddUserModal, setShowAddUserModal] = useState(false); const [newUser, setNewUser] = useState({ firstname: '', email: '', contactno: '', roleid: defaultNewUserRole, locationid: 0, applocationid: 0, address: '', suburb: '', city: '', state: '', postcode: '', latitude: '', longitude: '', shiftid: 0, }); // Stores/branches for this merchant — the new user is bound to the store the // admin picks (its locationid + applocationid go into the create payload). const locationsQ = useFiestaTenantLocations(tenantId); const allStores = (locationsQ.data ?? []) .map((l) => ({ locationid: fnum((l as Record).locationid), applocationid: fnum((l as Record).applocationid) || 1, name: fstr((l as Record).locationname) || `Store ${fnum((l as Record).locationid)}`, address: fstr((l as Record).address), status: fstr((l as Record).status), })) .filter((s) => s.locationid > 0); // Prefer Active stores (per the create-staff flow); fall back to all if a tenant // doesn't flag status, so the picker is never empty. const activeStores = allStores.filter((s) => s.status.toLowerCase() === 'active'); const storeOptions = activeStores.length ? activeStores : allStores; // Auto-bind when the merchant has exactly one store (nothing to choose). useEffect(() => { if (!newUser.locationid && storeOptions.length === 1) { setNewUser((u) => ({ ...u, locationid: storeOptions[0].locationid, applocationid: storeOptions[0].applocationid })); } if (!assignLocationId && storeOptions.length === 1) { setAssignLocationId(storeOptions[0].locationid); } }, [storeOptions.length, newUser.locationid, assignLocationId]); // Rider-shift picker — shown only when a rider role is selected (parity with the // merchant_web create form). Shifts come from the live partners/getridershifts. const selectedRole = roleChoices.find((r) => r.id === newUser.roleid); const isRiderRole = (selectedRole?.label || '').toLowerCase().includes('rider') || newUser.roleid === 5; const shiftsQ = useFiestaRiderShifts(); const shiftOptions = (shiftsQ.data ?? []) .map((s) => ({ id: fnum((s as Record).shiftid), label: fstr((s as Record).shiftname) || `Shift ${fnum((s as Record).shiftid)}` })) .filter((s) => s.id > 0); // Address autocomplete → discrete fields (or clear them when the field is emptied). const handleAddressSelect = (r: AddressResult | null) => { setNewUser((u) => ({ ...u, address: r?.address ?? '', suburb: r?.suburb ?? '', city: r?.city ?? '', state: r?.state ?? '', postcode: r?.postcode ?? '', latitude: r?.latitude ?? '', longitude: r?.longitude ?? '', })); }; // 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)) === 'Manager' ? 'Admin' : 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.role === userRoleFilter; return matchesSearch && matchesRole; }); const roleOptions = React.useMemo(() => { const roles = new Set(users.map((u) => u.role)); roles.add('Admin'); roles.add('Rider'); return Array.from(roles); }, [users]); const handleCreateUser = async (e: React.FormEvent) => { e.preventDefault(); if (!newUser.firstname || !newUser.email || !newUser.contactno) { alert('Please provide first name, contact number, and email.'); return; } if (!/^\d{10}$/.test(newUser.contactno.trim())) { alert('Contact number must be exactly 10 digits.'); return; } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newUser.email.trim())) { alert('Please enter a valid email address.'); return; } if (!Number(newUser.roleid)) { alert('Please select a role.'); return; } if (!newUser.locationid) { alert('Please select the store this user belongs to.'); return; } try { await createUserMut.mutateAsync({ firstname: newUser.firstname, email: newUser.email, contactno: newUser.contactno, roleid: Number(newUser.roleid), configid: selectedRole?.configid ?? 15, // Store binding — sent exactly as the backend expects so the user is tied // to the chosen branch (tenantid + locationid + applocationid). tenantid: tenantId, locationid: newUser.locationid, applocationid: newUser.applocationid || 1, address: newUser.address, suburb: newUser.suburb, city: newUser.city, state: newUser.state, postcode: newUser.postcode, latitude: newUser.latitude, longitude: newUser.longitude, shiftid: isRiderRole ? Number(newUser.shiftid) || 0 : 0, }); setShowAddUserModal(false); setNewUser({ firstname: '', email: '', contactno: '', roleid: defaultNewUserRole, locationid: 0, applocationid: 0, address: '', suburb: '', city: '', state: '', postcode: '', latitude: '', longitude: '', shiftid: 0 }); alert(`Team member "${newUser.firstname}" added successfully.`); } catch (err) { alert(`Could not add team member: ${err instanceof Error ? err.message : 'Unknown error'}`); } }; return (
{/* Header section */}
Users & Access

Manage Store Team

Manage your store team, access roles, and contact details.

{usersQ.isLoading ? ( Loading team list… ) : usersQ.isError ? ( Connection issue ) : ( Active · {users.length} Team Members )}
{/* Search & Filter Utility Bar */}
setSearch(e.target.value)} className="w-full pl-3 pr-10 py-2.5 bg-transparent border-none text-sm font-semibold text-slate-800 placeholder-slate-400 focus:outline-none outline-none" /> {search && ( )}
{/* Role filter capsules */}
{roleOptions.map((roleNameStr) => ( ))}
{/* Directory Grid */}
{filteredUsers.length === 0 ? (
{usersQ.isLoading ? 'Loading team list…' : 'No matches found in team list.'}
) : ( filteredUsers.map((u) => { const roleInfo = ROLE_THEMES[u.roleid] || { bg: 'bg-slate-55', text: 'text-slate-700', border: 'border-slate-100', label: u.role }; return (
{/* Background aura gradient effect on hover */}
{/* User Avatar with status indicator ring */}
{u.name}

{u.name}

{u.email}

{/* Metadata fields */}
{u.contact}
{u.location}
{u.shift && u.shift !== '—' && (
Shift {u.shift}
)}
{u.roleid === 1 && } {u.roleid === 2 && } {u.roleid === 3 && } {u.roleid === 4 && } {u.roleid === 5 && } {u.roleid === 6 && } {roleInfo.label} {u.roleid !== 1 && ( )}
#{u.userid}
); }) )}
{/* CREATE NEW USER MODAL */} {showAddUserModal && (
{ if (e.target === e.currentTarget) setShowAddUserModal(false); }} >
{/* Modal Header */}

Add Team Member

{/* Modal Form */}

Add a new member to your store team. This will create their account and sync it to the list.

{/* Firstname */}
setNewUser({ ...newUser, firstname: e.target.value })} className="w-full border border-slate-200 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm" required />
{/* Contactno */}
setNewUser({ ...newUser, contactno: e.target.value.replace(/\D/g, '') })} className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm" required />
{/* Email */}
setNewUser({ ...newUser, email: e.target.value })} className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm" required />
{/* Interactive Role Buttons instead of standard select */}
{roleChoices.map((r) => { const isSelected = newUser.roleid === r.id; const Icon = r.icon; return ( ); })}
{/* Store / branch — binds the user to a specific outlet (tenantid + locationid + applocationid) */}
{/* Rider shift — only when a rider role is selected */} {isRiderRole && (
)} {/* Address — keyless autocomplete that fills suburb / city / state / postcode */}
{(['suburb', 'city', 'state', 'postcode'] as const).map((key) => (
setNewUser({ ...newUser, [key]: e.target.value })} className="w-full border border-slate-200 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm" />
))}
{/* Modal Footer */}
)} {/* ASSIGN USER MODAL */} {assignUserId !== null && (
{ if (e.target === e.currentTarget) setAssignUserId(null); }} >

Assign Team Member

{ e.preventDefault(); const store = storeOptions.find(s => s.locationid === assignLocationId); try { await updateUserMut.mutateAsync({ userid: assignUserId, roleid: assignRoleId, locationid: assignLocationId, applocationid: store?.applocationid ?? 1 }); setAssignUserId(null); } catch (err) { alert(`Could not assign user: ${err instanceof Error ? err.message : 'Unknown error'}`); } }} className="p-6 space-y-5">
)}
); }