You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq-web/src/pages/Employees.tsx

195 lines
8.8 KiB
TypeScript

import { useState } from 'react'
import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
import {
createEmployee, deactivateEmployee, getEmployees, patchEmployee,
reactivateEmployee, resetEmployeePassword,
EMPLOYEE_ROLES, type Employee, type EmployeeRole,
} from '../api'
import { useData } from './Clients'
/**
* Employee management (owner-only page; the nav item is hidden for others and the
* server 403s every mutation anyway). Standard table per spec §6a: name, email,
* role, active, actions — plus add / edit / reset-password forms. Email is the
* login id and is immutable once created.
*/
export function Employees() {
const list = useData(getEmployees, [])
const [adding, setAdding] = useState(false)
const [editing, setEditing] = useState<Employee | undefined>()
const [resetting, setResetting] = useState<Employee | undefined>()
const [error, setError] = useState<string | undefined>()
const act = (p: Promise<unknown>) => {
setError(undefined)
p.then(() => list.reload()).catch((e: Error) => setError(e.message))
}
const employees = list.data?.employees
return (
<div className="wf-page">
<PageHeader
title="Employees"
desc="Console users — owner manages accounts, roles and passwords. Deactivation locks the account out immediately."
actions={(
<Button tone="primary" onClick={() => { setAdding((v) => !v); setEditing(undefined); setResetting(undefined) }}>
+ Add employee
</Button>
)}
/>
{adding && (
<AddEmployeeForm
onDone={() => { setAdding(false); list.reload() }}
onCancel={() => setAdding(false)}
/>
)}
{editing !== undefined && (
<EditEmployeeForm
key={editing.id}
employee={editing}
onDone={() => { setEditing(undefined); list.reload() }}
onCancel={() => setEditing(undefined)}
/>
)}
{resetting !== undefined && (
<ResetPasswordForm
key={resetting.id}
employee={resetting}
onDone={() => { setResetting(undefined); list.reload() }}
onCancel={() => setResetting(undefined)}
/>
)}
{error !== undefined && <Notice tone="err">{error}</Notice>}
{list.error !== undefined && <Notice tone="err">{list.error}</Notice>}
{/* Branch on the fetch error first — `employees` stays undefined on failure,
so falling through would show "Loading…" forever under the error notice. */}
{list.error !== undefined ? null
: employees === undefined ? <EmptyState>Loading</EmptyState>
: employees.length === 0 ? <EmptyState>No employees yet.</EmptyState> : (
<>
<DataTable
columns={[
{ key: 'name', label: 'Name' }, { key: 'email', label: 'Email' },
{ key: 'role', label: 'Role' }, { key: 'active', label: 'Active' },
{ key: 'actions', label: 'Actions' },
]}
rows={employees.map((e) => ({
name: e.displayName, email: e.email,
role: <Badge tone={e.role === 'owner' ? 'accent' : undefined}>{e.role}</Badge>,
active: e.active ? <Badge tone="ok">active</Badge> : <Badge tone="err">inactive</Badge>,
actions: (
<span style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<Button onClick={() => { setEditing(e); setAdding(false); setResetting(undefined) }}>Edit</Button>
<Button onClick={() => { setResetting(e); setAdding(false); setEditing(undefined) }}>Reset pw</Button>
{e.active
? <Button tone="danger" onClick={() => act(deactivateEmployee(e.id))}>Deactivate</Button>
: <Button onClick={() => act(reactivateEmployee(e.id))}>Reactivate</Button>}
</span>
),
}))}
/>
<Toolbar>
<Badge>{list.data!.total} employee{list.data!.total === 1 ? '' : 's'}</Badge>
<span style={{ fontSize: 12, opacity: 0.7 }}>
Guards: the last active owner cannot be demoted or deactivated; you cannot deactivate yourself.
</span>
</Toolbar>
</>
)}
</div>
)
}
function AddEmployeeForm(props: { onDone: () => void; onCancel: () => void }) {
const [f, setF] = useState({ email: '', displayName: '', role: 'staff' as EmployeeRole, password: '' })
const [error, setError] = useState<string | undefined>()
const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
setF((p) => ({ ...p, [k]: e.target.value }))
const save = () => {
if (f.email.trim() === '' || f.displayName.trim() === '') { setError('Email and name are required'); return }
if (f.password.length < 8) { setError('Initial password must be at least 8 characters'); return }
setError(undefined)
createEmployee({ email: f.email.trim(), displayName: f.displayName.trim(), role: f.role, password: f.password })
.then(props.onDone)
.catch((e: Error) => setError(e.message))
}
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Email (login id)"><input className="wf" type="email" autoFocus value={f.email} onChange={set('email')} /></Field>
<Field label="Name"><input className="wf" value={f.displayName} onChange={set('displayName')} /></Field>
<Field label="Role">
<select className="wf" value={f.role} onChange={set('role')}>
{EMPLOYEE_ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</Field>
<Field label="Initial password (min 8)"><input className="wf" type="password" value={f.password} onChange={set('password')} /></Field>
<Button tone="primary" onClick={save}>Create employee</Button>
<Button onClick={props.onCancel}>Cancel</Button>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}
function EditEmployeeForm(props: { employee: Employee; onDone: () => void; onCancel: () => void }) {
const [f, setF] = useState({ displayName: props.employee.displayName, role: props.employee.role })
const [error, setError] = useState<string | undefined>()
const save = () => {
if (f.displayName.trim() === '') { setError('Name is required'); return }
setError(undefined)
patchEmployee(props.employee.id, { displayName: f.displayName.trim(), role: f.role })
.then(props.onDone)
.catch((e: Error) => setError(e.message))
}
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
{/* Email is the login id — immutable after creation. */}
<Field label="Email (immutable)"><input className="wf" value={props.employee.email} disabled /></Field>
<Field label="Name">
<input className="wf" autoFocus value={f.displayName} onChange={(e) => setF((p) => ({ ...p, displayName: e.target.value }))} />
</Field>
<Field label="Role">
<select className="wf" value={f.role} onChange={(e) => setF((p) => ({ ...p, role: e.target.value as EmployeeRole }))}>
{EMPLOYEE_ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</Field>
<Button tone="primary" onClick={save}>Save changes</Button>
<Button onClick={props.onCancel}>Cancel</Button>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}
function ResetPasswordForm(props: { employee: Employee; onDone: () => void; onCancel: () => void }) {
const [password, setPassword] = useState('')
const [error, setError] = useState<string | undefined>()
const save = () => {
if (password.length < 8) { setError('New password must be at least 8 characters'); return }
setError(undefined)
resetEmployeePassword(props.employee.id, password)
.then(props.onDone)
.catch((e: Error) => setError(e.message))
}
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label={`New password for ${props.employee.email} (min 8)`}>
<input className="wf" type="password" autoFocus value={password} onChange={(e) => setPassword(e.target.value)} />
</Field>
<Button tone="primary" onClick={save}>Reset password</Button>
<Button onClick={props.onCancel}>Cancel</Button>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
)
}