import { useCallback, useEffect, useMemo, useState } from "react" import { CheckCircle2, Megaphone, Plus, RefreshCw, Trash2, } from "lucide-react" import { useArcadiaClient } from "@crema/arcadia-core-client" import { useToast } from "@crema/notification-ui" import { ActionsCell, BadgeCell, DataTable, DateCell, Pagination, useTable, type ActionItem, type BadgeTone, type Column, } from "@crema/table-ui" import { SearchInput } from "@crema/search-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 { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "~/components/ui/card" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog" import { Input } from "~/components/ui/input" import { Label } from "~/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "~/components/ui/select" import { Switch } from "~/components/ui/switch" import { Textarea } from "~/components/ui/textarea" import { createAnnouncement, deleteAnnouncement, listAnnouncements, updateAnnouncement, type Announcement, type AnnouncementInput, type AnnouncementType, } from "~/lib/arcadia/announcements" import { listTenants, type Tenant } from "~/lib/arcadia/tenants" import { pageTitle } from "~/lib/page-meta" import { useSession } from "~/lib/session" import { useRegisterContext } from "@crema/aifirst-ui/context" export const meta = () => pageTitle("Announcements") const TYPES: AnnouncementType[] = ["info", "warning", "maintenance", "incident", "feature"] const KIND_OPTIONS: { value: AnnouncementType; hint: string }[] = [ { value: "info", hint: "Neutral update" }, { value: "warning", hint: "Degraded service or heads-up" }, { value: "maintenance", hint: "Scheduled work" }, { value: "incident", hint: "Active outage" }, { value: "feature", hint: "Something new shipped" }, ] function typeToAlertVariant( t: AnnouncementType, ): "info" | "success" | "warning" | "error" | "neutral" { if (t === "incident") return "error" if (t === "warning" || t === "maintenance") return "warning" if (t === "feature") return "success" return "info" } function publishButtonLabel(opts: { isEdit: boolean active: boolean audience: "platform" | "tenant" tenantId: string tenants: Tenant[] }): string { if (opts.isEdit) return "Save changes" if (!opts.active) return "Save draft" if (opts.audience === "tenant") { const name = opts.tenants.find((t) => t.id === opts.tenantId)?.name return name ? `Publish to ${name}` : "Publish to tenant" } return "Publish to all users" } type Editor = | { kind: "create" } | { kind: "edit"; announcement: Announcement } | null export default function AnnouncementsRoute() { const session = useSession() const arcadia = useArcadiaClient() const toast = useToast() const [items, setItems] = useState([]) const [tenants, setTenants] = useState([]) const [loading, setLoading] = useState(true) // Raw thrown value — `DataState` normalises it. Successes are toasts now. const [error, setError] = useState(null) const [search, setSearch] = useState("") const [editor, setEditor] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) const [refreshedAt, setRefreshedAt] = useState(null) const [now, setNow] = useState(() => Date.now()) const refresh = useCallback(async () => { setError(null) setLoading(true) 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) } finally { setLoading(false) } }, [arcadia]) useEffect(() => { if (refreshedAt == null) return const id = window.setInterval(() => setNow(Date.now()), 30_000) return () => window.clearInterval(id) }, [refreshedAt]) const lastRefreshedLabel = useMemo(() => { if (refreshedAt == null) return null const seconds = Math.max(1, Math.round((now - refreshedAt) / 1000)) if (seconds < 60) return `${seconds}s ago` const minutes = Math.round(seconds / 60) if (minutes < 60) return `${minutes}m ago` return `${Math.round(minutes / 60)}h ago` }, [refreshedAt, now]) useEffect(() => { if (session) refresh() }, [session, refresh]) const columns = useMemo[]>( () => [ { id: "title", header: "Title", accessor: "title", sortable: true, cell: (a) => (
{a.title} {a.body ? ( {a.body} ) : null}
), }, { id: "type", header: "Type", accessor: "announcement_type", sortable: true, cell: (a) => , }, { id: "scope", header: "Audience", cell: (a) => { if (!a.tenant_id) return All apps const t = tenants.find((x) => x.id === a.tenant_id) return {t?.slug ?? "Single tenant"} }, }, { id: "active", header: "Active", accessor: "active", sortable: true, cell: (a) => ( ), }, { id: "window", header: "Window", cell: (a) => ( {a.starts_at ? new Date(a.starts_at).toLocaleDateString() : "—"} {" → "} {a.ends_at ? new Date(a.ends_at).toLocaleDateString() : "∞"} ), }, { id: "updated", header: "Updated", accessor: "updated_at", sortable: true, cell: (a) => , }, { id: "actions", header: "", align: "right", cell: (a) => { const items: ActionItem[] = [ { id: "edit", label: "Edit", dataAction: `announcement-${a.id}-edit`, onSelect: () => setEditor({ kind: "edit", announcement: a }), }, { id: "toggle", label: a.active ? "Deactivate" : "Activate", dataAction: `announcement-${a.id}-toggle`, onSelect: async () => { try { await updateAnnouncement(arcadia, a.id, { active: !a.active }) await refresh() toast.success( a.active ? `Deactivated "${a.title}"` : `Activated "${a.title}"`, ) } catch (err) { toast.error( errorMessage( err, `${a.active ? "deactivate" : "activate"} "${a.title}"`, ), ) } }, }, { id: "delete", label: "Delete", icon: , destructive: true, dataAction: `announcement-${a.id}-delete`, onSelect: () => setPendingDelete(a), }, ] return }, }, ], [arcadia, refresh, tenants, toast], ) const summary = useMemo( () => ({ total: items.length, active: items.filter((a) => a.active).length, byType: countBy(items, (a) => a.announcement_type), }), [items], ) useRegisterContext("announcements", summary) const table = useTable({ data: items, columns, getRowId: (a) => a.id, initialPageSize: 25, initialSearch: search, }) useEffect(() => { table.setSearch(search) }, [search, table]) return (

Announcements

Banners that appear at the top of every Sky AI app. Use them for maintenance windows, incidents, or new features.

{lastRefreshedLabel ? ( Updated {lastRefreshedLabel} ) : null} {items.length > 0 ? ( ) : null}
{items.length > 0 ? (
{search && table.total !== items.length ? `${table.total} of ${items.length}` : `${items.length} ${items.length === 1 ? "announcement" : "announcements"}`}
) : null}
} 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 ? ( ) : ( ) } /> } > a.id} sort={table.sort} onSortToggle={table.toggleSort} loading={loading && items.length > 0} stickyHeader /> !o && setPendingDelete(null)} title="Delete announcement?" description={pendingDelete ? `${pendingDelete.title} will be removed for all users.` : ""} confirmLabel="Delete" variant="danger" onConfirm={async () => { if (!pendingDelete) return const title = pendingDelete.title try { await deleteAnnouncement(arcadia, pendingDelete.id) setPendingDelete(null) await refresh() toast.success(`Deleted "${title}"`) } catch (err) { setPendingDelete(null) toast.error(errorMessage(err, `delete "${title}"`)) } }} /> setEditor(null)} onSaved={async (msg) => { setEditor(null) await refresh() toast.success(msg) }} />
) } function typeTone(t: AnnouncementType): BadgeTone { if (t === "incident") return "danger" if (t === "warning" || t === "maintenance") return "warning" if (t === "feature") return "success" return "default" } function countBy(arr: T[], key: (x: T) => string): Record { return arr.reduce>((acc, x) => { const k = key(x) acc[k] = (acc[k] ?? 0) + 1 return acc }, {}) } function AnnouncementEditorDialog({ state, tenants, onClose, onSaved, }: { state: Editor tenants: Tenant[] onClose: () => void onSaved: (msg: string) => Promise }) { const arcadia = useArcadiaClient() const open = state !== null const isEdit = state?.kind === "edit" const initial = isEdit ? state.announcement : null const [title, setTitle] = useState("") const [body, setBody] = useState("") const [type, setType] = useState("info") const [audience, setAudience] = useState<"platform" | "tenant">("platform") const [tenantId, setTenantId] = useState("") const [actionLabel, setActionLabel] = useState("") const [actionUrl, setActionUrl] = useState("") const [startsAt, setStartsAt] = useState("") const [endsAt, setEndsAt] = useState("") const [dismissible, setDismissible] = useState(true) const [active, setActive] = useState(true) const [saving, setSaving] = useState(false) // 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(null) useEffect(() => { if (!open) setLocalError(null) }, [open]) useEffect(() => { if (!open) return if (initial) { setTitle(initial.title) setBody(initial.body ?? "") setType(initial.announcement_type) setAudience(initial.tenant_id ? "tenant" : "platform") setTenantId(initial.tenant_id ?? "") setActionLabel(initial.action_label ?? "") setActionUrl(initial.action_url ?? "") setStartsAt(initial.starts_at ? initial.starts_at.slice(0, 16) : "") setEndsAt(initial.ends_at ? initial.ends_at.slice(0, 16) : "") setDismissible(initial.dismissible) setActive(initial.active) } else { setTitle("") setBody("") setType("info") setAudience("platform") setTenantId("") setActionLabel("") setActionUrl("") setStartsAt("") setEndsAt("") setDismissible(true) setActive(true) } }, [open, initial]) const submit = async () => { setLocalError(null) setSaving(true) try { const input: AnnouncementInput = { title, body: body || undefined, announcement_type: type, audience, action_label: actionLabel || null, action_url: actionUrl || null, starts_at: startsAt ? new Date(startsAt).toISOString() : null, ends_at: endsAt ? new Date(endsAt).toISOString() : null, dismissible, active, tenant_id: audience === "tenant" ? tenantId || null : null, } if (isEdit && initial) { await updateAnnouncement(arcadia, initial.id, input) await onSaved(`Updated "${title}"`) } else { await createAnnouncement(arcadia, input) await onSaved( active ? `Published "${title}"` : `Saved draft "${title}"`, ) } } catch (err) { // 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) } } return ( !o && onClose()}> {isEdit ? "Edit announcement" : "New announcement"} A banner shows at the top of every Sky AI app. It's visible when it's switched on and today falls inside its date range. {/* Live preview — what users will see. Updates as the form is edited so the operator never has to imagine the output or publish blind. */}
{}} action={ actionLabel && actionUrl ? ( ) : undefined } > {body || ( Body text appears here. )}

{audience === "tenant" ? `Visible to users of ${ tenants.find((t) => t.id === tenantId)?.name ?? "the selected tenant" } only.` : "Visible to everyone across every Sky AI app."}

setTitle(e.target.value)} data-action="announcement-form-title" placeholder="Scheduled maintenance Sunday 2am AEST" />