"use client" /** * Which app the Administrator card opens its console on. * * The other cards on `/home` carry a school and program picker, because a * product is entered inside a scope. Administrator is workspace-wide, so the * question its card has to answer is a different one: an admin arriving here * is nearly always on their way to fix one app's records, and the console that * opens showing every object of every app makes them filter once they land. * This picker moves that choice to the door. * * It is deliberately **not** a second scope picker. There is no program * narrowing behind it, the menu lists apps this workspace owns, and the answer * travels as `?app=` on the console URL rather than into shared scope state: * pointing the console at Clinical Education must not change which program * Clinical Education itself opens in. * * Same three trigger shapes as `ProductScopePicker`, so on a page where both * appear the Administrator card and the app cards beside it read as one row of * controls rather than two unrelated ones. */ import * as React from "react" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { usePersistedState } from "@exxatdesignux/ui/lib/persisted-state" import { NEUTRAL_TILE_CLASS } from "@/lib/product-glyph" import { adminConsoleHref, type AdminAppTarget, type ProductHomeCard, } from "@/lib/product-home" import { cn } from "@/lib/utils" import { ProductMarkTile } from "./product-mark-tile" /** * Shell-global, like the home's layout preference and unlike a hub's filters: * `/home` has no product selected, so there is no product to namespace under, * and the choice is about the console rather than about any one app. */ const ADMIN_APP_KEY = "home-admin-app" /** Stored when the console is pointed at everything, so "unset" has a name. */ const ALL_APPS = "all" /** What the card shows, and where its Open button goes. */ export interface AdminAppScope { apps: AdminAppTarget[] /** The chosen app, or null while the console covers all of them. */ selected: AdminAppTarget | null select: (slug: string | null) => void /** The console URL for the current choice. */ href: string } const ALL_APPS_LABEL = "All apps" /** * The card's app choice, or null when there is no choice worth offering. * * Null for every card that is not Administrator, and for an Administrator card * in a workspace with one app: a menu whose only branch is the one thing you * already have is a control that cannot do anything. * * A stored app that is no longer in the list resolves to all apps rather than * being rewritten, so an app hidden for a day and restored comes back to the * choice the admin made instead of one this hook invented while it was gone. */ export function useAdminAppScope(card: ProductHomeCard): AdminAppScope | null { const apps = card.adminApps // Written on click, so no debounce: the write has to reach the other hooks // on this key in the same paint, and `setStorageItem` is what notifies them. const [slug, setSlug] = usePersistedState(ADMIN_APP_KEY, ALL_APPS, { debounceMs: 0 }) const select = React.useCallback( (next: string | null) => setSlug(next ?? ALL_APPS), [setSlug], ) if (!apps || apps.length < 2) return null const selected = apps.find(app => app.slug === slug) ?? null return { apps, selected, select, href: adminConsoleHref(selected?.slug) } } /** The mark, or the neutral circle that stands in for every app at once. */ function AppFace({ app }: { app: AdminAppTarget | null }) { if (!app) { return ( ) } return } /** * One flat list, All apps first. * * The scope menu beside this one is two levels — a school heading over the * programs inside it — because a program only means anything under its school. * Apps have no such parent, and All apps is not one: it is the same kind of * answer as Clinical Education, just a wider one. Giving it a separator and an * "App" heading underneath said the opposite, that everything below was filed * beneath it. */ function AdminAppMenu({ scope, onPick, }: { scope: AdminAppScope /** When set, runs after the choice is stored — e.g. open the console immediately. */ onPick?: (slug: string | null) => void }) { const options: Array<{ key: string; app: AdminAppTarget | null; label: string }> = [ { key: ALL_APPS, app: null, label: ALL_APPS_LABEL }, ...scope.apps.map(app => ({ key: app.slug, app, label: app.label })), ] return ( <> {options.map(option => ( { const slug = option.app?.slug ?? null scope.select(slug) onPick?.(slug) }} > {/* Full `md` tile, the footprint every switcher row uses. Shrinking it here would leave the art inside sized for a tile it no longer has (see `PRODUCT_TILE_SIZES`). */} {option.label} {(scope.selected?.slug ?? null) === (option.app?.slug ?? null) ? (