201 lines
7.4 KiB
TypeScript
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>
|
|
)
|
|
},
|
|
})
|