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/actions.ts b/src/app/account/actions.ts new file mode 100644 index 0000000..6f03bb1 --- /dev/null +++ b/src/app/account/actions.ts @@ -0,0 +1,69 @@ +'use server'; + +type SendPasswordResetState = { + success: boolean; + error: string | null; +}; + +const genericErrorMessage = + 'Unable to send password reset email. Please try again.'; + +export async function sendPasswordResetEmail( + _previousState: SendPasswordResetState, + formData: FormData +): Promise { + const email = String(formData.get('email') ?? '').trim(); + + if (!email) { + return { + success: false, + error: 'Email address is missing.', + }; + } + + const apiKey = process.env.WORKOS_API_KEY; + const clientId = process.env.WORKOS_CLIENT_ID; + const passwordResetUrl = process.env.WORKOS_PASSWORD_RESET_URL; + + if (!apiKey || !clientId || !passwordResetUrl) { + return { + success: false, + error: 'Password reset is not configured.', + }; + } + + try { + const response = await fetch( + 'https://api.workos.com/user_management/password_reset/send', + { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email, + client_id: clientId, + password_reset_url: passwordResetUrl, + }), + } + ); + + if (!response.ok) { + return { + success: false, + error: genericErrorMessage, + }; + } + + return { + success: true, + error: null, + }; + } catch { + return { + success: false, + error: genericErrorMessage, + }; + } +} \ No newline at end of file diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index c73b16f..0bfdcb0 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} /> @@ -177,7 +227,7 @@ export default function AccountPage() { - + @@ -187,4 +237,4 @@ export default function AccountPage() {
); -} +} \ No newline at end of file diff --git a/src/app/api/reset-password/route.ts b/src/app/api/reset-password/route.ts deleted file mode 100644 index d74e72b..0000000 --- a/src/app/api/reset-password/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getAuthContext } from '@/lib/auth'; - -export async function POST() { - try { - await getAuthContext(); - return NextResponse.json({ - success: true, - message: 'Password reset is handled by WorkOS session management.', - }); - } catch { - return NextResponse.json({ error: 'Unauthorised' }, { status: 401 }); - } -} diff --git a/src/app/reset-password/page.tsx b/src/app/reset-password/page.tsx deleted file mode 100644 index b2ac355..0000000 --- a/src/app/reset-password/page.tsx +++ /dev/null @@ -1,272 +0,0 @@ -'use client'; - -import { useState, useEffect, Suspense } from 'react'; -import { useSearchParams, useRouter } from 'next/navigation'; -import { useAuthUser } from '@/hooks/useAuthUser'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Loader2, CheckCircle, AlertTriangle, Eye, EyeOff } from 'lucide-react'; - -export default function ResetPasswordPage() { - return ( - - Loading… - - } - > - - - ); -} - -function ResetPasswordForm() { - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); - const [token, setToken] = useState(null); - - const { signIn, setActive: _setActive } = useAuthUser(); - // Wrap useSearchParams usage to appease Next.js build when prerendering - const searchParams = useSearchParams(); - const router = useRouter(); - - useEffect(() => { - const tokenParam = searchParams.get('token'); - if (!tokenParam) { - setError('No reset token provided. Please use the link from your email.'); - return; - } - setToken(tokenParam); - }, [searchParams]); - - const validatePassword = (password: string) => { - if (password.length < 8) { - return 'Password must be at least 8 characters long'; - } - if (!/(?=.*[a-z])/.test(password)) { - return 'Password must contain at least one lowercase letter'; - } - if (!/(?=.*[A-Z])/.test(password)) { - return 'Password must contain at least one uppercase letter'; - } - if (!/(?=.*\d)/.test(password)) { - return 'Password must contain at least one number'; - } - if (!/(?=.*[!@#$%^&*])/.test(password)) { - return 'Password must contain at least one special character (!@#$%^&*)'; - } - return null; - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!token) { - setError('No reset token available'); - return; - } - - // Validate passwords - const passwordError = validatePassword(password); - if (passwordError) { - setError(passwordError); - return; - } - - if (password !== confirmPassword) { - setError('Passwords do not match'); - return; - } - - setIsLoading(true); - setError(null); - - try { - // First, create a sign-in attempt with the reset token - const signInAttempt = await signIn?.create({ - identifier: token, - strategy: 'reset_password_email_code', - }); - - if (!signInAttempt) { - throw new Error('Failed to create sign-in attempt'); - } - - // Then attempt to complete the password reset - const result = await signIn?.attemptFirstFactor({ - strategy: 'reset_password_email_code', - code: token, - password: password, - }); - - if (result?.status === 'complete') { - setSuccess(true); - // Redirect to dashboard after a short delay - setTimeout(() => { - router.push('/dashboard'); - }, 2000); - } else { - setError( - 'Failed to reset password. Please try again or contact support.' - ); - } - } catch { - setError( - 'Failed to reset password. The token may be invalid or expired.' - ); - } finally { - setIsLoading(false); - } - }; - - if (success) { - return ( -
- - -
- -
- - Password Reset Successful - - - Your password has been successfully reset. You will be redirected - to the dashboard shortly. - -
-
-
- ); - } - - return ( -
- - - Reset Your Password - Enter your new password below - - - {error && ( - - - - {error} - - - )} - -
-
- -
- setPassword(e.target.value)} - placeholder="Enter your new password" - required - disabled={isLoading} - /> - -
-
- -
- -
- setConfirmPassword(e.target.value)} - placeholder="Confirm your new password" - required - disabled={isLoading} - /> - -
-
- -
-

Password must contain:

-
    -
  • At least 8 characters
  • -
  • One uppercase letter
  • -
  • One lowercase letter
  • -
  • One number
  • -
  • One special character (!@#$%^&*)
  • -
-
- - -
- -
- -
-
-
-
- ); -} 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" + /> +
      + +
      + +