Admin UX overhaul: P0 fixes, error-UX foundations, nav cleanup, tenant detail page

From the 2026-07-14 UI/UX audit (17/40). Four phases:

P1 — Settings route crashed on Agents→Edit (Input/Textarea used but never
imported). Added imports + a shared route-level error boundary
(components/route-error.tsx) re-exported from all shell routes, so one
crashing panel degrades to an explained card with the nav intact instead of
replacing the whole app with a stack trace. Corrected the stale CLAUDE.md
claim that `npm run typecheck` crashes — it works, and would have caught the
missing import.

P2 — Error/empty/feedback foundations. Fixed useSession identity churn that
fired ~3x duplicate fetches per screen and self-inflicted 429s (referentially
stable snapshot). New lib/errors.ts (describeError → plain-language + the fix)
and components/data-state.tsx (DataState renders exactly one of
error/loading/empty/content, so a failed load never shows as "empty";
DialogError for in-dialog failures; 429 auto-retry). Rolled across all 15
list routes; every mutation now toasts. Surfaced+fixed two silent-failure
bugs (sso + buckets-CORS swallowed load errors; the latter could wipe rules
on save).

P3 — Nav IA + trust cleanup. Default-expanded rail; regrouped into 7 coherent
sections; collapsed the Apps/Plan/Entitlements stub triplication into one
honest /billing page; deleted fabricated seeded notifications and the dead
Help menu item; fixed the 403 copy (referenced a tenant switcher that doesn't
exist); removed orphan /assistant + /library routes; gated dev-seed login
hints behind DEV; renamed /activity → /audit-log with a redirect.

P4 — Tenant detail page (routes/tenants.$id.tsx + components/tenant-detail/*),
closing the provision→configure gap. 8 tabs (Overview, Plan & quotas,
Branding, Localization, Email & SMS, Feature flags, IP rules, Inbound
webhooks), each verified saving to the real backend. Row name links to detail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jules
2026-07-14 13:43:57 +10:00
parent 938143f3f5
commit 7415b40240
51 changed files with 5923 additions and 4575 deletions

View File

@@ -7,7 +7,8 @@ import {
Trash2,
} from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { useArcadiaClient } from "@crema/arcadia-core-client"
import { useToast } from "@crema/notification-ui"
import {
ActionsCell,
BadgeCell,
@@ -20,9 +21,14 @@ import {
type Column,
} from "@crema/table-ui"
import { SearchInput } from "@crema/search-ui"
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
// AlertBanner is imported for the *preview* below — it is the very component
// the published announcement renders as in every Sky AI app. It is no longer
// used to report errors or successes; those are DataState / DialogError / toasts.
import { AlertBanner, ConfirmDialog, EmptyState } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell"
import { DataState, DialogError } from "~/components/data-state"
import { errorMessage } from "~/lib/errors"
import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button"
import {
@@ -110,12 +116,13 @@ type Editor =
export default function AnnouncementsRoute() {
const session = useSession()
const arcadia = useArcadiaClient()
const toast = useToast()
const [items, setItems] = useState<Announcement[]>([])
const [tenants, setTenants] = useState<Tenant[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
// Raw thrown value — `DataState` normalises it. Successes are toasts now.
const [error, setError] = useState<unknown>(null)
const [search, setSearch] = useState("")
const [editor, setEditor] = useState<Editor>(null)
const [pendingDelete, setPendingDelete] = useState<Announcement | null>(null)
@@ -128,13 +135,16 @@ export default function AnnouncementsRoute() {
try {
const [a, t] = await Promise.all([
listAnnouncements(arcadia),
// Tenants only label the audience column and fill the scope picker.
// Losing them degrades those two spots; it doesn't make the
// announcements list wrong, so it must not fail the whole screen.
listTenants(arcadia).catch(() => [] as Tenant[]),
])
setItems(a)
setTenants(t)
setRefreshedAt(Date.now())
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Failed to load announcements.")
setError(err)
} finally {
setLoading(false)
}
@@ -237,10 +247,17 @@ export default function AnnouncementsRoute() {
onSelect: async () => {
try {
await updateAnnouncement(arcadia, a.id, { active: !a.active })
setInfo(a.active ? "Announcement deactivated." : "Announcement activated.")
await refresh()
toast.success(
a.active ? `Deactivated "${a.title}"` : `Activated "${a.title}"`,
)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Toggle failed.")
toast.error(
errorMessage(
err,
`${a.active ? "deactivate" : "activate"} "${a.title}"`,
),
)
}
},
},
@@ -257,7 +274,7 @@ export default function AnnouncementsRoute() {
},
},
],
[arcadia, refresh, tenants],
[arcadia, refresh, tenants, toast],
)
const summary = useMemo(
@@ -329,17 +346,6 @@ export default function AnnouncementsRoute() {
</div>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Card>
<CardHeader className="flex flex-row items-center gap-3">
<SearchInput
@@ -359,74 +365,75 @@ export default function AnnouncementsRoute() {
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay
active={loading && items.length === 0}
label="Loading announcements…"
/>
{table.total === 0 && !loading ? (
<EmptyState
icon={
<div
className="grid size-14 place-items-center rounded-full"
style={{
background:
"radial-gradient(circle at center, color-mix(in oklch, var(--primary) 22%, transparent), transparent 70%)",
}}
>
<Megaphone
className="size-6"
style={{ color: "var(--primary)" }}
/>
</div>
}
title={search ? "No announcements match." : "No announcements yet."}
description={
search
? "Try a different search."
: "Post your first banner. Show it to everyone, or scope it to a single tenant."
}
action={
search ? (
<Button
size="sm"
variant="outline"
onClick={() => setSearch("")}
data-action="announcements-clear-search"
<DataState
loading={loading}
error={error}
isEmpty={table.total === 0}
onRetry={refresh}
loadingLabel="Loading announcements…"
empty={
<EmptyState
icon={
<div
className="grid size-14 place-items-center rounded-full"
style={{
background:
"radial-gradient(circle at center, color-mix(in oklch, var(--primary) 22%, transparent), transparent 70%)",
}}
>
Clear search
</Button>
) : (
<Button
size="sm"
onClick={() => setEditor({ kind: "create" })}
data-action="announcements-create-empty"
>
<Plus className="size-4" />
New announcement
</Button>
)
}
<Megaphone
className="size-6"
style={{ color: "var(--primary)" }}
/>
</div>
}
title={search ? "No announcements match." : "No announcements yet."}
description={
search
? "Try a different search."
: "Post your first banner. Show it to everyone, or scope it to a single tenant."
}
action={
search ? (
<Button
size="sm"
variant="outline"
onClick={() => setSearch("")}
data-action="announcements-clear-search"
>
Clear search
</Button>
) : (
<Button
size="sm"
onClick={() => setEditor({ kind: "create" })}
data-action="announcements-create-empty"
>
<Plus className="size-4" />
New announcement
</Button>
)
}
/>
}
>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(a) => a.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && items.length > 0}
stickyHeader
/>
) : (
<>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(a) => a.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && items.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</DataState>
</CardContent>
</Card>
</div>
@@ -440,14 +447,15 @@ export default function AnnouncementsRoute() {
variant="danger"
onConfirm={async () => {
if (!pendingDelete) return
const title = pendingDelete.title
try {
await deleteAnnouncement(arcadia, pendingDelete.id)
setPendingDelete(null)
setInfo("Announcement deleted.")
await refresh()
toast.success(`Deleted "${title}"`)
} catch (err) {
setError(err instanceof ArcadiaError ? err.message : "Delete failed.")
setPendingDelete(null)
toast.error(errorMessage(err, `delete "${title}"`))
}
}}
/>
@@ -458,10 +466,9 @@ export default function AnnouncementsRoute() {
onClose={() => setEditor(null)}
onSaved={async (msg) => {
setEditor(null)
if (msg) setInfo(msg)
await refresh()
toast.success(msg)
}}
onError={setError}
/>
</AppShell>
)
@@ -487,13 +494,11 @@ function AnnouncementEditorDialog({
tenants,
onClose,
onSaved,
onError,
}: {
state: Editor
tenants: Tenant[]
onClose: () => void
onSaved: (msg?: string) => Promise<void>
onError: (msg: string | null) => void
onSaved: (msg: string) => Promise<void>
}) {
const arcadia = useArcadiaClient()
const open = state !== null
@@ -512,7 +517,9 @@ function AnnouncementEditorDialog({
const [dismissible, setDismissible] = useState(true)
const [active, setActive] = useState(true)
const [saving, setSaving] = useState(false)
const [localError, setLocalError] = useState<string | null>(null)
// A failed publish speaks inside the dialog, right above the button that was
// pressed. Hoisting it to a page banner would put it behind the modal scrim.
const [localError, setLocalError] = useState<unknown>(null)
useEffect(() => {
if (!open) setLocalError(null)
@@ -548,7 +555,6 @@ function AnnouncementEditorDialog({
}, [open, initial])
const submit = async () => {
onError(null)
setLocalError(null)
setSaving(true)
try {
@@ -567,19 +573,17 @@ function AnnouncementEditorDialog({
}
if (isEdit && initial) {
await updateAnnouncement(arcadia, initial.id, input)
await onSaved("Announcement updated.")
await onSaved(`Updated "${title}"`)
} else {
await createAnnouncement(arcadia, input)
await onSaved("Announcement posted.")
await onSaved(
active ? `Published "${title}"` : `Saved draft "${title}"`,
)
}
} catch (err) {
const msg =
err instanceof ArcadiaError
? err.message
: err instanceof Error
? err.message
: "Save failed."
setLocalError(msg)
// Keep the dialog open with the form intact so the operator can fix and
// resubmit without retyping the whole banner.
setLocalError(err)
} finally {
setSaving(false)
}
@@ -630,16 +634,6 @@ function AnnouncementEditorDialog({
</div>
</div>
{localError ? (
<AlertBanner
variant="error"
dismissible
onDismiss={() => setLocalError(null)}
>
{localError}
</AlertBanner>
) : null}
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 flex flex-col gap-1.5">
<Label htmlFor="ann-title">Title</Label>
@@ -784,6 +778,13 @@ function AnnouncementEditorDialog({
</div>
</div>
{localError ? (
<DialogError
error={localError}
context={isEdit ? "save the announcement" : "publish the announcement"}
/>
) : null}
<DialogFooter className="flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Active = publish state, paired with the publish button. */}
<label
@@ -821,3 +822,5 @@ function AnnouncementEditorDialog({
</Dialog>
)
}
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"