From 88dac142f23e40114c3cb3e57efe6c4fdf7f63c1 Mon Sep 17 00:00:00 2001 From: Sam McNab Date: Sat, 9 May 2026 17:38:35 +0100 Subject: [PATCH 1/2] feat: account display updated --- convex/schema.ts | 6 + convex/staff.ts | 42 ++ convex/users.ts | 34 + src/app/account/page.tsx | 150 ++-- src/app/staff/[id]/page.tsx | 657 ++++++++++++++---- src/components/account/AccountDetailsTab.tsx | 14 +- src/components/account/AccountHeroHeader.tsx | 72 +- src/components/account/AccountOverviewTab.tsx | 258 +++++-- src/components/account/PreferencesTab.tsx | 2 +- src/components/account/ProfilePictureTab.tsx | 14 +- src/components/account/SecurityTab.tsx | 8 +- src/components/dashboard/DashboardContent.tsx | 1 - src/components/nav-user.tsx | 2 - src/lib/account/profile-completeness.ts | 115 +++ src/lib/formatters.ts | 9 + 15 files changed, 1078 insertions(+), 306 deletions(-) create mode 100644 src/lib/account/profile-completeness.ts create mode 100644 src/lib/formatters.ts diff --git a/convex/schema.ts b/convex/schema.ts index 63bbf11..e89d566 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -78,6 +78,12 @@ export default defineSchema({ }) ), onboardingCompletedAt: v.optional(v.float64()), + accountUiState: v.optional( + v.object({ + profileCompletenessDismissedAt: v.optional(v.float64()), + profileCompletenessDismissedVersion: v.optional(v.string()), + }) + ), createdAt: v.float64(), updatedAt: v.float64(), }) diff --git a/convex/staff.ts b/convex/staff.ts index 5e87acc..63e9a95 100644 --- a/convex/staff.ts +++ b/convex/staff.ts @@ -90,6 +90,48 @@ export const create = mutation({ }, }); +export const updateOwnPreferences = mutation({ + args: { + userId: v.string(), + profileId: v.id('lecturer_profiles'), + prefWorkingLocation: v.optional(v.string()), + prefWorkingTime: v.optional( + v.union(v.literal('am'), v.literal('pm'), v.literal('all_day')) + ), + prefSpecialism: v.optional(v.string()), + prefNotes: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const profile = await ctx.db.get(args.profileId); + + if (!profile) { + throw new Error('Profile not found'); + } + + if (profile.userSubject !== args.userId) { + throw new Error('You can only update your own profile preferences'); + } + + await ctx.db.patch(args.profileId, { + ...(args.prefWorkingLocation !== undefined + ? { prefWorkingLocation: args.prefWorkingLocation.trim() || undefined } + : {}), + ...(args.prefWorkingTime !== undefined + ? { prefWorkingTime: args.prefWorkingTime } + : {}), + ...(args.prefSpecialism !== undefined + ? { prefSpecialism: args.prefSpecialism.trim() || undefined } + : {}), + ...(args.prefNotes !== undefined + ? { prefNotes: args.prefNotes.trim() || undefined } + : {}), + updatedAt: Date.now(), + }); + + return { success: true }; + }, +}); + // Update lecturer profile export const edit = mutation({ args: { diff --git a/convex/users.ts b/convex/users.ts index 7c38c7b..ff75ee0 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1428,9 +1428,15 @@ export const getAccountManagementDetails = query({ ? await ctx.db.get(user.organisationId) : null; + const linkedStaffProfile = await ctx.db + .query('lecturer_profiles') + .filter((q) => q.eq(q.field('userSubject'), args.subject)) + .first(); + return { user, organisation, + linkedStaffProfile, }; }, }); @@ -1501,4 +1507,32 @@ export const updateOwnAccount = mutation({ return await ctx.db.get(user._id); }, +}); + +export const dismissProfileCompleteness = mutation({ + args: { + subject: v.string(), + version: v.string(), + }, + handler: async (ctx, args) => { + const user = await ctx.db + .query('users') + .withIndex('by_subject', (q) => q.eq('subject', args.subject)) + .first(); + + if (!user || !user.isActive) { + throw new Error('User not found'); + } + + await ctx.db.patch(user._id, { + accountUiState: { + ...(user.accountUiState ?? {}), + profileCompletenessDismissedAt: Date.now(), + profileCompletenessDismissedVersion: args.version, + }, + updatedAt: Date.now(), + }); + + return { dismissed: true }; + }, }); \ No newline at end of file diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index c73b16f..34d56fc 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -3,6 +3,7 @@ import { useState, useCallback, useEffect } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { useQuery } from 'convex/react'; + import { api } from '@/convex/_generated/api'; import { useAuthUser } from '@/hooks/useAuthUser'; import { getUserRoles } from '@/lib/utils'; @@ -17,14 +18,25 @@ import { ProfilePictureTab } from '@/components/account/ProfilePictureTab'; import { SecurityTab } from '@/components/account/SecurityTab'; import { PreferencesTab } from '@/components/account/PreferencesTab'; -// Force dynamic rendering to prevent WorkOS authentication errors during build +import { + getProfileCompletenessItems, + getProfileCompletenessSummary, + PROFILE_COMPLETENESS_VERSION, +} from '@/lib/account/profile-completeness'; + export const dynamic = 'force-dynamic'; type TabValue = 'overview' | 'details' | 'picture' | 'security' | 'preferences'; const breadcrumbs = [{ label: 'Home', href: '/' }, { label: 'Account' }]; -const VALID_TABS: TabValue[] = ['overview', 'details', 'picture', 'security', 'preferences']; +const VALID_TABS: TabValue[] = [ + 'overview', + 'details', + 'picture', + 'security', + 'preferences', +]; export default function AccountPage() { const { user, isLoaded, isSignedIn } = useAuthUser({ @@ -33,30 +45,13 @@ export default function AccountPage() { const searchParams = useSearchParams(); const router = useRouter(); - - // Get initial tab from URL or default to 'overview' + const tabFromUrl = searchParams.get('tab') as TabValue | null; - const initialTab = tabFromUrl && VALID_TABS.includes(tabFromUrl) ? tabFromUrl : 'overview'; - + const initialTab = + tabFromUrl && VALID_TABS.includes(tabFromUrl) ? tabFromUrl : 'overview'; + const [activeTab, setActiveTab] = useState(initialTab); const [avatarRefreshKey, setAvatarRefreshKey] = useState(0); - - // Sync tab state with URL - useEffect(() => { - const tabParam = searchParams.get('tab') as TabValue | null; - if (tabParam && VALID_TABS.includes(tabParam) && tabParam !== activeTab) { - setActiveTab(tabParam); - } - }, [searchParams, activeTab]); - - // Update URL when tab changes - const handleTabChange = useCallback((newTab: string) => { - const tab = newTab as TabValue; - setActiveTab(tab); - const params = new URLSearchParams(searchParams.toString()); - params.set('tab', tab); - router.replace(`/account?${params.toString()}`, { scroll: false }); - }, [searchParams, router]); const accountDetails = useQuery( api.users.getAccountManagementDetails, @@ -68,19 +63,60 @@ export default function AccountPage() { user ? { subject: user.id } : 'skip' ); + useEffect(() => { + const tabParam = searchParams.get('tab') as TabValue | null; + + if (tabParam && VALID_TABS.includes(tabParam) && tabParam !== activeTab) { + setActiveTab(tabParam); + } + }, [searchParams, activeTab]); + + const handleTabChange = useCallback( + (newTab: string) => { + const tab = newTab as TabValue; + + if (!VALID_TABS.includes(tab)) { + return; + } + + setActiveTab(tab); + + const params = new URLSearchParams(searchParams.toString()); + params.set('tab', tab); + + router.replace(`/account?${params.toString()}`, { scroll: false }); + }, + [searchParams, router] + ); + const handleRefreshed = useCallback(() => { setAvatarRefreshKey((prev) => prev + 1); }, []); - const handleGoToTab = useCallback((tab: string) => { - handleTabChange(tab); - }, [handleTabChange]); + const handleGoToTab = useCallback( + (tab: string) => { + handleTabChange(tab); + }, + [handleTabChange] + ); + + if (!isLoaded) { + return ; + } + + if (!isSignedIn || !user) { + return null; + } - // ── Auth guard ────────────────────────────────────────────────────────────── - if (!isLoaded) return ; - if (!isSignedIn || !user) return null; + const linkedStaffProfile = accountDetails?.linkedStaffProfile ?? null; + + const hasStaffPreferences = Boolean( + linkedStaffProfile?.prefWorkingLocation || + linkedStaffProfile?.prefWorkingTime || + linkedStaffProfile?.prefSpecialism || + linkedStaffProfile?.prefNotes + ); - // ── Derived values ────────────────────────────────────────────────────────── const userName = accountDetails?.user.fullName || user.fullName || @@ -88,19 +124,17 @@ export default function AccountPage() { 'User'; const userEmail = - accountDetails?.user.email || - user.emailAddresses[0]?.emailAddress || - ''; + accountDetails?.user.email || user.emailAddresses[0]?.emailAddress || ''; const username = accountDetails?.user.username ?? null; const organisationName = accountDetails?.organisation?.name ?? null; const userRoles = getUserRoles(user); const createdAt = user.createdAt ?? null; - // Prefer Convex-stored picture, fall back to WorkOS imageUrl const convexPictureUrl = profilePictureUrl ? `${profilePictureUrl}?r=${avatarRefreshKey}` : null; + const avatarUrl = convexPictureUrl || (user.imageUrl ? `${user.imageUrl}?r=${avatarRefreshKey}` : ''); @@ -116,14 +150,29 @@ export default function AccountPage() { const hasFirstName = Boolean(accountDetails?.user.givenName || user.firstName); const hasLastName = Boolean(accountDetails?.user.familyName || user.lastName); + const profileCompletenessItems = getProfileCompletenessItems({ + userEmail, + username, + hasProfilePicture, + hasFirstName, + hasLastName, + hasStaffPreferences, + }); + + const profileCompletenessSummary = + getProfileCompletenessSummary(profileCompletenessItems); + + const profileCompletenessDismissed = + accountDetails?.user.accountUiState?.profileCompletenessDismissedVersion === + PROFILE_COMPLETENESS_VERSION; + return (
- {/* ── Hero header ─────────────────────────────────────────────────── */} setActiveTab('details')} - onChangePhoto={() => setActiveTab('picture')} - onSecuritySettings={() => setActiveTab('security')} + onEditDetails={() => handleTabChange('details')} + onChangePhoto={() => handleTabChange('picture')} + onSecuritySettings={() => handleTabChange('security')} /> - {/* ── Tabs ────────────────────────────────────────────────────────── */} - + Overview - Account details - Profile picture + Account Details + Profile Picture Security Preferences @@ -156,9 +201,14 @@ export default function AccountPage() { username={username} organisationName={organisationName} userRoles={userRoles} - hasProfilePicture={hasProfilePicture} - hasFirstName={hasFirstName} - hasLastName={hasLastName} + profileCompletenessItems={profileCompletenessItems} + profileCompletenessSummary={profileCompletenessSummary} + profileCompletenessDismissed={profileCompletenessDismissed} + profileCompletenessVersion={PROFILE_COMPLETENESS_VERSION} + subject={user.id} + {...(linkedStaffProfile?._id + ? { linkedStaffProfileId: String(linkedStaffProfile._id) } + : {})} onGoToTab={handleGoToTab} /> @@ -187,4 +237,4 @@ export default function AccountPage() {
); -} +} \ No newline at end of file diff --git a/src/app/staff/[id]/page.tsx b/src/app/staff/[id]/page.tsx index 8620841..5c1cddb 100644 --- a/src/app/staff/[id]/page.tsx +++ b/src/app/staff/[id]/page.tsx @@ -2,10 +2,10 @@ import { useAuthUser } from '@/hooks/useAuthUser'; import { useParams } from 'next/navigation'; -import { useState } from 'react'; +import { useState, type ReactNode } from 'react'; import { useQuery, useMutation } from 'convex/react'; import { api } from '@/convex/_generated/api'; -import type { Id } from '@/convex/_generated/dataModel'; +import type { Doc, Id } from '@/convex/_generated/dataModel'; import { StandardizedSidebarLayout } from '@/components/layout/StandardizedSidebarLayout'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -14,12 +14,12 @@ import { useToast } from '@/hooks/use-toast'; import { useAcademicYear } from '@/components/providers/AcademicYearProvider'; import { PermissionGate } from '@/components/common/PermissionGate'; import { - CheckCircle, AlertTriangle, - User, - Link2, + CheckCircle, Edit, + Link2, Shield, + User, } from 'lucide-react'; import { Dialog, @@ -27,16 +27,31 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; import { withToast } from '@/lib/utils'; +import { toDisplayLabel } from '@/lib/formatters'; import { EditStaffForm } from '@/components/domain/EditStaffForm'; import { DeactivateConfirmationModal } from '@/components/domain/DeactivateConfirmationModal'; -import type { Doc } from '@/convex/_generated/dataModel'; -// Force dynamic rendering to prevent WorkOS authentication errors during build export const dynamic = 'force-dynamic'; +type PreferredWorkingTime = 'am' | 'pm' | 'all_day'; + +type OwnStaffPreferencesFormData = { + prefWorkingLocation?: string; + prefWorkingTime?: PreferredWorkingTime; + prefSpecialism?: string; + prefNotes?: string; +}; + interface AdminAllocation { _id: Id<'admin_allocations'>; categoryId: string; @@ -60,15 +75,57 @@ interface LecturerAllocationDetail { module: Doc<'modules'> | null; } +function formatPreferredWorkingTime(value?: string) { + switch (value) { + case 'am': + return 'AM'; + case 'pm': + return 'PM'; + case 'all_day': + return 'All day'; + default: + return '—'; + } +} + +function DetailRow({ + label, + value, + className, +}: { + label: string; + value: ReactNode; + className?: string; +}) { + return ( +
+
+ {label} +
+
{value}
+
+ ); +} + export default function LecturerProfilePage() { const { user, isLoaded, isSignedIn } = useAuthUser({ redirectOnUnauthenticated: true, }); + const params = useParams(); const { toast } = useToast(); + const { currentYear } = useAcademicYear(); const profileId = params.id as string; const [isEditing, setIsEditing] = useState(false); + const [isEditingOwnPreferences, setIsEditingOwnPreferences] = useState(false); const [showDeactivateModal, setShowDeactivateModal] = useState(false); const [showLinkConfirm, setShowLinkConfirm] = useState(false); @@ -77,7 +134,6 @@ export default function LecturerProfilePage() { profileId ? { profileId: profileId as Id<'lecturer_profiles'> } : 'skip' ); - const { currentYear } = useAcademicYear(); const _adminAllocations = useQuery( api.allocations.listAdminAllocations, profileId && currentYear?._id @@ -87,12 +143,12 @@ export default function LecturerProfilePage() { } : 'skip' ) as AdminAllocation[] | undefined; + const _groupAllocations = useQuery( api.allocations.listForLecturer, 'skip' ) as GroupAllocation[] | undefined; - // Permission checks const canEdit = useQuery(api.permissions.hasPermission, { userId: user?.id || '', permissionId: 'staff.edit', @@ -100,10 +156,9 @@ export default function LecturerProfilePage() { const _canDeactivate = useQuery(api.permissions.hasPermission, { userId: user?.id || '', - permissionId: 'staff.edit', // Using edit permission for deactivate + permissionId: 'staff.edit', }); - // Check if email matches WorkOS user const workosUser = useQuery( api.users.getByEmail, profile?.email ? { email: profile.email } : 'skip' @@ -111,6 +166,46 @@ export default function LecturerProfilePage() { const editMutation = useMutation(api.staff.edit); const deactivateMutation = useMutation(api.staff.edit); + const updateOwnPreferencesMutation = useMutation( + api.staff.updateOwnPreferences + ); + + const isOwnLinkedProfile = Boolean( + profile?.userSubject && user?.id && profile.userSubject === user.id + ); + + const handleUpdateOwnPreferences = async ( + formData: OwnStaffPreferencesFormData + ) => { + try { + await updateOwnPreferencesMutation({ + profileId: profileId as Id<'lecturer_profiles'>, + userId: user?.id || '', + prefWorkingLocation: formData.prefWorkingLocation?.trim() ?? '', + ...(formData.prefWorkingTime + ? { prefWorkingTime: formData.prefWorkingTime } + : {}), + prefSpecialism: formData.prefSpecialism?.trim() ?? '', + prefNotes: formData.prefNotes?.trim() ?? '', + }); + + setIsEditingOwnPreferences(false); + + toast({ + title: 'Preferences updated', + description: 'Your staff profile preferences have been updated.', + }); + } catch (error) { + toast({ + title: 'Update failed', + description: + error instanceof Error + ? error.message + : 'Failed to update your profile preferences.', + variant: 'destructive', + }); + } + }; const handleEdit = async ( formData: Partial<{ @@ -128,7 +223,7 @@ export default function LecturerProfilePage() { prefNotes?: string; isActive: boolean; contractFamily?: string; - prefWorkingTime?: 'am' | 'pm' | 'all_day'; + prefWorkingTime?: PreferredWorkingTime; }> ) => { try { @@ -139,13 +234,14 @@ export default function LecturerProfilePage() { }); setIsEditing(false); + toast({ - title: 'Profile Updated', + title: 'Profile updated', description: 'Lecturer profile has been updated successfully.', }); } catch { toast({ - title: 'Update Failed', + title: 'Update failed', description: 'Failed to update lecturer profile. Please try again.', variant: 'destructive', }); @@ -154,22 +250,25 @@ export default function LecturerProfilePage() { const handleLinkUser = async () => { if (!profile || !workosUser) return; + try { await editMutation({ profileId: profileId as Id<'lecturer_profiles'>, userSubject: workosUser.subject, userId: user?.id || '', }); + toast({ title: 'Profile linked', description: 'Lecturer profile linked to user account.', }); - // ensure UI shows linked state immediately + window.location.reload(); - } catch (e) { + } catch (error) { toast({ title: 'Link failed', - description: e instanceof Error ? e.message : 'An error occurred', + description: + error instanceof Error ? error.message : 'An error occurred', variant: 'destructive', }); } @@ -184,13 +283,14 @@ export default function LecturerProfilePage() { }); setShowDeactivateModal(false); + toast({ - title: 'Profile Deactivated', + title: 'Profile deactivated', description: 'Lecturer profile has been deactivated.', }); } catch { toast({ - title: 'Deactivation Failed', + title: 'Deactivation failed', description: 'Failed to deactivate lecturer profile. Please try again.', variant: 'destructive', }); @@ -206,12 +306,12 @@ export default function LecturerProfilePage() { }); toast({ - title: 'Profile Reactivated', + title: 'Profile reactivated', description: 'Lecturer profile has been reactivated.', }); } catch { toast({ - title: 'Reactivation Failed', + title: 'Reactivation failed', description: 'Failed to reactivate lecturer profile. Please try again.', }); } @@ -228,13 +328,8 @@ export default function LecturerProfilePage() { ); } - if (!isLoaded) { - return null; - } - - if (!isSignedIn || !user) { - return null; - } + if (!isLoaded) return null; + if (!isSignedIn || !user) return null; if (!user.organisationId) { return ( @@ -266,7 +361,7 @@ export default function LecturerProfilePage() { )} - {profile?.userSubject ? ( + {profile.userSubject ? ( WorkOS User + ) )} - {canEdit && ( + {canEdit ? ( <> @@ -312,7 +409,7 @@ export default function LecturerProfilePage() { size="sm" onClick={() => setShowDeactivateModal(true)} > - + Deactivate ) : ( @@ -321,48 +418,122 @@ export default function LecturerProfilePage() { size="sm" onClick={handleReactivate} > - + Reactivate )} - )} + ) : null} } > -
- - - Details - - -
Role: {profile.role || '—'}
-
Team: {profile.teamName || '—'}
-
Contract: {profile.contract}
-
FTE: {profile.fte}
-
Max Teaching: {profile.maxTeachingHours}h
-
Total Contract: {profile.totalContract}h
-
Pref Location: {profile.prefWorkingLocation || '—'}
-
- Pref Working Time:{' '} - {profile.prefWorkingTime === 'am' - ? 'AM' - : profile.prefWorkingTime === 'pm' - ? 'PM' - : profile.prefWorkingTime === 'all_day' - ? 'All day' - : '—'} +
+ + + Details + + + +
+

+ Employment details +

+ +
+ + + + + + +
+
+ +
+
+

+ Preferences +

+ + {isOwnLinkedProfile ? ( + + + + + + + Edit your preferred location, working time, specialism and notes + + + + ) : null}
-
Specialism: {profile.prefSpecialism || '—'}
-
Notes: {profile.prefNotes || '—'}
- - + +
+ + + + + {profile.prefNotes} + + ) : ( + '—' + ) + } + /> +
+
+
+
Module Allocations (current AY) + @@ -372,6 +543,7 @@ export default function LecturerProfilePage() { Admin Allocations +
- {/* Edit Form Modal */} - {isEditing && ( + {isEditingOwnPreferences ? ( + + ) : null} + + {isEditing ? ( setIsEditing(false)} /> - )} + ) : null} - {/* Deactivate Confirmation Modal */} - {showDeactivateModal && ( + {showDeactivateModal ? ( setShowDeactivateModal(false)} /> - )} + ) : null} - {/* Link Confirmation */} Link Profile to WorkOS User +
-
- This will link this lecturer profile to the WorkOS account: -
+
This will link this lecturer profile to the WorkOS account:
+
Name:{' '} @@ -418,10 +596,12 @@ export default function LecturerProfilePage() { Email: {workosUser?.email}
+
You can change this later by editing the profile.
+
+ @@ -558,25 +757,27 @@ function ModuleAllocationsTable({ ) : data.length === 0 ? (
No allocations
) : ( -
    +
      {data.map(({ allocation, group, module }) => { const hours = typeof allocation.hoursOverride === 'number' ? allocation.hoursOverride : allocation.hoursComputed || 0; + return (
    • - {module?.code || ''} — {module?.name || ''} + {module?.code || ''} - {module?.name || ''}
      Group: {group?.name || ''}
      +
      {allocation.type}
      {hours}h
      @@ -600,18 +801,21 @@ function AdminAllocationsTable({ const { currentYear } = useAcademicYear(); const { user } = useAuthUser(); const { toast } = useToast(); + const orgCategories = useQuery( api.allocations.listOrganisationAdminCategories, user?.id ? { userId: user.id } : 'skip' ); + const sysCategories = useQuery(api.allocations.listAdminCategories, {}); const categories = orgCategories && orgCategories.length > 0 ? orgCategories : sysCategories; + const rows = useQuery( api.allocations.listAdminAllocations, currentYear?._id ? { - lecturerId: lecturerId, + lecturerId, academicYearId: currentYear._id, } : 'skip' @@ -624,8 +828,10 @@ function AdminAllocationsTable({ | null; }> | undefined; + const upsert = useMutation(api.allocations.upsertAdminAllocation); const remove = useMutation(api.allocations.removeAdminAllocation); + const [isSaving, setIsSaving] = useState(null); const [isRemoving, setIsRemoving] = useState(null); @@ -637,10 +843,12 @@ function AdminAllocationsTable({ customLabel?: string; comment?: string; }>(null); + const handleSave = async () => { - if (!canManageAllocations) return; - if (!formOpen) return; + if (!canManageAllocations || !formOpen || !currentYear || !user) return; + const hours = Number(formOpen.hours); + if (!Number.isFinite(hours) || hours < 0 || hours > 1000) { toast({ title: 'Invalid hours', @@ -649,14 +857,16 @@ function AdminAllocationsTable({ }); return; } + setIsSaving(formOpen.id || 'new'); + try { await withToast( () => upsert({ - userId: user!.id, - lecturerId: lecturerId, - academicYearId: currentYear!._id, + userId: user.id, + lecturerId, + academicYearId: currentYear._id, ...(formOpen.isCustom ? { isCustom: true, @@ -675,6 +885,7 @@ function AdminAllocationsTable({ }, toast ); + setFormOpen(null); } finally { setIsSaving(null); @@ -682,12 +893,14 @@ function AdminAllocationsTable({ }; const handleRemove = async (allocationId: Id<'admin_allocations'>) => { - if (!canManageAllocations) return; + if (!canManageAllocations || !user) return; if (!confirm('Remove allocation?')) return; + setIsRemoving(allocationId); + try { await withToast( - () => remove({ userId: user!.id, allocationId: allocationId }), + () => remove({ userId: user.id, allocationId }), { success: { title: 'Allocation removed' }, error: { title: 'Remove failed' }, @@ -699,33 +912,37 @@ function AdminAllocationsTable({ } }; - if (!currentYear) + if (!currentYear) { return (
      Select an academic year
      ); + } const totalAdminHours = (rows || []).reduce( - (acc, r) => acc + (Number(r?.allocation?.hours) || 0), + (acc, row) => acc + (Number(row?.allocation?.hours) || 0), 0 ); return (
      -
      +
      Academic Year: {currentYear.name} Admin total: {totalAdminHours}h
      - {canManageAllocations && ( + + {canManageAllocations ? ( - )} + ) : null}
      + {!Array.isArray(rows) ? (
      Loading…
      ) : rows.length === 0 ? ( @@ -747,7 +965,7 @@ function AdminAllocationsTable({ {rows.map(({ allocation, category }) => (
      @@ -755,21 +973,25 @@ function AdminAllocationsTable({ ? allocation.customLabel : category?.name || allocation.categoryId}
      - {category?.description && ( + + {category?.description ? (
      {category.description}
      - )} - {allocation.isCustom && allocation.comment && ( + ) : null} + + {allocation.isCustom && allocation.comment ? (
      {allocation.comment}
      - )} + ) : null} +
      {allocation.hours}h
      - {canManageAllocations && ( + + {canManageAllocations ? (
      +
      - )} + ) : null}
      ))}
      )} - {formOpen && ( -
      -
      + + {formOpen ? ( +
      +
      {formOpen.id ? 'Edit Admin Allocation' : 'Add Admin Allocation'}
      +
      -
      + {formOpen.isCustom ? (
      + onChange={(event) => setFormOpen( - (f) => f && { ...f, customLabel: e.target.value } + (current) => + current && { + ...current, + customLabel: event.target.value, + } ) } /> + - setFormOpen((f) => f && { ...f, comment: e.target.value }) + onChange={(event) => + setFormOpen( + (current) => + current && { + ...current, + comment: event.target.value, + } + ) } />
      ) : ( )}
      +
      - setFormOpen((f) => f && { ...f, hours: e.target.value }) + onChange={(event) => + setFormOpen( + (current) => + current && { ...current, hours: event.target.value } + ) } type="number" min="0" max="1000" />
      +
      -
      - )} + ) : null}
      ); } + +function EditOwnStaffPreferencesDialog({ + open, + profile, + onOpenChange, + onSave, +}: { + open: boolean; + profile: Doc<'lecturer_profiles'>; + onOpenChange: (open: boolean) => void; + onSave: (formData: OwnStaffPreferencesFormData) => Promise; +}) { + const [prefWorkingLocation, setPrefWorkingLocation] = useState( + profile.prefWorkingLocation ?? '' + ); + + const initialPrefWorkingTime: '' | PreferredWorkingTime = + profile.prefWorkingTime === 'am' || + profile.prefWorkingTime === 'pm' || + profile.prefWorkingTime === 'all_day' + ? profile.prefWorkingTime + : ''; + + const [prefWorkingTime, setPrefWorkingTime] = useState< + '' | PreferredWorkingTime + >(initialPrefWorkingTime); + + const [prefSpecialism, setPrefSpecialism] = useState( + profile.prefSpecialism ?? '' + ); + const [prefNotes, setPrefNotes] = useState(profile.prefNotes ?? ''); + const [isSaving, setIsSaving] = useState(false); + + const handleSave = async () => { + setIsSaving(true); + + try { + const formData: OwnStaffPreferencesFormData = { + prefWorkingLocation, + ...(prefWorkingTime ? { prefWorkingTime } : {}), + prefSpecialism, + prefNotes, + }; + + await onSave(formData); + } finally { + setIsSaving(false); + } + }; + + return ( + + + + Edit your preferences + + +
      +
      + + setPrefWorkingLocation(event.target.value)} + placeholder="e.g. Brentford, Reading, remote" + /> +
      + +
      + + +
      + +
      + + setPrefSpecialism(event.target.value)} + placeholder="e.g. simulation, paramedicine, assessment" + /> +
      + +
      + +