@@ -0,0 +1,200 @@
|
||||
// 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>
|
||||
)
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user