This repository has been archived on 2026-08-11. You can view files and clone it, but cannot push or open issues or pull requests.
Gishen-Admin/src/pages/profile/ProfilePage.tsx
kirukib fce0149b1a Ship full Admin console polish and update backend spec.
Add medication Items catalogue, counter POS/QR purchase, profile/login,
detail pages, loyalty ledger, Cmd+K nav, KPI strips/charts, avatars, and
i18n; document the new APIs and open questions for the shared backend team.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 00:03:21 +03:00

746 lines
30 KiB
TypeScript

import { useMemo, useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
Bell,
Building2,
Check,
KeyRound,
Languages,
LogOut,
Monitor,
Pencil,
ShieldCheck,
SlidersHorizontal,
Sparkles,
UserRound,
X,
} from 'lucide-react'
import { DetailSection } from '@/components/detail/ActivityTimeline'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { Separator } from '@/components/ui/separator'
import { PersonAvatar } from '@/components/ui/avatar'
import { StatusBadge } from '@/components/ui/status-badge'
import { EmptyState } from '@/components/ui/empty-state'
import { IconTile } from '@/components/ui/icon-tile'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { branchProfiles, branches, staffActivity, staffProfiles, staffSessions } from '@/mocks/data'
import type { StaffProfile, StaffSession } from '@/mocks/data'
import { canAccess, demoAccounts, roleModules } from '@/config/permissions'
import { sidebarEntries } from '@/config/navigation'
import { useAuthStore } from '@/zustand/authStore'
import { formatDate, formatDateTime, formatRelative, humanize } from '@/lib/utils'
import i18n from '@/i18n/config'
const LOCALE_LABEL: Record<'en' | 'am', string> = { en: 'English', am: 'አማርኛ' }
const NOTIFICATION_FIELDS: { key: keyof StaffProfile['notifications']; label: string; description: string }[] = [
{ key: 'orderAlerts', label: 'Order alerts', description: 'New and at-risk orders on your branch' },
{ key: 'lowStockAlerts', label: 'Low stock alerts', description: 'Reorder points and near-expiry batches' },
{ key: 'weeklyDigest', label: 'Weekly digest', description: 'A Monday summary of your modules' },
{ key: 'securityAlerts', label: 'Security alerts', description: 'New sign-ins and permission changes' },
]
function fallbackProfile(id: string): StaffProfile {
return {
id,
jobTitle: 'Staff member',
phone: '',
status: 'active',
joinedAt: new Date().toISOString(),
lastSignInAt: new Date().toISOString(),
lastSignInFrom: 'Addis Ababa, Ethiopia',
passwordUpdatedAt: new Date().toISOString(),
twoFactorEnabled: false,
preferredLocale: 'en',
notifications: { orderAlerts: false, lowStockAlerts: false, weeklyDigest: false, securityAlerts: true },
}
}
function ToggleSwitch({
checked,
onChange,
label,
}: {
checked: boolean
onChange: (next: boolean) => void
label: string
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
checked ? 'bg-brand-500' : 'bg-grayScale-300'
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
checked ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
)
}
export function ProfilePage() {
const { t } = useTranslation()
const user = useAuthStore((s) => s.user)
const updateUser = useAuthStore((s) => s.updateUser)
// Resolved once: editing your own email must not detach the page from its mocks.
const [accountId] = useState(
() => demoAccounts.find((a) => a.email === user?.email)?.id ?? user?.id ?? 'unknown',
)
const [profile, setProfile] = useState<StaffProfile>(
() => staffProfiles[accountId] ?? fallbackProfile(accountId),
)
const [sessions, setSessions] = useState<StaffSession[]>(() => staffSessions[accountId] ?? [])
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState({
name: user?.name ?? '',
email: user?.email ?? '',
phone: profile.phone,
jobTitle: profile.jobTitle,
locale: (user?.preferredLocale ?? profile.preferredLocale) as 'en' | 'am',
})
const [passwordOpen, setPasswordOpen] = useState(false)
const [passwordForm, setPasswordForm] = useState({ current: '', next: '', confirm: '' })
const role = user?.role
const modules = useMemo(() => {
if (!role) return []
return roleModules[role].map((module) => {
const entry = sidebarEntries.find((e) => e.module === module)
return {
module,
label: entry?.labelKey
? t(entry.labelKey, { defaultValue: entry.title })
: (entry?.title ?? humanize(module)),
path: entry?.path,
icon: entry?.icon,
}
})
}, [role, t])
const activity = useMemo(
() =>
(staffActivity[accountId] ?? [])
.slice()
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()),
[accountId],
)
const branch = useMemo(() => branches.find((b) => b.id === user?.branchId), [user?.branchId])
const branchProfile = user?.branchId ? branchProfiles[user.branchId] : undefined
if (!user || !role) return <Navigate to="/login" replace />
const totalModules = new Set(Object.values(roleModules).flat()).size
const persistProfile = (next: StaffProfile) => {
staffProfiles[next.id] = next
setProfile(next)
}
const startEditing = () => {
setForm({
name: user.name,
email: user.email,
phone: profile.phone,
jobTitle: profile.jobTitle,
locale: (user.preferredLocale ?? profile.preferredLocale) as 'en' | 'am',
})
setEditing(true)
}
const saveAccountDetails = () => {
if (!form.name.trim()) {
toast.error('Enter your full name')
return
}
if (!form.email.includes('@')) {
toast.error('Enter a valid email address')
return
}
setSaving(true)
window.setTimeout(() => {
persistProfile({
...profile,
phone: form.phone.trim(),
jobTitle: form.jobTitle.trim(),
preferredLocale: form.locale,
})
updateUser({ name: form.name.trim(), email: form.email.trim(), preferredLocale: form.locale })
if (i18n.language !== form.locale) void i18n.changeLanguage(form.locale)
setSaving(false)
setEditing(false)
toast.success('Profile updated')
}, 400)
}
const changeLocale = (locale: 'en' | 'am') => {
if (locale === (user.preferredLocale ?? profile.preferredLocale)) return
persistProfile({ ...profile, preferredLocale: locale })
updateUser({ preferredLocale: locale })
setForm((prev) => ({ ...prev, locale }))
void i18n.changeLanguage(locale)
toast.success(`Language set to ${LOCALE_LABEL[locale]}`)
}
const toggleNotification = (key: keyof StaffProfile['notifications'], next: boolean) => {
persistProfile({ ...profile, notifications: { ...profile.notifications, [key]: next } })
toast.success(`${NOTIFICATION_FIELDS.find((f) => f.key === key)?.label} ${next ? 'on' : 'off'}`)
}
const toggleTwoFactor = () => {
const next = !profile.twoFactorEnabled
persistProfile({ ...profile, twoFactorEnabled: next })
toast.success(next ? 'Two-factor authentication enabled' : 'Two-factor authentication disabled')
}
const revokeSession = (session: StaffSession) => {
setSessions((prev) => prev.filter((s) => s.id !== session.id))
staffSessions[accountId] = (staffSessions[accountId] ?? []).filter((s) => s.id !== session.id)
toast.success(`Signed out ${session.device}`)
}
const revokeOtherSessions = () => {
const remaining = sessions.filter((s) => s.current)
setSessions(remaining)
staffSessions[accountId] = remaining
toast.success('All other sessions signed out')
}
const submitPasswordChange = () => {
if (!passwordForm.current) {
toast.error('Enter your current password')
return
}
if (passwordForm.next.length < 8) {
toast.error('New password must be at least 8 characters')
return
}
if (passwordForm.next !== passwordForm.confirm) {
toast.error('New passwords do not match')
return
}
persistProfile({ ...profile, passwordUpdatedAt: new Date().toISOString() })
setPasswordForm({ current: '', next: '', confirm: '' })
setPasswordOpen(false)
toast.success('Password updated')
}
const activeLocale = (user.preferredLocale ?? profile.preferredLocale) as 'en' | 'am'
return (
<div className="w-full min-w-0 space-y-5">
<section className="overflow-hidden rounded-2xl border bg-card shadow-soft">
<div className="relative h-24 bg-gradient-to-r from-brand-500 via-brand-400 to-orange">
<div
className="absolute inset-0"
style={{
backgroundImage: 'radial-gradient(rgba(20,20,20,0.12) 1px, transparent 1px)',
backgroundSize: '18px 18px',
}}
aria-hidden="true"
/>
</div>
<div className="flex flex-col gap-4 px-5 pb-5 lg:flex-row lg:items-end lg:justify-between">
<div className="-mt-12 flex flex-col gap-4 sm:flex-row sm:items-end">
<PersonAvatar
name={user.name}
avatarUrl={user.avatarUrl}
size="xl"
className="h-24 w-24 text-2xl ring-4 ring-card"
/>
<div className="min-w-0 pb-1">
<h1 className="font-display text-2xl font-bold tracking-tight text-forest">{user.name}</h1>
<p className="mt-0.5 truncate text-sm text-muted-foreground">{user.email}</p>
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<StatusBadge domain="role" status={role} />
<Badge variant="outline" className="gap-1.5">
<Building2 className="h-3 w-3" aria-hidden="true" />
{user.branchName ?? 'All branches'}
</Badge>
<Badge variant={profile.status === 'active' ? 'success' : 'warning'} className="gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden="true" />
{humanize(profile.status)}
</Badge>
<Badge variant="muted">{profile.jobTitle}</Badge>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{editing ? (
<>
<Button size="sm" variant="outline" className="h-9 gap-1.5" onClick={() => setEditing(false)}>
<X className="h-3.5 w-3.5" />
Cancel
</Button>
<Button size="sm" className="h-9 gap-1.5" disabled={saving} onClick={saveAccountDetails}>
<Check className="h-3.5 w-3.5" />
{saving ? 'Saving…' : 'Save changes'}
</Button>
</>
) : (
<>
<Button size="sm" variant="outline" className="h-9 gap-1.5" onClick={() => setPasswordOpen(true)}>
<KeyRound className="h-3.5 w-3.5" />
Change password
</Button>
<Button size="sm" className="h-9 gap-1.5" onClick={startEditing}>
<Pencil className="h-3.5 w-3.5" />
Edit profile
</Button>
</>
)}
</div>
</div>
</section>
<div className="grid gap-5 [&>*]:min-w-0 lg:grid-cols-[minmax(0,1fr)_22rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]">
<div className="space-y-5">
<DetailSection
title="Account details"
description="How you appear across the console"
action={
editing ? null : (
<Button size="sm" variant="ghost" className="h-8 gap-1.5 text-muted-foreground" onClick={startEditing}>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
)
}
>
{editing ? (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-name">Full name</Label>
<Input
id="profile-name"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-email">Email</Label>
<Input
id="profile-email"
type="email"
value={form.email}
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-phone">Phone</Label>
<Input
id="profile-phone"
value={form.phone}
placeholder="+251 …"
onChange={(e) => setForm((prev) => ({ ...prev, phone: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-job">Job title</Label>
<Input
id="profile-job"
value={form.jobTitle}
onChange={(e) => setForm((prev) => ({ ...prev, jobTitle: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile-locale">Language preference</Label>
<SelectNative
id="profile-locale"
value={form.locale}
onChange={(e) => setForm((prev) => ({ ...prev, locale: e.target.value as 'en' | 'am' }))}
>
<option value="en">{LOCALE_LABEL.en}</option>
<option value="am">{LOCALE_LABEL.am}</option>
</SelectNative>
</div>
</div>
<Separator />
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" onClick={() => setEditing(false)}>
Cancel
</Button>
<Button disabled={saving} onClick={saveAccountDetails}>
{saving ? 'Saving…' : 'Save changes'}
</Button>
</div>
</div>
) : (
<KeyValueGrid
columns={3}
items={[
{ label: 'Full name', value: user.name },
{ label: 'Email', value: user.email },
{ label: 'Phone', value: profile.phone || '—' },
{ label: 'Job title', value: profile.jobTitle },
{ label: 'Language', value: LOCALE_LABEL[activeLocale] },
{ label: 'Member since', value: formatDate(profile.joinedAt) },
]}
/>
)}
</DetailSection>
<DetailSection
title="Role & permissions"
description={`${modules.length} of ${totalModules} modules enabled for this role`}
action={<StatusBadge domain="role" status={role} />}
>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{modules.map(({ module, label, path, icon }) => {
const content = (
<>
{icon ? (
<IconTile icon={icon} size="sm" tone="brand" />
) : (
<IconTile icon={Sparkles} size="sm" tone="muted" />
)}
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">{label}</span>
<span className="block truncate text-xs text-muted-foreground">{module}</span>
</span>
</>
)
return path ? (
<Link
key={module}
to={path}
className="flex items-center gap-3 rounded-xl border bg-background p-3 transition-colors hover:bg-grayScale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{content}
</Link>
) : (
<div key={module} className="flex items-center gap-3 rounded-xl border bg-background p-3">
{content}
</div>
)
})}
</div>
<p className="mt-4 text-xs text-muted-foreground">
Module access is granted by role. Ask a super admin if you need a module that is not listed here.
</p>
</DetailSection>
<DetailSection title="Branch assignment" description="Where your work is scoped">
{branch ? (
<div className="space-y-4">
<KeyValueGrid
columns={3}
items={[
{ label: 'Branch', value: branch.name },
{ label: 'Zone', value: branch.zone },
{ label: 'Phone', value: branch.phone },
{ label: 'Manager', value: branchProfile?.manager ?? '—' },
{ label: 'Opening hours', value: branchProfile?.openingHours ?? '—' },
{ label: 'Address', value: branchProfile?.address ?? '—' },
]}
/>
{canAccess(role, 'settings') ? (
<Button asChild variant="outline" size="sm" className="h-9">
<Link to={`/settings/branches/${branch.id}`}>Open branch settings</Link>
</Button>
) : null}
</div>
) : (
<div className="flex items-start gap-3 rounded-xl border bg-background p-4">
<IconTile icon={Building2} size="sm" tone="forest" />
<div>
<p className="text-sm font-medium text-foreground">All branches</p>
<p className="mt-0.5 text-xs text-muted-foreground">
This account is platform-wide and is not scoped to a single branch.
</p>
</div>
</div>
)}
</DetailSection>
<DetailSection title="Recent activity" description="Your last actions in the console">
{activity.length === 0 ? (
<EmptyState
icon={UserRound}
title="No activity yet"
description="Actions you take in the console will show up here."
variant="dashed"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Activity</TableHead>
<TableHead>Module</TableHead>
<TableHead>Outcome</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activity.map((event) => {
const linkable = event.targetPath && canAccess(role, event.module)
return (
<TableRow key={event.id}>
<TableCell className="whitespace-nowrap align-top">
<span className="block text-sm font-medium text-foreground">
{formatRelative(event.at)}
</span>
<span className="block text-xs text-muted-foreground">{formatDateTime(event.at)}</span>
</TableCell>
<TableCell className="align-top">
<span className="block text-sm font-medium text-foreground">{event.summary}</span>
<span className="block font-mono text-xs text-muted-foreground">{event.action}</span>
{linkable ? (
<Link
to={event.targetPath as string}
className="mt-0.5 inline-block text-xs font-medium text-brand-700 underline-offset-2 hover:underline"
>
{event.target}
</Link>
) : event.target ? (
<span className="mt-0.5 block text-xs text-muted-foreground">{event.target}</span>
) : null}
</TableCell>
<TableCell className="align-top">
<Badge variant="outline">{humanize(event.module)}</Badge>
</TableCell>
<TableCell className="align-top">
<Badge variant={event.outcome === 'success' ? 'success' : 'danger'}>
{humanize(event.outcome)}
</Badge>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</DetailSection>
</div>
<div className="space-y-5">
<DetailSection title="Security" description="Password, verification and devices">
<div className="space-y-4">
<KeyValueGrid
columns={1}
items={[
{
label: 'Last sign-in',
value: (
<>
<span className="block">{formatDateTime(profile.lastSignInAt)}</span>
<span className="block text-xs font-normal text-muted-foreground">
{profile.lastSignInFrom}
</span>
</>
),
},
{
label: 'Password updated',
value: `${formatDate(profile.passwordUpdatedAt)} · ${formatRelative(profile.passwordUpdatedAt)}`,
},
]}
/>
<div className="flex items-start justify-between gap-3 rounded-xl border bg-background p-4">
<div className="flex items-start gap-3">
<IconTile icon={ShieldCheck} size="sm" tone={profile.twoFactorEnabled ? 'brand' : 'muted'} />
<div>
<p className="text-sm font-medium text-foreground">Two-factor authentication</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{profile.twoFactorEnabled ? 'Required at every sign-in' : 'Not enabled on this account'}
</p>
</div>
</div>
<ToggleSwitch
checked={profile.twoFactorEnabled}
onChange={toggleTwoFactor}
label="Two-factor authentication"
/>
</div>
<Button variant="outline" className="w-full gap-2" onClick={() => setPasswordOpen(true)}>
<KeyRound className="h-4 w-4" />
Change password
</Button>
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Active sessions
</p>
{sessions.some((s) => !s.current) ? (
<Button size="sm" variant="ghost" className="h-8 gap-1.5 text-xs" onClick={revokeOtherSessions}>
<LogOut className="h-3.5 w-3.5" />
Sign out others
</Button>
) : null}
</div>
{sessions.length === 0 ? (
<EmptyState icon={Monitor} title="No active sessions" size="sm" />
) : (
<ul className="space-y-2">
{sessions.map((session) => (
<li key={session.id} className="flex items-start justify-between gap-3 rounded-xl border p-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">{session.device}</span>
{session.current ? <Badge variant="success">This device</Badge> : null}
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{session.browser} · {session.location}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{session.ip} · {formatRelative(session.lastActiveAt)}
</p>
</div>
{session.current ? null : (
<Button
size="sm"
variant="ghost"
className="h-8 shrink-0 text-xs text-destructive hover:bg-red-50"
onClick={() => revokeSession(session)}
>
Revoke
</Button>
)}
</li>
))}
</ul>
)}
</div>
</div>
</DetailSection>
<DetailSection title="Preferences" description="Language and what we notify you about">
<div className="space-y-5">
<div>
<div className="mb-2 flex items-center gap-2">
<Languages className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<p className="text-sm font-medium text-foreground">Console language</p>
</div>
<div className="grid grid-cols-2 gap-2" role="group" aria-label="Console language">
{(['en', 'am'] as const).map((locale) => (
<Button
key={locale}
type="button"
variant={activeLocale === locale ? 'default' : 'outline'}
aria-pressed={activeLocale === locale}
onClick={() => changeLocale(locale)}
>
{LOCALE_LABEL[locale]}
</Button>
))}
</div>
</div>
<Separator />
<div>
<div className="mb-3 flex items-center gap-2">
<Bell className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<p className="text-sm font-medium text-foreground">Notifications</p>
</div>
<ul className="space-y-3">
{NOTIFICATION_FIELDS.map((field) => (
<li key={field.key} className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{field.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p>
</div>
<ToggleSwitch
checked={profile.notifications[field.key]}
onChange={(next) => toggleNotification(field.key, next)}
label={field.label}
/>
</li>
))}
</ul>
</div>
<div className="flex items-start gap-3 rounded-xl bg-brand-50 p-3">
<SlidersHorizontal className="mt-0.5 h-4 w-4 shrink-0 text-brand-700" aria-hidden="true" />
<p className="text-xs leading-relaxed text-muted-foreground">
Preferences are stored against your account and apply to this console only.
</p>
</div>
</div>
</DetailSection>
</div>
</div>
<Dialog open={passwordOpen} onOpenChange={setPasswordOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Change password</DialogTitle>
<DialogDescription>
Choose a password of at least 8 characters. You stay signed in on this device.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Current password</Label>
<Input
id="current-password"
type="password"
autoComplete="current-password"
value={passwordForm.current}
onChange={(e) => setPasswordForm((prev) => ({ ...prev, current: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">New password</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={passwordForm.next}
onChange={(e) => setPasswordForm((prev) => ({ ...prev, next: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm new password</Label>
<Input
id="confirm-password"
type="password"
autoComplete="new-password"
value={passwordForm.confirm}
onChange={(e) => setPasswordForm((prev) => ({ ...prev, confirm: e.target.value }))}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setPasswordOpen(false)}>
Cancel
</Button>
<Button onClick={submitPasswordChange}>Update password</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}