diff --git a/client/src/routes/(authenticated)/admin.departments.tsx b/client/src/routes/(authenticated)/admin.departments.tsx index 40cab2c..cb55ac8 100644 --- a/client/src/routes/(authenticated)/admin.departments.tsx +++ b/client/src/routes/(authenticated)/admin.departments.tsx @@ -1,10 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { createFileRoute } from '@tanstack/react-router'; import { - type AdminDepartment, useAdminData, } from '@/shared/admin/adminData.tsx'; import { HttpError } from '@/lib/api.ts'; +import { DepartmentRow } from '@/shared/admin/DepartmentRow.tsx'; +import { DepartmentSettingsModal } from '@/shared/admin/DepartmentSettingsModal.tsx'; export const Route = createFileRoute('/(authenticated)/admin/departments')({ component: AdminDepartmentsRoute, @@ -211,6 +212,7 @@ function AdminDepartmentsRoute() { setEditingDepartmentId(null)} onRemoveRoutingEmail={(emailId) => removeRoutingEmail(editingDepartment.id, emailId) @@ -229,374 +231,6 @@ function AdminDepartmentsRoute() { ); } -function NameEditor({ - inputClassName, - name, - onSave, - savingMessage, - wrapperClassName, -}: { - inputClassName: string; - name: string; - onSave: (name: string) => Promise; - savingMessage: string; - wrapperClassName?: string; -}) { - const [value, setValue] = useState(name); - const [error, setError] = useState(null); - const [isSaving, setIsSaving] = useState(false); - - useEffect(() => { - setValue(name); - setError(null); - }, [name]); - - const handleBlur = async () => { - const nextName = value.trim(); - - if (!nextName) { - setValue(name); - setError(null); - return; - } - - if (nextName === name) { - setError(null); - return; - } - - setIsSaving(true); - setError(null); - - try { - await onSave(nextName); - setValue(nextName); - } catch (mutationError) { - setError(getAdminMutationErrorMessage(mutationError)); - } finally { - setIsSaving(false); - } - }; - - return ( -
- { - void handleBlur(); - }} - onChange={(event) => setValue(event.target.value)} - value={value} - /> - {error ? ( -

{error}

- ) : isSaving ? ( -

- {savingMessage} -

- ) : null} -
- ); -} - -function DepartmentRow({ - department, - linkedUserCount, - onOpenRoster, - onOpenSettings, - onRename, -}: { - department: AdminDepartment; - linkedUserCount: number; - onOpenRoster: () => void; - onOpenSettings: () => void; - onRename: (name: string) => Promise; -}) { - const approvalLabel = - department.approvalMode === 'approval' - ? 'Approval required' - : department.approvalMode === 'auto' - ? 'Auto-approve' - : 'Notification only'; - - return ( -
-
-
-
- - -
-
- {department.code} -
-
- {linkedUserCount} active users · {approvalLabel} - {department.routingEmails.length > 0 - ? ` · ${department.routingEmails.length} routing emails` - : ' · No email configured'} -
-
- -
-
- Database-backed settings -
- -
-
-
- ); -} - -function DepartmentSettingsModal({ - clusters, - department, - onClose, - onRemoveRoutingEmail, - onSave, - onUpsertRoutingEmail, -}: { - clusters: Array<{ id: string; name: string }>; - department: AdminDepartment; - onClose: () => void; - onRemoveRoutingEmail: (emailId: string) => Promise; - onSave: ( - updates: Partial> - ) => Promise; - onUpsertRoutingEmail: (email: { - address: string; - id?: string; - kind: 'to' | 'cc'; - }) => Promise; -}) { - const [approvalMode, setApprovalMode] = useState(department.approvalMode); - const [clusterId, setClusterId] = useState(department.clusterId ?? ''); - const [newEmail, setNewEmail] = useState(''); - const [saveError, setSaveError] = useState(null); - const [routingEmailError, setRoutingEmailError] = useState( - null - ); - const [isSaving, setIsSaving] = useState(false); - const [isAddingEmail, setIsAddingEmail] = useState(false); - const [pendingRemovalEmailId, setPendingRemovalEmailId] = useState< - string | null - >(null); - - const handleSave = async () => { - setIsSaving(true); - setSaveError(null); - - try { - await onSave({ - approvalMode, - clusterId: clusterId || null, - }); - } catch (mutationError) { - setSaveError(getAdminMutationErrorMessage(mutationError)); - } finally { - setIsSaving(false); - } - }; - - const handleAddEmail = async () => { - const emailAddress = newEmail.trim(); - - if (!emailAddress) { - return; - } - - setIsAddingEmail(true); - setRoutingEmailError(null); - - try { - await onUpsertRoutingEmail({ - address: emailAddress, - kind: 'to', - }); - setNewEmail(''); - } catch (mutationError) { - setRoutingEmailError(getAdminMutationErrorMessage(mutationError)); - } finally { - setIsAddingEmail(false); - } - }; - - const handleRemoveEmail = async (emailId: string) => { - setPendingRemovalEmailId(emailId); - setRoutingEmailError(null); - - try { - await onRemoveRoutingEmail(emailId); - } catch (mutationError) { - setRoutingEmailError(getAdminMutationErrorMessage(mutationError)); - } finally { - setPendingRemovalEmailId(null); - } - }; - - return ( -
-
-

- Department settings -

-

- These controls persist to the database for cluster placement, workflow - mode, and routing emails. -

- -
- - - -
- -
-

- Routing emails -

-

- Routing emails are now stored in `DepartmentEmailRouting`. -

- -
- {department.routingEmails.map((email) => ( -
- - EMAIL - - - {email.address} - - -
- ))} -
- - {routingEmailError ? ( -
- {routingEmailError} -
- ) : null} - -
- setNewEmail(event.target.value)} - placeholder="email@ucdavis.edu" - type="email" - value={newEmail} - /> - -
-
- - {saveError ? ( -
- {saveError} -
- ) : null} - -
- - -
-
-
- ); -} - function getAdminMutationErrorMessage(error: unknown) { if (error instanceof HttpError) { if (typeof error.body === 'string' && error.body.trim()) { diff --git a/client/src/routes/(authenticated)/admin.status.tsx b/client/src/routes/(authenticated)/admin.status.tsx index f2d4cbc..78bbbab 100644 --- a/client/src/routes/(authenticated)/admin.status.tsx +++ b/client/src/routes/(authenticated)/admin.status.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from '@tanstack/react-router'; +import { AdminStatusContent } from '@/shared/admin/AdminStatusContent.tsx'; import { useAdminData } from '@/shared/admin/adminData.tsx'; export const Route = createFileRoute('/(authenticated)/admin/status')({ @@ -9,394 +10,10 @@ function AdminStatusRoute() { const { dataSources, departments, statusSnapshot } = useAdminData(); return ( -
-
- - - - -
- -
- -
- {dataSources.map((source) => ( - - ))} -
-
- - -
- - - - - -
-
-
- -
- - - - - - - - - - - ({ - label, - value, - }) - )} - /> - - - - - -
-
- ); -} - -function Card({ - children, - title, -}: { - children: React.ReactNode; - title: string; -}) { - return ( -
-

{title}

-
{children}
-
- ); -} - -function StatCard({ - accent, - label, - sublabel, - value, -}: { - accent?: string; - label: string; - sublabel: string; - value: string; -}) { - return ( -
-

- {label} -

-

- {value} -

-

{sublabel}

-
- ); -} - -function FreshnessRow({ - detail, - label, - status, - updatedAt, -}: { - detail: string; - label: string; - status: 'ready' | 'planned' | 'deferred'; - updatedAt: string | null; -}) { - const updatedLabel = updatedAt - ? new Date(updatedAt).toLocaleString() - : 'No rows loaded yet'; - const tone = - status === 'ready' - ? 'text-emerald-700 bg-emerald-50' - : status === 'planned' - ? 'text-amber-700 bg-amber-50' - : 'text-slate-600 bg-slate-100'; - - return ( -
-
-
{label}
-
- {detail} -
-
-
- - {status} - -
- {updatedLabel} -
-
-
- ); -} - -function IssueRow({ - count, - label, - tone, -}: { - count: number; - label: string; - tone: 'error' | 'warning' | 'neutral'; -}) { - const styles = { - error: 'bg-rose-600', - neutral: 'bg-slate-400', - warning: 'bg-amber-500', - } as const; - - return ( -
- - {label} - - {count} - -
- ); -} - -function MetricGrid({ - items, -}: { - items: Array<{ - label: string; - tone?: 'amber' | 'emerald' | 'rose' | 'violet'; - value: string; - }>; -}) { - const toneClasses = { - amber: 'text-amber-700', - emerald: 'text-emerald-700', - rose: 'text-rose-700', - violet: 'text-violet-700', - } as const; - - return ( -
- {items.map((item) => ( -
-
- {item.label} -
-
- {item.value} -
-
- ))} -
- ); -} - -function TypeBreakdown({ - className, - items, -}: { - className?: string; - items: Array<{ - label: string; - value: number; - }>; -}) { - const total = items.reduce((sum, item) => sum + item.value, 0); - const tones = [ - { - bar: 'bg-[var(--admin-blue)]', - dot: 'bg-[var(--admin-blue)]', - }, - { - bar: 'bg-emerald-600', - dot: 'bg-emerald-600', - }, - { - bar: 'bg-amber-600', - dot: 'bg-amber-600', - }, - { - bar: 'bg-violet-600', - dot: 'bg-violet-600', - }, - ]; - - return ( -
-
-

- By type -

- - {total} total - -
-
- {items.map((item, index) => { - const share = total > 0 ? Math.round((item.value / total) * 100) : 0; - const tone = tones[index % tones.length]; - - return ( -
-
-
- - - {item.label} - -
-
- - {item.value} - - - {share}% - -
-
-
-
-
-
- ); - })} -
-
+ ); } diff --git a/client/src/routes/(authenticated)/admin.users.tsx b/client/src/routes/(authenticated)/admin.users.tsx index 0a2bae8..438cac9 100644 --- a/client/src/routes/(authenticated)/admin.users.tsx +++ b/client/src/routes/(authenticated)/admin.users.tsx @@ -1,9 +1,11 @@ import { useState } from 'react'; import { createFileRoute } from '@tanstack/react-router'; import type { ColumnDef } from '@tanstack/react-table'; -import { z } from 'zod'; import { HttpError } from '@/lib/api.ts'; -import type { AdminUser } from '@/shared/admin/adminData.tsx'; +import { AdminUserModal } from '@/shared/admin/AdminUserModal.tsx'; +import type { + AdminUser, +} from '@/shared/admin/adminData.tsx'; import { useAdminData } from '@/shared/admin/adminData.tsx'; import { DataTable } from '@/shared/dataTable.tsx'; @@ -15,37 +17,6 @@ type UserRow = AdminUser & { departmentName: string; }; -const userFormSchema = z.object({ - email: z - .string() - .trim() - .refine( - (value) => value.length === 0 || z.email().safeParse(value).success, - 'Enter a valid email address.' - ), - employeeId: z - .string() - .trim() - .refine( - (value) => value.length === 0 || /^\d{8}$/.test(value), - 'Employee ID must be exactly 8 digits.' - ), - iamId: z - .string() - .trim() - .min(1, 'IAM ID is required.') - .max(10, 'IAM ID must be 10 characters or fewer.') - .regex( - /^[a-z][\w-]*$/i, - 'IAM ID must start with a letter and use only letters, numbers, underscores, or hyphens.' - ), - name: z.string().trim().min(1, 'Display name is required.'), -}); - -type UserFormValues = z.infer; -type UserFormField = keyof UserFormValues; -type UserFormErrors = Partial>; - function AdminUsersRoute() { const { createUser, departments, readonlyReason, updateUser, users } = useAdminData(); @@ -250,24 +221,54 @@ function AdminUsersRoute() { {editingUser ? ( - setEditingUserId(null)} - onSave={(updates) => - updateUser(editingUser.id, updates).then(() => { - setEditingUserId(null); - }) - } - user={editingUser} + onSubmit={async (value) => { + await updateUser(editingUser.id, { + active: value.active, + email: value.email, + employeeId: value.employeeId, + iamId: value.iamId, + name: value.name, + }); + }} + submitErrorMessage={getUserUpdateErrorMessage} + submitLabel="Save changes" + submittingLabel="Saving..." + title={`Edit ${editingUser.name}`} /> ) : null} {showCreateModal ? ( - setShowCreateModal(false)} - onCreate={async (payload) => { - await createUser(payload); - setShowCreateModal(false); + onSubmit={async (value) => { + await createUser({ + email: value.email, + employeeId: value.employeeId, + iamId: value.iamId, + name: value.name, + }); }} + showActiveField={false} + submitErrorMessage={getUserCreateErrorMessage} + submitLabel="Create user" + submittingLabel="Creating..." + title="Add user" /> ) : null}
@@ -298,30 +299,6 @@ function SummaryCard({ ); } -function ModalFrame({ - children, - title, -}: { - children: React.ReactNode; - title: string; -}) { - return ( -
-
-
-

- {title} -

-

- These edits now persist to the AppUser table. -

-
- {children} -
-
- ); -} - function UserStatusToggle({ active, onToggle, @@ -364,300 +341,6 @@ function UserStatusToggle({ ); } -function UserEditorModal({ - onClose, - onSave, - user, -}: { - onClose: () => void; - onSave: ( - updates: Partial< - Pick - > - ) => Promise; - user: AdminUser; -}) { - const [name, setName] = useState(user.name); - const [email, setEmail] = useState(user.email); - const [employeeId, setEmployeeId] = useState(user.employeeId); - const [iamId, setIamId] = useState(user.iamId); - const [active, setActive] = useState(user.active); - const [fieldErrors, setFieldErrors] = useState({}); - const [submitError, setSubmitError] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - - function clearFieldError(field: UserFormField) { - setFieldErrors((current) => { - if (!current[field]) { - return current; - } - - return { - ...current, - [field]: undefined, - }; - }); - } - - async function handleSubmit() { - setSubmitError(null); - - const formValues: UserFormValues = { - email, - employeeId, - iamId, - name, - }; - - const result = userFormSchema.safeParse(formValues); - - if (!result.success) { - const nextFieldErrors: UserFormErrors = {}; - for (const issue of result.error.issues) { - const field = issue.path[0]; - if (typeof field === 'string' && !nextFieldErrors[field as UserFormField]) { - nextFieldErrors[field as UserFormField] = issue.message; - } - } - - setFieldErrors(nextFieldErrors); - return; - } - - setFieldErrors({}); - setIsSubmitting(true); - - try { - await onSave({ - active, - email: result.data.email.trim(), - employeeId: result.data.employeeId.trim(), - iamId: result.data.iamId.trim(), - name: result.data.name.trim(), - }); - } catch (error) { - setSubmitError(getUserUpdateErrorMessage(error)); - } finally { - setIsSubmitting(false); - } - } - - return ( - -
- { - setName(value); - clearFieldError('name'); - }} - value={name} - /> - { - setEmail(value); - clearFieldError('email'); - }} - type="email" - value={email} - /> - { - setEmployeeId(value); - clearFieldError('employeeId'); - }} - value={employeeId} - /> - { - setIamId(value); - clearFieldError('iamId'); - }} - value={iamId} - /> -
- - - - {submitError ? ( -
- {submitError} -
- ) : null} - -
- - -
-
- ); -} - -function CreateUserModal({ - onClose, - onCreate, -}: { - onClose: () => void; - onCreate: (payload: { - email: string; - employeeId: string; - iamId: string; - name: string; - }) => Promise; -}) { - const [name, setName] = useState(''); - const [email, setEmail] = useState(''); - const [employeeId, setEmployeeId] = useState(''); - const [iamId, setIamId] = useState(''); - const [fieldErrors, setFieldErrors] = useState({}); - const [submitError, setSubmitError] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - - function clearFieldError(field: UserFormField) { - setFieldErrors((current) => { - if (!current[field]) { - return current; - } - - return { - ...current, - [field]: undefined, - }; - }); - } - - async function handleSubmit() { - setSubmitError(null); - - const formValues: UserFormValues = { - email, - employeeId, - iamId, - name, - }; - - const result = userFormSchema.safeParse(formValues); - - if (!result.success) { - const nextFieldErrors: UserFormErrors = {}; - for (const issue of result.error.issues) { - const field = issue.path[0]; - if (typeof field === 'string' && !nextFieldErrors[field as UserFormField]) { - nextFieldErrors[field as UserFormField] = issue.message; - } - } - - setFieldErrors(nextFieldErrors); - return; - } - - setFieldErrors({}); - setIsSubmitting(true); - - try { - await onCreate({ - email: result.data.email.trim(), - employeeId: result.data.employeeId.trim(), - iamId: result.data.iamId.trim(), - name: result.data.name.trim(), - }); - } catch (error) { - setSubmitError(getUserCreateErrorMessage(error)); - } finally { - setIsSubmitting(false); - } - } - - return ( - -
- { - setName(value); - clearFieldError('name'); - }} - value={name} - /> - { - setEmail(value); - clearFieldError('email'); - }} - type="email" - value={email} - /> - { - setEmployeeId(value); - clearFieldError('employeeId'); - }} - value={employeeId} - /> - { - setIamId(value); - clearFieldError('iamId'); - }} - value={iamId} - /> -
- - {submitError ? ( -
- {submitError} -
- ) : null} - -
- - -
-
- ); -} function getUserCreateErrorMessage(error: unknown) { return getUserMutationErrorMessage( @@ -718,40 +401,3 @@ function getUserMutationErrorMessage(error: unknown, fallbackMessage: string) { return fallbackMessage; } - -function FormField({ - error, - helperText, - label, - onChange, - type, - value, -}: { - error?: string; - helperText?: string; - label: string; - onChange: (value: string) => void; - type?: 'email' | 'text'; - value: string; -}) { - return ( - - ); -} diff --git a/client/src/shared/admin/AdminMetricCard.tsx b/client/src/shared/admin/AdminMetricCard.tsx new file mode 100644 index 0000000..c69e9d9 --- /dev/null +++ b/client/src/shared/admin/AdminMetricCard.tsx @@ -0,0 +1,41 @@ +export function AdminMetricCard({ + accent, + label, + subtitle, + value, + variant = 'tile', +}: { + accent?: string; + label: string; + subtitle?: string; + value: string; + variant?: 'summary' | 'tile'; +}) { + const containerClassName = + variant === 'summary' + ? 'rounded-[1.25rem] border border-[var(--admin-border)] bg-white p-5 shadow-sm' + : 'rounded-2xl bg-[var(--admin-sand)] px-4 py-4'; + const labelClassName = + variant === 'summary' + ? 'text-xs font-semibold uppercase tracking-[0.24em] text-[var(--admin-gold-deep)]' + : 'text-xs font-semibold uppercase tracking-[0.2em] text-[var(--admin-ink-soft)]'; + const valueClassName = + variant === 'summary' + ? 'mt-3 text-3xl font-bold' + : 'mt-2 text-2xl font-bold'; + const subtitleClassName = 'mt-2 text-sm text-[var(--admin-ink-muted)]'; + + return ( +
+

{label}

+

+ {value} +

+ {subtitle ?

{subtitle}

: null} +
+ ); +} diff --git a/client/src/shared/admin/AdminModalFrame.tsx b/client/src/shared/admin/AdminModalFrame.tsx new file mode 100644 index 0000000..9582661 --- /dev/null +++ b/client/src/shared/admin/AdminModalFrame.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react'; + +export function AdminModalFrame({ + children, + description, + maxWidthClassName, + title, +}: { + children: ReactNode; + description?: string; + maxWidthClassName?: string; + title: string; +}) { + return ( +
+
+
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+ {children} +
+
+ ); +} diff --git a/client/src/shared/admin/AdminStatusContent.tsx b/client/src/shared/admin/AdminStatusContent.tsx new file mode 100644 index 0000000..ff3cd32 --- /dev/null +++ b/client/src/shared/admin/AdminStatusContent.tsx @@ -0,0 +1,330 @@ +import type { ReactNode } from 'react'; +import type { AdminDataSource } from '@/shared/admin/adminData.tsx'; +import { AdminMetricCard } from './AdminMetricCard.tsx'; +import { AdminTypeBreakdown } from './AdminTypeBreakdown.tsx'; + +type MetricItem = { + accent?: string; + label: string; + value: string; +}; + +type StatusSnapshot = { + departments: { + clustered: number; + total: number; + withFaculty: number; + }; + issues: { + approachingVacationCap: number; + excludedUsers: number; + facultyAtVacationCap: number; + missingEmails: number; + pendingRequests: number; + }; + requests: { + bySource: Record<'cognos' | 'manual', number>; + byType: Record; + pending: number; + total: number; + }; + users: { + admins: number; + ayFaculty: number; + caos: number; + chairs: number; + fyFaculty: number; + total: number; + }; +}; + +export function AdminStatusContent({ + dataSources, + departmentCount, + statusSnapshot, +}: { + dataSources: AdminDataSource[]; + departmentCount: number; + statusSnapshot: StatusSnapshot; +}) { + return ( +
+
+ + + + +
+ +
+ +
+ {dataSources.map((source) => ( + + ))} +
+
+ + +
+ + + + + +
+
+
+ +
+ + + + + + + ({ + label, + value, + }) + )} + /> + + + +
+
+ ); +} + +function AdminSectionCard({ + children, + title, +}: { + children: ReactNode; + title: string; +}) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function MetricGrid({ items }: { items: MetricItem[] }) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +function MetricSection({ + items, + title, +}: { + items: MetricItem[]; + title: string; +}) { + return ( + + + + ); +} + +function FreshnessRow({ + detail, + label, + status, + updatedAt, +}: { + detail: string; + label: string; + status: 'ready' | 'planned' | 'deferred'; + updatedAt: string | null; +}) { + const updatedLabel = updatedAt + ? new Date(updatedAt).toLocaleString() + : 'No rows loaded yet'; + const tone = + status === 'ready' + ? 'text-emerald-700 bg-emerald-50' + : status === 'planned' + ? 'text-amber-700 bg-amber-50' + : 'text-slate-600 bg-slate-100'; + + return ( +
+
+
{label}
+
+ {detail} +
+
+
+ + {status} + +
+ {updatedLabel} +
+
+
+ ); +} + +function IssueRow({ + count, + label, + tone, +}: { + count: number; + label: string; + tone: 'error' | 'warning' | 'neutral'; +}) { + const styles = { + error: 'bg-rose-600', + neutral: 'bg-slate-400', + warning: 'bg-amber-500', + } as const; + + return ( +
+ + {label} + + {count} + +
+ ); +} diff --git a/client/src/shared/admin/AdminTypeBreakdown.tsx b/client/src/shared/admin/AdminTypeBreakdown.tsx new file mode 100644 index 0000000..f9d70b7 --- /dev/null +++ b/client/src/shared/admin/AdminTypeBreakdown.tsx @@ -0,0 +1,76 @@ +export function AdminTypeBreakdown({ + className, + items, +}: { + className?: string; + items: Array<{ + label: string; + value: number; + }>; +}) { + const total = items.reduce((sum, item) => sum + item.value, 0); + const tones = [ + { + bar: 'bg-[var(--admin-blue)]', + dot: 'bg-[var(--admin-blue)]', + }, + { + bar: 'bg-emerald-600', + dot: 'bg-emerald-600', + }, + { + bar: 'bg-amber-600', + dot: 'bg-amber-600', + }, + { + bar: 'bg-violet-600', + dot: 'bg-violet-600', + }, + ]; + + return ( +
+
+

+ By type +

+ + {total} total + +
+
+ {items.map((item, index) => { + const share = total > 0 ? Math.round((item.value / total) * 100) : 0; + const tone = tones[index % tones.length]; + + return ( +
+
+
+ + + {item.label} + +
+
+ + {item.value} + + + {share}% + +
+
+
+
+
+
+ ); + })} +
+
+ ); +} diff --git a/client/src/shared/admin/AdminUserModal.tsx b/client/src/shared/admin/AdminUserModal.tsx new file mode 100644 index 0000000..c57a17c --- /dev/null +++ b/client/src/shared/admin/AdminUserModal.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react'; +import { z } from 'zod'; +import { useAppForm } from '@/shared/forms/formContext.tsx'; +import { AdminModalFrame } from './AdminModalFrame.tsx'; + +const userFormSchema = z.object({ + email: z + .string() + .trim() + .refine( + (value) => value.length === 0 || z.email().safeParse(value).success, + 'Enter a valid email address.' + ), + employeeId: z + .string() + .trim() + .refine( + (value) => value.length === 0 || /^\d{8}$/.test(value), + 'Employee ID must be exactly 8 digits.' + ), + iamId: z + .string() + .trim() + .min(1, 'IAM ID is required.') + .max(10, 'IAM ID must be 10 characters or fewer.') + .regex( + /^[a-z][\w-]*$/i, + 'IAM ID must start with a letter and use only letters, numbers, underscores, or hyphens.' + ), + name: z.string().trim().min(1, 'Display name is required.'), +}); + +const editableUserFormSchema = userFormSchema.extend({ + active: z.boolean(), +}); + +export type AdminUserModalValues = z.infer; + +export function AdminUserModal({ + initialValues, + onClose, + onSubmit, + showActiveField = true, + submitErrorMessage, + submitLabel, + submittingLabel, + title, +}: { + initialValues: AdminUserModalValues; + onClose: () => void; + onSubmit: (value: AdminUserModalValues) => Promise; + showActiveField?: boolean; + submitErrorMessage: (error: unknown) => string; + submitLabel: string; + submittingLabel: string; + title: string; +}) { + const [submitError, setSubmitError] = useState(null); + const form = useAppForm({ + defaultValues: initialValues, + onSubmit: async ({ value }) => { + setSubmitError(null); + + try { + await onSubmit({ + active: value.active, + email: value.email.trim(), + employeeId: value.employeeId.trim(), + iamId: value.iamId.trim(), + name: value.name.trim(), + }); + } catch (error) { + setSubmitError(submitErrorMessage(error)); + } + }, + validators: { + onChange: editableUserFormSchema, + }, + }); + + return ( + +
{ + event.preventDefault(); + void form.handleSubmit(); + }} + > + +
+ + {(field) => } + + + {(field) => } + + + {(field) => } + + + {(field) => } + +
+ + {showActiveField ? ( +
+ + {(field) => ( + + )} + +
+ ) : null} + + {submitError ? ( +
+ {submitError} +
+ ) : null} + +
+ + +
+
+
+
+ ); +} diff --git a/client/src/shared/admin/DepartmentRow.tsx b/client/src/shared/admin/DepartmentRow.tsx new file mode 100644 index 0000000..daee595 --- /dev/null +++ b/client/src/shared/admin/DepartmentRow.tsx @@ -0,0 +1,72 @@ +import type { AdminDepartment } from '@/shared/admin/adminData.tsx'; +import { InlineTextEditor } from './InlineTextEditor.tsx'; + +export function DepartmentRow({ + department, + linkedUserCount, + onOpenRoster, + onOpenSettings, + onRename, +}: { + department: AdminDepartment; + linkedUserCount: number; + onOpenRoster: () => void; + onOpenSettings: () => void; + onRename: (name: string) => Promise; +}) { + const approvalLabel = + department.approvalMode === 'approval' + ? 'Approval required' + : department.approvalMode === 'auto' + ? 'Auto-approve' + : 'Notification only'; + + return ( +
+
+
+
+ + +
+
+ {department.code} +
+
+ {linkedUserCount} active users · {approvalLabel} + {department.routingEmails.length > 0 + ? ` · ${department.routingEmails.length} routing emails` + : ' · No email configured'} +
+
+ +
+
+ Database-backed settings +
+ +
+
+
+ ); +} diff --git a/client/src/shared/admin/DepartmentSettingsModal.tsx b/client/src/shared/admin/DepartmentSettingsModal.tsx new file mode 100644 index 0000000..03e9db8 --- /dev/null +++ b/client/src/shared/admin/DepartmentSettingsModal.tsx @@ -0,0 +1,257 @@ +import { useState } from 'react'; +import { z } from 'zod'; +import type { + AdminCluster, + AdminDepartment, +} from '@/shared/admin/adminData.tsx'; +import { useAppForm } from '@/shared/forms/formContext.tsx'; +import { AdminModalFrame } from './AdminModalFrame.tsx'; + +const departmentSettingsSchema = z.object({ + approvalMode: z.enum(['notification', 'approval', 'auto']), + clusterId: z.string(), +}); + +const routingEmailSchema = z.object({ + address: z.email('Enter a valid email address.'), + kind: z.enum(['to', 'cc']), +}); + +type RoutingEmailFormValues = z.infer; + +const defaultRoutingEmailValues: RoutingEmailFormValues = { + address: '', + kind: 'to', +}; + +export function DepartmentSettingsModal({ + clusters, + department, + formatError, + onClose, + onRemoveRoutingEmail, + onSave, + onUpsertRoutingEmail, +}: { + clusters: AdminCluster[]; + department: AdminDepartment; + formatError: (error: unknown) => string; + onClose: () => void; + onRemoveRoutingEmail: (emailId: string) => Promise; + onSave: ( + updates: Partial> + ) => Promise; + onUpsertRoutingEmail: (email: { + address: string; + id?: string; + kind: 'to' | 'cc'; + }) => Promise; +}) { + const [saveError, setSaveError] = useState(null); + const [routingEmailError, setRoutingEmailError] = useState( + null + ); + const [pendingRemovalEmailId, setPendingRemovalEmailId] = useState< + string | null + >(null); + + const settingsForm = useAppForm({ + defaultValues: { + approvalMode: department.approvalMode, + clusterId: department.clusterId ?? '', + }, + onSubmit: async ({ value }) => { + setSaveError(null); + + try { + await onSave({ + approvalMode: value.approvalMode, + clusterId: value.clusterId || null, + }); + } catch (error) { + setSaveError(formatError(error)); + } + }, + validators: { + onChange: departmentSettingsSchema, + }, + }); + + const routingEmailForm = useAppForm({ + defaultValues: defaultRoutingEmailValues, + onSubmit: async ({ value }) => { + setRoutingEmailError(null); + + try { + await onUpsertRoutingEmail({ + address: value.address.trim(), + kind: value.kind, + }); + routingEmailForm.reset(); + } catch (error) { + setRoutingEmailError(formatError(error)); + } + }, + validators: { + onChange: routingEmailSchema, + }, + }); + + const handleRemoveEmail = async (emailId: string) => { + setPendingRemovalEmailId(emailId); + setRoutingEmailError(null); + + try { + await onRemoveRoutingEmail(emailId); + } catch (error) { + setRoutingEmailError(formatError(error)); + } finally { + setPendingRemovalEmailId(null); + } + }; + + return ( + +
{ + event.preventDefault(); + void settingsForm.handleSubmit(); + }} + > + +
+ + {(field) => ( + ({ + label: cluster.name, + value: cluster.id, + }))} + placeholder="No cluster" + /> + )} + + + + {(field) => ( + + )} + +
+ +
+

+ Routing emails +

+

+ Routing emails are now stored in `DepartmentEmailRouting`. +

+ +
+ {department.routingEmails.map((email) => ( +
+ + EMAIL + + + {email.address} + + +
+ ))} +
+ + {routingEmailError ? ( +
+ {routingEmailError} +
+ ) : null} + + +
+
+ + {(field) => ( + + )} + +
+ { + void routingEmailForm.handleSubmit(); + }} + type="button" + /> +
+
+
+ + {saveError ? ( +
+ {saveError} +
+ ) : null} + +
+ + +
+
+
+
+ ); +} diff --git a/client/src/shared/admin/InlineTextEditor.tsx b/client/src/shared/admin/InlineTextEditor.tsx new file mode 100644 index 0000000..2984e34 --- /dev/null +++ b/client/src/shared/admin/InlineTextEditor.tsx @@ -0,0 +1,86 @@ +import { z } from 'zod'; +import { useAppForm } from '@/shared/forms/formContext.tsx'; + +type InlineTextEditorValues = { + value: string; +}; + +export function InlineTextEditor({ + initialValue, + inputClassName, + onSave, + requiredMessage, + savingMessage, + wrapperClassName, +}: { + initialValue: string; + inputClassName: string; + onSave: (value: string) => Promise; + requiredMessage: string; + savingMessage: string; + wrapperClassName?: string; +}) { + const schema = z.object({ + value: z.string().trim().min(1, requiredMessage), + }); + + const form = useAppForm({ + defaultValues: { + value: initialValue, + } satisfies InlineTextEditorValues, + onSubmit: async ({ value }) => { + const nextValue = value.value.trim(); + + if (nextValue === initialValue) { + return; + } + + await onSave(nextValue); + }, + validators: { + onChange: schema, + }, + }); + + return ( + + + {(field) => { + const hasError = + field.state.meta.isTouched && !field.state.meta.isValid; + + return ( +
+ { + field.handleBlur(); + + if (event.target.value.trim()) { + void form.handleSubmit(); + } + }} + onChange={(event) => field.handleChange(event.target.value)} + value={field.state.value} + /> + {hasError ? ( +

+ {field.state.meta.errors + .flatMap((issue) => (issue?.message ? [issue.message] : [])) + .join(', ')} +

+ ) : field.form.state.isSubmitting ? ( +

+ {savingMessage} +

+ ) : null} +
+ ); + }} +
+
+ ); +} diff --git a/client/src/shared/admin/adminData.tsx b/client/src/shared/admin/adminData.tsx index ebced5b..107b5cc 100644 --- a/client/src/shared/admin/adminData.tsx +++ b/client/src/shared/admin/adminData.tsx @@ -28,6 +28,19 @@ export type AdminUser = { role: AdminRole; }; +export type AdminUserEditableFields = Pick< + AdminUser, + 'email' | 'employeeId' | 'iamId' | 'name' +>; + +export type CreateUserInput = AdminUserEditableFields & { + active?: boolean; +}; + +export type UpdateUserInput = Partial & { + active?: boolean; +}; + export type DepartmentRoutingEmail = { address: string; id: string; @@ -59,18 +72,6 @@ export type AdminDataSource = { updatedAt: string | null; }; -type CreateUserInput = { - active?: boolean; - email: string; - employeeId: string; - iamId: string; - name: string; -}; - -type UpdateUserInput = Partial< - Pick ->; - type UpdateDepartmentInput = Partial< Pick >; diff --git a/client/src/shared/forms/checkboxField.tsx b/client/src/shared/forms/checkboxField.tsx new file mode 100644 index 0000000..f3af2b7 --- /dev/null +++ b/client/src/shared/forms/checkboxField.tsx @@ -0,0 +1,30 @@ +import { useFieldContext } from './formContext.tsx'; + +interface CheckboxFieldProps { + description?: string; + label: string; +} + +export function CheckboxField({ description, label }: CheckboxFieldProps) { + const field = useFieldContext(); + + return ( + + ); +} diff --git a/client/src/shared/forms/fieldWrapper.tsx b/client/src/shared/forms/fieldWrapper.tsx index 0ed437b..8cd4e5d 100644 --- a/client/src/shared/forms/fieldWrapper.tsx +++ b/client/src/shared/forms/fieldWrapper.tsx @@ -3,18 +3,25 @@ import { useFieldContext } from './formContext.tsx'; interface FieldWrapperProps { children: ReactNode; + helperText?: string; label: string; + wrapperClassName?: string; } /** * Common wrapper component for form fields that handles label, error display, and validation state */ -export function FieldWrapper({ children, label }: FieldWrapperProps) { +export function FieldWrapper({ + children, + helperText, + label, + wrapperClassName, +}: FieldWrapperProps) { const field = useFieldContext(); const hasError = field.state.meta.isTouched && !field.state.meta.isValid; return ( -
+
@@ -26,6 +33,13 @@ export function FieldWrapper({ children, label }: FieldWrapperProps) { )} + {!hasError && helperText ? ( + + ) : null} {field.state.meta.isValidating && ( )} diff --git a/client/src/shared/forms/formContext.tsx b/client/src/shared/forms/formContext.tsx index dac0486..6e769bd 100644 --- a/client/src/shared/forms/formContext.tsx +++ b/client/src/shared/forms/formContext.tsx @@ -1,4 +1,5 @@ import { createFormHookContexts, createFormHook } from '@tanstack/react-form'; +import { CheckboxField } from './checkboxField.tsx'; import { TextField } from './textField.tsx'; import { SubscribeButton } from './submitButton.tsx'; import { SelectField } from './selectField.tsx'; @@ -10,6 +11,7 @@ export const { fieldContext, formContext, useFieldContext, useFormContext } = const { useAppForm } = createFormHook({ fieldComponents: { // text, select, checkbox, etc. + CheckboxField, SelectField, TextField, }, diff --git a/client/src/shared/forms/selectField.tsx b/client/src/shared/forms/selectField.tsx index a31677f..c601eb3 100644 --- a/client/src/shared/forms/selectField.tsx +++ b/client/src/shared/forms/selectField.tsx @@ -2,21 +2,30 @@ import { useFieldContext } from './formContext.tsx'; import { FieldWrapper } from './fieldWrapper.tsx'; interface SelectFieldProps { + helperText?: string; label: string; options: Array<{ label: string; value: string }>; placeholder?: string; + selectClassName?: string; } -export function SelectField({ label, options, placeholder }: SelectFieldProps) { +export function SelectField({ + helperText, + label, + options, + placeholder, + selectClassName, +}: SelectFieldProps) { const field = useFieldContext(); const hasError = field.state.meta.isTouched && !field.state.meta.isValid; return ( - + field.handleChange(e.target.value)} placeholder={placeholder ?? `Enter ${label.toLowerCase()}`} - type="text" + type={type ?? 'text'} value={field.state.value} />