40227-vm/frontend/src/pages/ProfilePage.tsx

599 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { FormEvent } from 'react';
import { useMemo, useState } from 'react';
import { ClipboardList, FileCheck, KeyRound, Loader2, UserCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ModuleHeader } from '@/components/ui/module-header';
import { NativeSelect } from '@/components/ui/native-select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useAuth } from '@/contexts/useAuth';
import { useIamCapabilities } from '@/business/iam-capabilities/hooks';
import { getAuthRoleLabel } from '@/business/auth/selectors';
import { hasPermission } from '@/business/auth/permissions';
import { changePassword, updateOwnProfile, updateOrganization } from '@/business/profile/api';
import {
buildProfileDocumentAcknowledgmentRows,
buildProfileQuizResultRows,
} from '@/business/profile/selectors';
import { getErrorMessage } from '@/shared/errors/errorMessages';
import { USER_NAME_PREFIX_OPTIONS } from '@/shared/constants/users';
import { ImageUpload } from '@/components/common/ImageUpload';
import { useCurrentPersonalityResultHistory } from '@/business/personality/queryHooks';
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks';
import { canZoneCheckIn } from '@/business/zone-checkin/selectors';
import { usePolicies, usePolicyAcknowledgments } from '@/business/policies/hooks';
import { useSafetyProtocols } from '@/business/safety-protocols/hooks';
interface StatusMessage {
readonly type: 'success' | 'error';
readonly text: string;
}
const formControlClassName =
'border-slate-600 bg-slate-950/80 text-slate-100 placeholder:text-slate-500 focus-visible:ring-sky-400 focus-visible:ring-offset-slate-950';
const profileCardClassName =
'border-slate-600/70 bg-slate-900/80 shadow-lg shadow-black/20';
const formPanelClassName =
'rounded-lg border border-slate-600/80 bg-slate-950/45 p-4';
function StatusBanner({ status }: { status: StatusMessage | null }) {
if (!status) {
return null;
}
const className =
status.type === 'success'
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200'
: 'border-red-500/30 bg-red-500/10 text-red-200';
return (
<div className={`rounded-lg border px-3 py-2 text-sm ${className}`}>{status.text}</div>
);
}
function ReadOnlyField({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-slate-700/70 bg-slate-950/55 px-3 py-2">
<p className="text-xs font-medium text-slate-400">{label}</p>
<p className="mt-1 text-sm font-semibold text-slate-100">{value || '—'}</p>
</div>
);
}
export default function ProfilePage() {
const { user, profile, refreshUser } = useAuth();
const capabilitiesQuery = useIamCapabilities();
const isExternalProfileUser =
user?.app_role?.name === 'student' || user?.app_role?.name === 'guardian';
const canShowQuizResults = Boolean(user) && !isExternalProfileUser;
const safetyQuizStatus = useMySafetyQuizStatus(undefined, canShowQuizResults);
const personalityHistoryStatus = useCurrentPersonalityResultHistory(canShowQuizResults);
const canUseZoneCheckin = canZoneCheckIn(user);
const zoneCheckinStatus = useTodayZoneCheckIn({ enabled: canShowQuizResults && canUseZoneCheckin });
const canReadAcknowledgedDocuments = hasPermission(user, 'ACK_POLICY');
const policyAcknowledgmentsStatus = usePolicyAcknowledgments(canReadAcknowledgedDocuments);
const handbookPoliciesStatus = usePolicies(canReadAcknowledgedDocuments);
const safetyProtocolsStatus = useSafetyProtocols(canReadAcknowledgedDocuments);
const [namePrefix, setNamePrefix] = useState<string>(user?.name_prefix ?? '');
const [firstName, setFirstName] = useState<string>(user?.firstName ?? '');
const [lastName, setLastName] = useState<string>(user?.lastName ?? '');
const [phoneNumber, setPhoneNumber] = useState<string>(user?.phoneNumber ?? '');
const [email, setEmail] = useState<string>(user?.email ?? '');
const [profileSaving, setProfileSaving] = useState(false);
const [profileStatus, setProfileStatus] = useState<StatusMessage | null>(null);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordSaving, setPasswordSaving] = useState(false);
const [passwordStatus, setPasswordStatus] = useState<StatusMessage | null>(null);
const [avatar, setAvatar] = useState<string | null>(user?.avatar ?? null);
const [avatarStatus, setAvatarStatus] = useState<StatusMessage | null>(null);
const [organizationName, setOrganizationName] = useState(user?.organizations?.name ?? '');
const [organizationLogo, setOrganizationLogo] = useState<string | null>(
user?.organizations?.logo ?? null,
);
const [organizationSaving, setOrganizationSaving] = useState(false);
const [organizationStatus, setOrganizationStatus] = useState<StatusMessage | null>(null);
const roleLabel = profile ? getAuthRoleLabel(profile.role) : '—';
const canEditOwnOrganization = capabilitiesQuery.data?.canEditOwnOrganization === true
&& Boolean(user?.organizations?.id);
const tenantChain = useMemo(() => {
const parts = [
user?.organizations?.name,
user?.school?.name,
user?.campus?.name ?? user?.campus?.code,
user?.classRoom?.name,
].filter((part): part is string => Boolean(part));
return parts.length > 0 ? parts.join(' ') : 'Platform';
}, [user]);
const quizResultRows = useMemo(
() => buildProfileQuizResultRows(
safetyQuizStatus.data?.result ?? null,
personalityHistoryStatus.data ?? [],
zoneCheckinStatus.isCheckedInToday && zoneCheckinStatus.todayZone
? {
date: zoneCheckinStatus.todayDate ?? new Date().toISOString().slice(0, 10),
zone: zoneCheckinStatus.todayZone,
isCheckedInToday: true,
}
: null,
canUseZoneCheckin,
),
[
personalityHistoryStatus.data,
safetyQuizStatus.data?.result,
zoneCheckinStatus.isCheckedInToday,
zoneCheckinStatus.todayDate,
zoneCheckinStatus.todayZone,
canUseZoneCheckin,
],
);
const documentAcknowledgmentRows = useMemo(
() => buildProfileDocumentAcknowledgmentRows(
handbookPoliciesStatus.data ?? [],
safetyProtocolsStatus.data ?? [],
policyAcknowledgmentsStatus.data ?? [],
),
[
handbookPoliciesStatus.data,
policyAcknowledgmentsStatus.data,
safetyProtocolsStatus.data,
],
);
if (!user) {
return null;
}
async function handleProfileSubmit(event: FormEvent) {
event.preventDefault();
setProfileStatus(null);
setProfileSaving(true);
try {
await updateOwnProfile({
name_prefix: namePrefix === '' ? null : namePrefix,
firstName: firstName.trim(),
lastName: lastName.trim(),
phoneNumber: phoneNumber.trim() || null,
email: email.trim(),
});
await refreshUser();
setProfileStatus({ type: 'success', text: 'Profile updated.' });
} catch (error) {
setProfileStatus({ type: 'error', text: getErrorMessage(error, 'Could not update profile') });
} finally {
setProfileSaving(false);
}
}
async function handleAvatarChange(privateUrl: string | null) {
setAvatar(privateUrl);
setAvatarStatus(null);
try {
await updateOwnProfile({ avatar: privateUrl });
await refreshUser();
setAvatarStatus({
type: 'success',
text: privateUrl ? 'Avatar updated.' : 'Avatar removed.',
});
} catch (error) {
setAvatarStatus({ type: 'error', text: getErrorMessage(error, 'Could not update avatar') });
}
}
async function handlePasswordSubmit(event: FormEvent) {
event.preventDefault();
setPasswordStatus(null);
if (newPassword !== confirmPassword) {
setPasswordStatus({ type: 'error', text: 'New password and confirmation do not match.' });
return;
}
setPasswordSaving(true);
try {
await changePassword(currentPassword, newPassword);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setPasswordStatus({ type: 'success', text: 'Password updated.' });
} catch (error) {
setPasswordStatus({ type: 'error', text: getErrorMessage(error, 'Could not update password') });
} finally {
setPasswordSaving(false);
}
}
async function handleOrganizationSubmit(event: FormEvent) {
event.preventDefault();
const organizationId = user?.organizations?.id;
if (!organizationId) return;
setOrganizationStatus(null);
setOrganizationSaving(true);
try {
await updateOrganization(organizationId, {
name: organizationName.trim(),
logo: organizationLogo ?? undefined,
});
await refreshUser();
setOrganizationStatus({ type: 'success', text: 'Organization profile updated.' });
} catch (error) {
setOrganizationStatus({
type: 'error',
text: getErrorMessage(error, 'Could not update organization profile'),
});
} finally {
setOrganizationSaving(false);
}
}
return (
<div className="space-y-6 p-4 md:p-6">
<ModuleHeader
title="My Profile"
description="Manage your name, email, and password."
icon={UserCircle}
iconClassName="bg-gradient-to-br from-violet-500 to-amber-400"
/>
<div className="space-y-6">
<Card className={profileCardClassName}>
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
<CardTitle className="text-base text-slate-50">Account details</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 md:px-5">
<div className="space-y-5 pt-6">
<div className={`${formPanelClassName} space-y-4`}>
<div>
<p className="text-sm font-semibold text-slate-100">Profile identity</p>
<p className="mt-1 text-xs text-slate-300">
Your visible name, contact details, and avatar.
</p>
</div>
<div className="grid gap-5 lg:grid-cols-[auto_1fr]">
<ImageUpload
value={avatar}
onChange={handleAvatarChange}
table="users"
field="avatar"
label="Avatar"
shape="square"
previewSize="lg"
/>
<div className="grid content-start gap-3 sm:grid-cols-2">
<ReadOnlyField label="Role" value={roleLabel} />
<ReadOnlyField label="Scope" value={tenantChain} />
</div>
</div>
<StatusBanner status={avatarStatus} />
</div>
<form className="space-y-5" onSubmit={handleProfileSubmit}>
<div className={`${formPanelClassName} space-y-4`}>
<div className="grid gap-4 md:grid-cols-4">
<div className="space-y-1.5">
<Label htmlFor="name_prefix" className="text-slate-100">Title</Label>
<NativeSelect
id="name_prefix"
value={namePrefix}
className={formControlClassName}
onChange={(event) => setNamePrefix(event.target.value)}
>
<option value="">None</option>
{USER_NAME_PREFIX_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</NativeSelect>
</div>
<div className="space-y-1.5">
<Label htmlFor="firstName" className="text-slate-100">First name</Label>
<Input
id="firstName"
value={firstName}
className={formControlClassName}
onChange={(event) => setFirstName(event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="lastName" className="text-slate-100">Last name</Label>
<Input
id="lastName"
value={lastName}
className={formControlClassName}
onChange={(event) => setLastName(event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="phoneNumber" className="text-slate-100">Phone number</Label>
<Input
id="phoneNumber"
type="tel"
value={phoneNumber}
className={formControlClassName}
onChange={(event) => setPhoneNumber(event.target.value)}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="email" className="text-slate-100">Email</Label>
<Input
id="email"
type="email"
value={email}
className={formControlClassName}
onChange={(event) => setEmail(event.target.value)}
/>
</div>
</div>
<StatusBanner status={profileStatus} />
<div className="flex justify-end">
<Button type="submit" disabled={profileSaving}>
{profileSaving && <Loader2 size={16} className="mr-2 animate-spin" />}
Save changes
</Button>
</div>
</form>
</div>
</CardContent>
</Card>
{canEditOwnOrganization && (
<Card className={profileCardClassName}>
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
<CardTitle className="text-base text-slate-50">Organization profile</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 md:px-5">
<form className="space-y-5 pt-6" onSubmit={handleOrganizationSubmit}>
<div className={`${formPanelClassName} space-y-4`}>
<div>
<p className="text-sm font-semibold text-slate-100">Organization identity</p>
<p className="mt-1 text-xs text-slate-300">
The name and logo shown for your organization.
</p>
</div>
<ImageUpload
value={organizationLogo}
onChange={setOrganizationLogo}
table="organizations"
field="logo"
label="Logo"
/>
<div className="space-y-1.5">
<Label htmlFor="organizationName" className="text-slate-100">Organization name</Label>
<Input
id="organizationName"
value={organizationName}
className={formControlClassName}
onChange={(event) => setOrganizationName(event.target.value)}
required
/>
</div>
</div>
<StatusBanner status={organizationStatus} />
<div className="flex justify-end">
<Button type="submit" disabled={organizationSaving}>
{organizationSaving && <Loader2 size={16} className="mr-2 animate-spin" />}
Save organization
</Button>
</div>
</form>
</CardContent>
</Card>
)}
</div>
{canShowQuizResults && (
<Card className={profileCardClassName}>
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
<ClipboardList size={16} />
Quiz results
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 md:px-5">
<div className={`${formPanelClassName} mt-6`}>
{safetyQuizStatus.isLoading || personalityHistoryStatus.isLoading || zoneCheckinStatus.isLoading ? (
<p className="text-sm text-slate-300">Loading quiz results...</p>
) : (
<div className="overflow-hidden rounded-lg border border-slate-700/70">
<Table>
<TableHeader>
<TableRow className="border-slate-700/70 bg-slate-950/60 hover:bg-slate-950/60">
<TableHead className="h-auto p-3 text-slate-400">Quiz</TableHead>
<TableHead className="h-auto p-3 text-slate-400">Category</TableHead>
<TableHead className="h-auto p-3 text-slate-400">Result</TableHead>
<TableHead className="h-auto p-3 text-slate-400">Completed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{quizResultRows.map((result) => (
<TableRow
key={result.id}
className="border-slate-800/80 hover:bg-slate-800/20"
>
<TableCell className="p-3">
<p className="font-semibold text-slate-100">{result.quiz}</p>
</TableCell>
<TableCell className="p-3 text-slate-300">{result.category}</TableCell>
<TableCell className={`p-3 font-semibold ${
result.status === 'complete' ? 'text-slate-100' : 'text-amber-300'
}`}>
{result.result}
</TableCell>
<TableCell className="p-3 text-slate-300">{result.completed}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</CardContent>
</Card>
)}
{canReadAcknowledgedDocuments && (
<Card className={profileCardClassName}>
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
<FileCheck size={16} />
Document acknowledgments
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 md:px-5">
<div className={`${formPanelClassName} mt-6`}>
{(
policyAcknowledgmentsStatus.isLoading
|| handbookPoliciesStatus.isLoading
|| safetyProtocolsStatus.isLoading
) ? (
<p className="text-sm text-slate-300">Loading document acknowledgments...</p>
) : documentAcknowledgmentRows.length === 0 ? (
<p className="text-sm text-slate-300">
No current documents are assigned yet.
</p>
) : (
<div className="overflow-hidden rounded-lg border border-slate-700/70">
<Table>
<TableHeader>
<TableRow className="border-slate-700/70 bg-slate-950/60 hover:bg-slate-950/60">
<TableHead className="h-auto p-3 text-slate-400">Document</TableHead>
<TableHead className="h-auto p-3 text-slate-400">Category</TableHead>
<TableHead className="h-auto p-3 text-slate-400">Version</TableHead>
<TableHead className="h-auto p-3 text-slate-400">Acknowledged on</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{documentAcknowledgmentRows.map((document) => (
<TableRow
key={document.id}
className="border-slate-800/80 hover:bg-slate-800/20"
>
<TableCell className="p-3">
<div className="flex flex-wrap items-center gap-2">
<p className="font-semibold text-slate-100">{document.title}</p>
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
document.status === 'acknowledged'
? 'bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-400/30'
: 'bg-red-500/15 text-red-200 ring-1 ring-red-400/30'
}`}>
{document.status === 'acknowledged'
? 'Acknowledged'
: 'Not acknowledged'}
</span>
</div>
</TableCell>
<TableCell className="p-3 text-slate-300">{document.category}</TableCell>
<TableCell className="p-3 text-slate-300">{document.version}</TableCell>
<TableCell className="p-3 text-slate-300">
{document.acknowledgedAt
? new Date(document.acknowledgedAt).toLocaleDateString()
: '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</CardContent>
</Card>
)}
<Card className={profileCardClassName}>
<CardHeader className="border-b border-slate-700/70 px-4 py-4 md:px-5">
<CardTitle className="flex items-center gap-2 text-base text-slate-50">
<KeyRound size={16} />
Change password
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 md:px-5">
<form className="space-y-5 pt-6" onSubmit={handlePasswordSubmit}>
<div className={`${formPanelClassName} space-y-4`}>
<div>
<p className="text-sm font-semibold text-slate-100">Password credentials</p>
<p className="mt-1 text-xs text-slate-300">
Use your current password before setting a new one.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="currentPassword" className="text-slate-100">Current password</Label>
<Input
id="currentPassword"
type="password"
autoComplete="current-password"
value={currentPassword}
className={formControlClassName}
onChange={(event) => setCurrentPassword(event.target.value)}
required
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="newPassword" className="text-slate-100">New password</Label>
<Input
id="newPassword"
type="password"
autoComplete="new-password"
value={newPassword}
className={formControlClassName}
onChange={(event) => setNewPassword(event.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirmPassword" className="text-slate-100">Confirm new password</Label>
<Input
id="confirmPassword"
type="password"
autoComplete="new-password"
value={confirmPassword}
className={formControlClassName}
onChange={(event) => setConfirmPassword(event.target.value)}
required
/>
</div>
</div>
</div>
<StatusBanner status={passwordStatus} />
<div className="flex justify-end">
<Button type="submit" disabled={passwordSaving}>
{passwordSaving && <Loader2 size={16} className="mr-2 animate-spin" />}
Update password
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}