Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
374 changes: 4 additions & 370 deletions client/src/routes/(authenticated)/admin.departments.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -211,6 +212,7 @@ function AdminDepartmentsRoute() {
<DepartmentSettingsModal
clusters={clusters}
department={editingDepartment}
formatError={getAdminMutationErrorMessage}
onClose={() => setEditingDepartmentId(null)}
onRemoveRoutingEmail={(emailId) =>
removeRoutingEmail(editingDepartment.id, emailId)
Expand All @@ -229,374 +231,6 @@ function AdminDepartmentsRoute() {
);
}

function NameEditor({
inputClassName,
name,
onSave,
savingMessage,
wrapperClassName,
}: {
inputClassName: string;
name: string;
onSave: (name: string) => Promise<void>;
savingMessage: string;
wrapperClassName?: string;
}) {
const [value, setValue] = useState(name);
const [error, setError] = useState<string | null>(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 (
<div className={wrapperClassName}>
<input
className={`${inputClassName} ${error ? 'border-rose-400 focus:border-rose-500' : ''}`}
disabled={isSaving}
onBlur={() => {
void handleBlur();
}}
onChange={(event) => setValue(event.target.value)}
value={value}
/>
{error ? (
<p className="mt-2 text-sm text-rose-700">{error}</p>
) : isSaving ? (
<p className="mt-2 text-sm text-[var(--admin-ink-muted)]">
{savingMessage}
</p>
) : null}
</div>
);
}

function DepartmentRow({
department,
linkedUserCount,
onOpenRoster,
onOpenSettings,
onRename,
}: {
department: AdminDepartment;
linkedUserCount: number;
onOpenRoster: () => void;
onOpenSettings: () => void;
onRename: (name: string) => Promise<void>;
}) {
const approvalLabel =
department.approvalMode === 'approval'
? 'Approval required'
: department.approvalMode === 'auto'
? 'Auto-approve'
: 'Notification only';

return (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white px-5 py-4 shadow-sm transition hover:shadow-md">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<NameEditor
inputClassName="input input-ghost h-auto min-h-0 w-full max-w-md justify-start px-0 text-lg font-bold uppercase tracking-wide text-[var(--admin-blue)] shadow-none focus:bg-transparent"
name={department.name}
onSave={onRename}
savingMessage="Saving department name..."
wrapperClassName="w-full max-w-md"
/>
<button
className="text-sm font-medium text-[var(--admin-blue)] underline decoration-[var(--admin-gold)] decoration-2 underline-offset-4"
onClick={onOpenRoster}
type="button"
>
View linked users
</button>
</div>
<div className="mt-1 font-mono text-sm text-[var(--admin-ink-muted)]">
{department.code}
</div>
<div className="mt-2 text-sm text-[var(--admin-ink-muted)]">
{linkedUserCount} active users · {approvalLabel}
{department.routingEmails.length > 0
? ` · ${department.routingEmails.length} routing emails`
: ' · No email configured'}
</div>
</div>

<div className="flex flex-col items-start gap-3 lg:w-72 lg:items-end lg:text-right">
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--admin-ink-soft)]">
Database-backed settings
</div>
<button
className="btn btn-outline w-full max-w-xs lg:max-w-none"
onClick={onOpenSettings}
type="button"
>
Settings
</button>
</div>
</div>
</div>
);
}

function DepartmentSettingsModal({
clusters,
department,
onClose,
onRemoveRoutingEmail,
onSave,
onUpsertRoutingEmail,
}: {
clusters: Array<{ id: string; name: string }>;
department: AdminDepartment;
onClose: () => void;
onRemoveRoutingEmail: (emailId: string) => Promise<void>;
onSave: (
updates: Partial<Pick<AdminDepartment, 'approvalMode' | 'clusterId'>>
) => Promise<void>;
onUpsertRoutingEmail: (email: {
address: string;
id?: string;
kind: 'to' | 'cc';
}) => Promise<void>;
}) {
const [approvalMode, setApprovalMode] = useState(department.approvalMode);
const [clusterId, setClusterId] = useState(department.clusterId ?? '');
const [newEmail, setNewEmail] = useState('');
const [saveError, setSaveError] = useState<string | null>(null);
const [routingEmailError, setRoutingEmailError] = useState<string | null>(
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/40 px-4 py-8">
<div className="max-h-[90vh] w-full max-w-3xl overflow-y-auto rounded-[1.5rem] border border-[var(--admin-border)] bg-white p-6 shadow-2xl">
<h2 className="text-xl font-semibold text-[var(--admin-blue)]">
Department settings
</h2>
<p className="mt-2 text-sm text-[var(--admin-ink-muted)]">
These controls persist to the database for cluster placement, workflow
mode, and routing emails.
</p>

<div className="mt-6 grid gap-4 sm:grid-cols-2">
<label className="form-control">
<span className="label-text mb-2 text-sm font-medium text-[var(--admin-ink)]">
Cluster
</span>
<select
className="select select-bordered"
onChange={(event) => setClusterId(event.target.value)}
value={clusterId}
>
<option value="">No cluster</option>
{clusters.map((cluster) => (
<option key={cluster.id} value={cluster.id}>
{cluster.name}
</option>
))}
</select>
</label>

<label className="form-control">
<span className="label-text mb-2 text-sm font-medium text-[var(--admin-ink)]">
Approval mode
</span>
<select
className="select select-bordered"
onChange={(event) =>
setApprovalMode(
event.target.value as 'approval' | 'notification'
)
}
value={approvalMode}
>
<option value="notification">Notification only</option>
<option value="approval">Approval required</option>
</select>
</label>
</div>

<div className="mt-6 rounded-2xl bg-[var(--admin-sand)] p-5">
<h3 className="text-sm font-semibold uppercase tracking-[0.2em] text-[var(--admin-gold-deep)]">
Routing emails
</h3>
<p className="mt-2 text-sm text-[var(--admin-ink-muted)]">
Routing emails are now stored in `DepartmentEmailRouting`.
</p>

<div className="mt-4 space-y-3">
{department.routingEmails.map((email) => (
<div
className="flex flex-col gap-3 rounded-xl border border-[var(--admin-border)] bg-white px-4 py-3 sm:flex-row sm:items-center"
key={email.id}
>
<span className="badge border-0 bg-[var(--admin-sand)] text-[var(--admin-blue)]">
EMAIL
</span>
<span className="flex-1 text-sm text-[var(--admin-ink)]">
{email.address}
</span>
<button
className="btn btn-ghost btn-sm text-rose-700"
disabled={pendingRemovalEmailId === email.id}
onClick={() => {
void handleRemoveEmail(email.id);
}}
type="button"
>
{pendingRemovalEmailId === email.id ? (
<>
<span className="loading loading-spinner loading-xs mr-2"></span>
Removing...
</>
) : (
'Remove'
)}
</button>
</div>
))}
</div>

{routingEmailError ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{routingEmailError}
</div>
) : null}

<div className="mt-4 flex flex-col gap-3 sm:flex-row">
<input
className="input input-bordered flex-1 bg-white"
onChange={(event) => setNewEmail(event.target.value)}
placeholder="email@ucdavis.edu"
type="email"
value={newEmail}
/>
<button
className="btn btn-outline"
disabled={isAddingEmail || !newEmail.trim()}
onClick={() => {
void handleAddEmail();
}}
type="button"
>
{isAddingEmail ? 'Adding...' : 'Add email'}
</button>
</div>
</div>

{saveError ? (
<div className="mt-6 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{saveError}
</div>
) : null}

<div className="mt-6 flex justify-end gap-3">
<button className="btn btn-ghost" onClick={onClose} type="button">
Cancel
</button>
<button
className="btn border-0 bg-[var(--admin-gold)] text-[var(--admin-blue)] hover:bg-[var(--admin-gold)]/85"
disabled={isSaving}
onClick={() => {
void handleSave();
}}
type="button"
>
{isSaving ? 'Saving...' : 'Save changes'}
</button>
</div>
</div>
</div>
);
}

function getAdminMutationErrorMessage(error: unknown) {
if (error instanceof HttpError) {
if (typeof error.body === 'string' && error.body.trim()) {
Expand Down
Loading
Loading