Files
template-app/modules/inventory/ui/pages.tsx
T
2026-11-14 09:56:45 +00:00

201 lines
7.4 KiB
TypeScript

// The inventory module's pages, on the shell's F10 contract (docs/reference/app-kit.md): a loader the web
// shell runs on the server under `/<org>/<workspace>/<project>/inventory`, and the view of what it returned.
//
// /inventory the organization's items, a form that adds one, and this project's reservations
// /inventory/<itemId> one item's stock by location, and the form that reserves some for this project
//
// Every read is a procedure through the same caller the API uses, and every label is translated in the
// loader. A refusal that is the platform's -- the service is not enabled for the organization, the
// deployment lacks a scope, the service did not answer -- is the page's own state, not an error page, in the
// kit's words for it (`businessServiceNotices` of `@conusai-kit/business/refusals`).
import { businessRefusalNotices, businessServiceNotices } from "@conusai-kit/business/refusals"
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
import type { I18n } from "@conusai-kit/i18n"
import { type ProjectPage, type ProjectPageViewProps, projectPage } from "@conusai-kit/ui"
import { Can } from "@conusai-kit/ui/data"
import {
DetailLayout,
Page,
PageHeader,
PageSection,
type ServiceNotice,
ServiceStatePage,
serviceNotice,
} from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
import {
type AvailabilityView,
type ItemView,
itemAvailability,
itemList,
type ReservationView,
reservationList,
} from "../contract/index.js"
import { ItemForm, type ItemFormLabels } from "./item-form.js"
import { ItemList, type ItemListLabels } from "./item-list.js"
import {
itemFormLabels,
itemListLabels,
reservationListLabels,
reserveFormLabels,
stockLabels,
type Translate,
} from "./labels.js"
import { ReservationList, type ReservationListLabels } from "./reservation-list.js"
import { ReserveForm, type ReserveFormLabels } from "./reserve-form.js"
import { Stock, type StockLabels } from "./stock.js"
/** The items, the form that adds one, and this project's open reservations. */
export interface InventoryListData {
readonly kind: "list"
readonly title: string
readonly intro: string
readonly itemsHeading: string
readonly items: { rows: ItemView[]; after?: string | undefined }
readonly itemForm: ItemFormLabels
readonly itemList: ItemListLabels
readonly reservations: { rows: ReservationView[]; after?: string | undefined }
readonly reservationList: ReservationListLabels
/** Each reserved item as a person reads it, by item id. */
readonly itemLabel: Readonly<Record<string, string>>
}
/** One item's stock, and the form that reserves it when the item is active. */
export interface InventoryItemData {
readonly kind: "item"
readonly title: string
readonly back: string
readonly heading: string
readonly description?: string
readonly itemId: string
readonly active: boolean
readonly availability: AvailabilityView
readonly stock: StockLabels
readonly reserve: ReserveFormLabels
}
/** The service cannot answer: the notice in place of the page. */
export interface InventoryStateData {
readonly kind: "state"
readonly title: string
readonly notice: ServiceNotice
}
export type InventoryPageData = InventoryListData | InventoryItemData | InventoryStateData
export type InventoryPage = ProjectPage<TransportCaller, I18n, Container, InventoryPageData>
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/** A location as a person reads it: the service knows locations by id, so the id's first block. */
const locationName = (locationId: string): string => locationId.slice(0, 8)
export const inventoryPage: InventoryPage = projectPage({
async load({ slugs, segments, caller, i18n }): Promise<InventoryPageData> {
const t: Translate = (key, values) => i18n.message(`inventory.${key}`, values)
const title = t("title")
const refusals = businessRefusalNotices(i18n.locale)
const [itemId, ...rest] = segments
if (rest.length > 0 || (itemId !== undefined && !UUID.test(itemId))) throw notFound("no such page")
try {
if (itemId === undefined) {
const [items, reservations] = await Promise.all([
caller.call(itemList, slugs),
caller.call(reservationList, { ...slugs, status: "open" }),
])
return {
kind: "list",
title,
intro: t("intro"),
itemsHeading: t("items.title"),
items,
itemForm: itemFormLabels(t, refusals),
itemList: itemListLabels(t),
reservations,
reservationList: reservationListLabels(t, refusals),
itemLabel: Object.fromEntries(items.rows.map((item) => [item.itemId, `${item.sku} ${item.name}`])),
}
}
const { item, availability } = await caller.call(itemAvailability, { ...slugs, itemId })
return {
kind: "item",
title,
back: t("item.back"),
heading: `${item.sku} ${item.name}`,
...(item.description ? { description: item.description } : {}),
itemId: item.itemId,
active: item.active,
availability,
stock: stockLabels(t, availability.unit, availability.locations.length),
reserve: reserveFormLabels(t, refusals),
}
} catch (error) {
const notice = serviceNotice(error, businessServiceNotices(i18n.locale))
if (!notice) throw error
return { kind: "state", title, notice }
}
},
View({ slugs, data }: ProjectPageViewProps<InventoryPageData>): ReactElement {
const listHref = `/${slugs.org}/${slugs.workspace}/${slugs.project}/inventory`
if (data.kind === "state") return <ServiceStatePage title={data.title} notice={data.notice} />
if (data.kind === "list") {
return (
<Page>
<PageHeader title={data.title} description={data.intro} />
<DetailLayout
aside={
<Can permission="inventory.item.write">
<ItemForm slugs={slugs} listHref={listHref} labels={data.itemForm} />
</Can>
}
>
<PageSection title={data.itemsHeading}>
<ItemList slugs={slugs} seed={data.items} listHref={listHref} labels={data.itemList} />
</PageSection>
<ReservationList
slugs={slugs}
seed={data.reservations}
itemLabel={data.itemLabel}
listHref={listHref}
labels={data.reservationList}
/>
</DetailLayout>
</Page>
)
}
return (
<Page>
<PageHeader
title={data.heading}
description={data.description}
breadcrumb={<a href={listHref}>{data.back}</a>}
/>
<DetailLayout
aside={
data.active ? (
<Can permission="inventory.stock.reserve">
<ReserveForm
slugs={slugs}
itemId={data.itemId}
options={data.availability.locations.map((level) => ({
locationId: level.locationId,
label: `${locationName(level.locationId)} (${level.available})`,
available: level.available,
}))}
labels={data.reserve}
/>
</Can>
) : undefined
}
>
<Stock availability={data.availability} locationName={locationName} labels={data.stock} />
</DetailLayout>
</Page>
)
},
})