updates on the ui changes and changed into doormile
This commit is contained in:
324
src/pages/doormile/appUsers/AppUsers.jsx
Normal file
324
src/pages/doormile/appUsers/AppUsers.jsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Pencil, Plus, ShieldCheck, Trash2, Users } from 'lucide-react';
|
||||
import {
|
||||
Avatar, Button, ConfirmModal, DataTable, EmptyState, Field, Grid, IconButton, Input,
|
||||
KpiCard, Modal, PageHeader, Select, SelectContent, SelectItem, SelectTrigger,
|
||||
SelectValue, Stack, StatusBadge,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import { useAppUsers, useCreateAppUser, useDeleteAppUser, useUpdateAppUser } from '@/lib/doormileHooks';
|
||||
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* App users — the staff logins for this console.
|
||||
*
|
||||
* `GET /admin/users` returns `{ id, email, first_name, phone, role, status }`.
|
||||
* `role` is free text there ("admin" and "rep" both seen live); the numeric
|
||||
* role ids in the auth documentation describe the JWT encoding used at login,
|
||||
* not this field, so the picker offers the observed strings rather than ids.
|
||||
*/
|
||||
|
||||
const ROLES = ['admin', 'manager', 'executive', 'rep'];
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'Active', label: 'Active' },
|
||||
{ value: 'Inactive', label: 'Inactive' },
|
||||
];
|
||||
|
||||
const titleCase = (value) => (value ? String(value).charAt(0).toUpperCase() + String(value).slice(1) : '');
|
||||
|
||||
const EMPTY_FORM = { id: null, first_name: '', email: '', phone: '', password: '', role: 'admin', status: 'Active' };
|
||||
|
||||
export default function AppUsers() {
|
||||
const { data: users = [], isLoading, isFetching } = useAppUsers();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [pendingDelete, setPendingDelete] = useState(null);
|
||||
|
||||
const createUser = useCreateAppUser();
|
||||
const updateUser = useUpdateAppUser();
|
||||
const deleteUser = useDeleteAppUser();
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = users.length;
|
||||
return {
|
||||
total,
|
||||
admins: users.filter((user) => String(user.role || '').toLowerCase() === 'admin').length,
|
||||
active: users.filter((user) => String(user.status || '').toLowerCase() === 'active').length,
|
||||
};
|
||||
}, [users]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
let list = users;
|
||||
if (activeFilter === 'admin') list = list.filter((u) => String(u.role || '').toLowerCase() === 'admin');
|
||||
else if (activeFilter === 'active') list = list.filter((u) => String(u.status || '').toLowerCase() === 'active');
|
||||
return list.filter((u) => matchesQuery(u, ['first_name', 'email', 'phone', 'role'], debouncedSearch));
|
||||
}, [users, activeFilter, debouncedSearch]);
|
||||
|
||||
const toggleFilter = (key) => setActiveFilter((current) => (current === key ? 'all' : key));
|
||||
const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setErrors({});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row) => {
|
||||
setForm({
|
||||
id: row.id,
|
||||
first_name: row.first_name || '',
|
||||
email: row.email || '',
|
||||
phone: row.phone || '',
|
||||
/* Never prefilled, and left blank means "leave the password alone" — see
|
||||
the save path, which strips the key rather than sending an empty one. */
|
||||
password: '',
|
||||
role: row.role || 'admin',
|
||||
status: row.status || 'Active',
|
||||
});
|
||||
setErrors({});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const nextErrors = {};
|
||||
if (!form.first_name.trim()) nextErrors.first_name = 'Enter a name';
|
||||
if (!form.email.trim()) nextErrors.email = 'Enter an email address';
|
||||
if (!form.id && !form.password) nextErrors.password = 'Set a password for the new account';
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length) return;
|
||||
|
||||
const { id, ...data } = form;
|
||||
/* An empty password on an edit is "unchanged", not "blank it" — sending the
|
||||
empty string would set the account's password to nothing. */
|
||||
if (id && !data.password) delete data.password;
|
||||
|
||||
const mutation = id ? updateUser : createUser;
|
||||
const variables = id ? { id, data } : data;
|
||||
mutation.mutate(variables, { onSuccess: (res) => res?.success !== false && setDialogOpen(false) });
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'first_name',
|
||||
header: 'User',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<Avatar name={row.first_name || row.email} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">{orDash(row.first_name)}</p>
|
||||
<p className="truncate text-caption text-ink-3">{orDash(row.email)}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'phone', header: 'Phone', sortable: true, accessor: (row) => orDash(row.phone), hideBelow: 'md' },
|
||||
{
|
||||
key: 'role',
|
||||
header: 'Role',
|
||||
sortable: true,
|
||||
accessor: (row) => titleCase(row.role),
|
||||
cell: (row) => <StatusBadge status={row.role} label={titleCase(row.role) || '—'} tone="info" size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
cell: (row) => <StatusBadge status={row.status} dot size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
cell: (row) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<IconButton label="Edit user" icon={Pencil} variant="ghost" size="sm" onClick={() => openEdit(row)} />
|
||||
<IconButton label="Remove user" icon={Trash2} variant="ghost" size="sm" onClick={() => setPendingDelete(row)} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Users}
|
||||
title="App users"
|
||||
subtitle="Staff logins for the Doormile Express console"
|
||||
actions={
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> New user
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={3}>
|
||||
<KpiCard
|
||||
icon={Users}
|
||||
value={stats.total}
|
||||
label="Total users"
|
||||
tone="brand"
|
||||
index={0}
|
||||
loading={isLoading}
|
||||
onClick={() => setActiveFilter('all')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={ShieldCheck}
|
||||
value={stats.admins}
|
||||
label={`Administrators · ${percentOf(stats.admins, stats.total)}%`}
|
||||
tone="navy"
|
||||
index={1}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('admin')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Users}
|
||||
value={stats.active}
|
||||
label={`Active · ${percentOf(stats.active, stats.total)}%`}
|
||||
tone="success"
|
||||
index={2}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('active')}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Stack space="sm">
|
||||
<ListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search by name, email, phone or role"
|
||||
count={{ shown: rows.length, total: users.length, noun: 'users' }}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row) => row.id}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
pageSize={10}
|
||||
defaultSort={{ key: 'first_name', direction: 'asc' }}
|
||||
isFiltered={Boolean(debouncedSearch) || activeFilter !== 'all'}
|
||||
onClearFilters={() => {
|
||||
setSearch('');
|
||||
setActiveFilter('all');
|
||||
}}
|
||||
emptyState={
|
||||
debouncedSearch || activeFilter !== 'all' ? undefined : (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No console users yet"
|
||||
description="Create the first staff login so someone other than you can work the board."
|
||||
action={{ label: 'New user', icon: Plus, onClick: openCreate }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={form.id ? 'Edit user' : 'New user'}
|
||||
icon={Users}
|
||||
size="lg"
|
||||
busy={createUser.isPending || updateUser.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save} disabled={createUser.isPending || updateUser.isPending}>
|
||||
{form.id ? 'Save changes' : 'Create user'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Name" required error={errors.first_name}>
|
||||
<Input value={form.first_name} onChange={(e) => set('first_name')(e.target.value)} placeholder="Priya R" />
|
||||
</Field>
|
||||
|
||||
<Field label="Email" required error={errors.email}>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => set('email')(e.target.value)}
|
||||
placeholder="priya@doormile.com"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Phone">
|
||||
<Input value={form.phone} onChange={(e) => set('phone')(e.target.value)} placeholder="9876543210" />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Password"
|
||||
required={!form.id}
|
||||
error={errors.password}
|
||||
hint={form.id ? 'Leave blank to keep the current password' : undefined}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.password}
|
||||
onChange={(e) => set('password')(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Role">
|
||||
<Select value={form.role} onValueChange={set('role')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLES.map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{titleCase(role)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Status">
|
||||
<Select value={form.status} onValueChange={set('status')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(pendingDelete)}
|
||||
onOpenChange={(open) => !open && setPendingDelete(null)}
|
||||
title="Remove this user?"
|
||||
description={`${pendingDelete?.first_name || pendingDelete?.email || 'This account'} will lose access to the console immediately.`}
|
||||
confirmLabel="Remove user"
|
||||
busy={deleteUser.isPending}
|
||||
onConfirm={() => {
|
||||
deleteUser.mutate(pendingDelete.id, { onSettled: () => setPendingDelete(null) });
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user