"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) ? (
) : null}
))}
>
)
}
export function AdminAppPicker({
scope,
variant = "card",
className,
}: {
scope: AdminAppScope
variant?: "card" | "inline" | "compact"
className?: string
}) {
const inline = variant === "inline"
const compact = variant === "compact"
const label = scope.selected?.label ?? ALL_APPS_LABEL
return (
{/* Opens below the trigger rather than covering it the way the scope
picker does. A covering panel lands its first row under the cursor
before a normal-length press ends, and Radix menu items take a
`pointerup` they did not see the `pointerdown` for — so the menu
picked All apps and shut on the way up. */}
)
}
/**
* Campus grid cell: the whole card opens the app menu, then lands on the choice.
*
* No second control beside the name — one door, one question.
*/
export function AdminAppCardDoor({
scope,
card,
onOpen,
className,
children,
}: {
scope: AdminAppScope
card: ProductHomeCard
onOpen: (card: ProductHomeCard) => void
className?: string
children: React.ReactNode
}) {
return (
onOpen({ ...card, href: adminConsoleHref(slug) })}
/>
)
}