@@ -0,0 +1,17 @@
|
||||
// The inventory module's pages and components. `inventoryPage` is what the web shell mounts; the rest
|
||||
// is exported for an app that composes its own page from the parts.
|
||||
|
||||
export { ItemForm, type ItemFormLabels } from "./item-form.js"
|
||||
export { ItemList, type ItemListLabels } from "./item-list.js"
|
||||
export {
|
||||
itemFormLabels,
|
||||
itemListLabels,
|
||||
reservationListLabels,
|
||||
reserveFormLabels,
|
||||
stockLabels,
|
||||
type Translate,
|
||||
} from "./labels.js"
|
||||
export { type InventoryPage, inventoryPage } from "./pages.js"
|
||||
export { ReservationList, type ReservationListLabels } from "./reservation-list.js"
|
||||
export { ReserveForm, type ReserveFormLabels, type ReserveOption } from "./reserve-form.js"
|
||||
export { Stock, type StockLabels } from "./stock.js"
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
// Adds an item to the organization's catalogue, then opens its stock page. The service checks the SKU
|
||||
// and the unit again and answers a taken SKU as a duplicate, shown here in the kit's words for that refusal.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useWrite } from "@conusai-kit/ui/data"
|
||||
import {
|
||||
FormLayout,
|
||||
type FormValues,
|
||||
PageSection,
|
||||
serviceNotice,
|
||||
TextField,
|
||||
usePageNavigation,
|
||||
} from "@conusai-kit/ui/patterns"
|
||||
import type { ReactElement } from "react"
|
||||
|
||||
export interface ItemFormLabels {
|
||||
readonly heading: string
|
||||
readonly sku: string
|
||||
readonly skuHint: string
|
||||
readonly name: string
|
||||
readonly unit: string
|
||||
readonly unitHint: string
|
||||
readonly description: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
|
||||
readonly refusals: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export function ItemForm({
|
||||
slugs,
|
||||
listHref,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
listHref: string
|
||||
labels: ItemFormLabels
|
||||
}): ReactElement {
|
||||
const navigation = usePageNavigation()
|
||||
// No live collection follows a service's rows, so the write resolves with the answer.
|
||||
const create = useWrite<Record<string, unknown>, { itemId: string }>("inventory.item.create")
|
||||
|
||||
const submit = async ({ sku, name, unit, description = "" }: FormValues): Promise<void> => {
|
||||
const trimmed = description.trim()
|
||||
const created = await create.mutate({ ...slugs, sku, name, unit, ...(trimmed ? { description: trimmed } : {}) })
|
||||
navigation.navigate(`${listHref}/${created.itemId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection title={labels.heading}>
|
||||
<FormLayout
|
||||
submitLabel={labels.submit}
|
||||
busyLabel={labels.busy}
|
||||
onSubmit={submit}
|
||||
describeError={(error) => serviceNotice(error, labels.refusals)?.text ?? describeError(error)}
|
||||
>
|
||||
<TextField
|
||||
name="sku"
|
||||
label={labels.sku}
|
||||
description={labels.skuHint}
|
||||
errorMessage={labels.skuHint}
|
||||
required
|
||||
maxLength={64}
|
||||
pattern="[A-Za-z0-9][A-Za-z0-9._\-]*"
|
||||
/>
|
||||
<TextField name="name" label={labels.name} required maxLength={200} />
|
||||
<TextField
|
||||
name="unit"
|
||||
label={labels.unit}
|
||||
description={labels.unitHint}
|
||||
errorMessage={labels.unitHint}
|
||||
required
|
||||
maxLength={16}
|
||||
pattern="[a-z][a-z0-9_]*"
|
||||
/>
|
||||
<TextField multiline name="description" label={labels.description} rows={3} maxLength={1000} />
|
||||
</FormLayout>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
// The organization's items, a page at a time. The server renders the first page (`seed`); a search and
|
||||
// "Show more" are reads made on a person's action, through the page's data client.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useDataClient } from "@conusai-kit/ui/data"
|
||||
import { type ResourceColumn, ResourceList, useResourceList } from "@conusai-kit/ui/patterns"
|
||||
import type { ReactElement } from "react"
|
||||
import type { ItemView } from "../contract/index.js"
|
||||
|
||||
export interface ItemListLabels {
|
||||
readonly caption: string
|
||||
readonly empty: string
|
||||
readonly sku: string
|
||||
readonly name: string
|
||||
readonly unit: string
|
||||
readonly active: string
|
||||
readonly archived: string
|
||||
/** `Stock of {name}`: the link's accessible name. */
|
||||
readonly open: string
|
||||
readonly search: string
|
||||
}
|
||||
|
||||
type Page = { rows: ItemView[]; after?: string | undefined }
|
||||
|
||||
export function ItemList({
|
||||
slugs,
|
||||
seed,
|
||||
listHref,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
seed: Page
|
||||
listHref: string
|
||||
labels: ItemListLabels
|
||||
}): ReactElement {
|
||||
const client = useDataClient()
|
||||
const { list } = useResourceList<ItemView>({
|
||||
seed,
|
||||
rowId: (item) => item.itemId,
|
||||
searchLabel: labels.search,
|
||||
read: async ({ q, after }) => {
|
||||
const input = { ...slugs, ...(q ? { q } : {}), ...(after ? { after } : {}) }
|
||||
return (await client.call<Page>("inventory.item.list", input)).value
|
||||
},
|
||||
})
|
||||
const columns: ResourceColumn<ItemView>[] = [
|
||||
{ id: "sku", header: labels.sku },
|
||||
{ id: "name", header: labels.name, wrap: true, minWidth: "10rem" },
|
||||
{ id: "unit", header: labels.unit },
|
||||
{ id: "active", header: labels.active, cell: (item) => (item.active ? labels.active : labels.archived) },
|
||||
]
|
||||
return (
|
||||
<ResourceList
|
||||
{...list}
|
||||
label={labels.caption}
|
||||
columns={columns}
|
||||
rowHref={(item) => `${listHref}/${item.itemId}`}
|
||||
rowLinkLabel={(item) => labels.open.replace("{name}", item.sku)}
|
||||
rowLabel={(item) => item.sku}
|
||||
empty={{ title: labels.empty }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// The labels each component takes, built from a message lookup. Every key this module's pages use is
|
||||
// named here or in pages.tsx, so `__tests__/i18n.test.ts` can run these builders against the
|
||||
// catalogues and fail on a key either language lacks.
|
||||
//
|
||||
// `t` takes a key relative to the module (`form.submit`); the page binds it to
|
||||
// `i18n.message("inventory." + key)`. `refusals` is the kit's text for each business refusal in the reader's
|
||||
// language (`businessRefusalNotices`), which a form or a list shows when the service refuses.
|
||||
|
||||
import type { ItemFormLabels } from "./item-form.js"
|
||||
import type { ItemListLabels } from "./item-list.js"
|
||||
import type { ReservationListLabels } from "./reservation-list.js"
|
||||
import type { ReserveFormLabels } from "./reserve-form.js"
|
||||
import type { StockLabels } from "./stock.js"
|
||||
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
export type Refusals = Readonly<Record<string, string>>
|
||||
|
||||
export function itemFormLabels(t: Translate, refusals: Refusals): ItemFormLabels {
|
||||
return {
|
||||
heading: t("form.heading"),
|
||||
sku: t("form.sku"),
|
||||
skuHint: t("form.skuHint"),
|
||||
name: t("form.name"),
|
||||
unit: t("form.unit"),
|
||||
unitHint: t("form.unitHint"),
|
||||
description: t("form.description"),
|
||||
submit: t("form.submit"),
|
||||
busy: t("form.busy"),
|
||||
refusals,
|
||||
}
|
||||
}
|
||||
|
||||
export function itemListLabels(t: Translate): ItemListLabels {
|
||||
return {
|
||||
caption: t("items.caption"),
|
||||
empty: t("items.empty"),
|
||||
sku: t("items.sku"),
|
||||
name: t("items.name"),
|
||||
unit: t("items.unit"),
|
||||
active: t("items.active"),
|
||||
archived: t("items.archived"),
|
||||
// The placeholders are the component's to fill, so they are passed through untouched.
|
||||
open: t("items.open", { name: "{name}" }),
|
||||
search: t("items.search"),
|
||||
}
|
||||
}
|
||||
|
||||
export function stockLabels(t: Translate, unit: string, locations: number): StockLabels {
|
||||
return {
|
||||
unit: t("item.unit", { unit }),
|
||||
locations: t("item.locations", { count: locations }),
|
||||
onHand: t("item.onHand"),
|
||||
reserved: t("item.reserved"),
|
||||
available: t("item.available"),
|
||||
caption: t("item.locationsCaption"),
|
||||
location: t("item.location"),
|
||||
none: t("item.none"),
|
||||
}
|
||||
}
|
||||
|
||||
export function reserveFormLabels(t: Translate, refusals: Refusals): ReserveFormLabels {
|
||||
return {
|
||||
heading: t("reserve.heading"),
|
||||
location: t("reserve.location"),
|
||||
quantity: t("reserve.quantity"),
|
||||
submit: t("reserve.submit"),
|
||||
busy: t("reserve.busy"),
|
||||
nothing: t("reserve.nothing"),
|
||||
refusals,
|
||||
}
|
||||
}
|
||||
|
||||
export function reservationListLabels(t: Translate, refusals: Refusals): ReservationListLabels {
|
||||
return {
|
||||
title: t("reservations.title"),
|
||||
caption: t("reservations.caption"),
|
||||
empty: t("reservations.empty"),
|
||||
item: t("reservations.item"),
|
||||
quantity: t("reservations.quantity"),
|
||||
outstanding: t("reservations.outstanding"),
|
||||
actions: t("reservations.actions"),
|
||||
release: t("reservations.release", { item: "{item}" }),
|
||||
releaseShort: t("reservations.releaseShort"),
|
||||
releasing: t("reservations.releasing"),
|
||||
releaseConfirm: t("reservations.releaseConfirm"),
|
||||
refusals,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
|
||||
// The stock this project holds: its open reservations, read again after a release. Releasing is for
|
||||
// whoever holds `inventory.stock.reserve`; the procedure checks again, and a reservation of another
|
||||
// project is not found there.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { Can, describeError, useRead, useWrite } from "@conusai-kit/ui/data"
|
||||
import { ConfirmDialog, PageSection, type ResourceColumn, ResourceList, serviceNotice } from "@conusai-kit/ui/patterns"
|
||||
import { Button, useUiLabels } from "@conusai-kit/ui/primitives"
|
||||
import type { ReactElement } from "react"
|
||||
import type { ReservationView } from "../contract/index.js"
|
||||
|
||||
export interface ReservationListLabels {
|
||||
readonly title: string
|
||||
readonly caption: string
|
||||
readonly empty: string
|
||||
readonly item: string
|
||||
readonly quantity: string
|
||||
readonly outstanding: string
|
||||
readonly actions: string
|
||||
/** `Release the stock held of {item}`: the button's accessible name. */
|
||||
readonly release: string
|
||||
readonly releaseShort: string
|
||||
readonly releasing: string
|
||||
/** What releasing does to the stock held. */
|
||||
readonly releaseConfirm: string
|
||||
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
|
||||
readonly refusals: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
type Page = { rows: ReservationView[]; after?: string | undefined }
|
||||
|
||||
export function ReservationList({
|
||||
slugs,
|
||||
seed,
|
||||
itemLabel,
|
||||
listHref,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
seed: Page
|
||||
/** What a person reads for an item, by id, for the items the page knows; the id otherwise. */
|
||||
itemLabel: Readonly<Record<string, string>>
|
||||
listHref: string
|
||||
labels: ReservationListLabels
|
||||
}): ReactElement {
|
||||
const list = useRead<Page>("inventory.reservation.list", { ...slugs, status: "open" }, { initial: seed })
|
||||
const release = useWrite<Record<string, unknown>, ReservationView>("inventory.stock.release")
|
||||
const failed = useUiLabels()?.failed ?? "{detail}"
|
||||
const nameOf = (row: ReservationView): string => itemLabel[row.itemId] ?? row.itemId
|
||||
const describe = (error: unknown): string => serviceNotice(error, labels.refusals)?.text ?? describeError(error)
|
||||
const columns: ResourceColumn<ReservationView>[] = [
|
||||
{ id: "itemId", header: labels.item, wrap: true, cell: nameOf },
|
||||
{ id: "quantity", header: labels.quantity, align: "end" },
|
||||
{ id: "outstanding", header: labels.outstanding, align: "end" },
|
||||
{
|
||||
id: "actions",
|
||||
header: labels.actions,
|
||||
cell: (row) => {
|
||||
const name = labels.release.replace("{item}", nameOf(row))
|
||||
return (
|
||||
<Can permission="inventory.stock.reserve">
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" aria-label={name}>
|
||||
{labels.releaseShort}
|
||||
</Button>
|
||||
}
|
||||
title={name}
|
||||
description={labels.releaseConfirm}
|
||||
confirmLabel={labels.releaseShort}
|
||||
busyLabel={labels.releasing}
|
||||
describeError={describe}
|
||||
onConfirm={async () => {
|
||||
await release.mutate({ ...slugs, reservationId: row.reservationId, requestId: crypto.randomUUID() })
|
||||
await list.refetch()
|
||||
}}
|
||||
/>
|
||||
</Can>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
return (
|
||||
<PageSection title={labels.title}>
|
||||
<ResourceList
|
||||
label={labels.caption}
|
||||
columns={columns}
|
||||
data={list.data?.rows ?? []}
|
||||
rowId={(row) => row.reservationId}
|
||||
rowHref={(row) => `${listHref}/${row.itemId}`}
|
||||
rowLabel={nameOf}
|
||||
empty={{ title: labels.empty }}
|
||||
{...(list.error === undefined ? {} : { error: failed.replace("{detail}", describe(list.error)) })}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
// Reserves stock of one item for this project, from one location that has some available. The request
|
||||
// id is minted once per submission, so a retried request reserves once; the service answers a
|
||||
// quantity it cannot hold as `insufficient_stock`, shown here in the kit's words for that refusal.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useWrite } from "@conusai-kit/ui/data"
|
||||
import {
|
||||
FormLayout,
|
||||
type FormValues,
|
||||
PageSection,
|
||||
SelectField,
|
||||
serviceNotice,
|
||||
TextField,
|
||||
usePageNavigation,
|
||||
} from "@conusai-kit/ui/patterns"
|
||||
import { type ReactElement, useState } from "react"
|
||||
|
||||
export interface ReserveFormLabels {
|
||||
readonly heading: string
|
||||
readonly location: string
|
||||
readonly quantity: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly nothing: string
|
||||
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
|
||||
readonly refusals: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface ReserveOption {
|
||||
readonly locationId: string
|
||||
/** What a person reads for the location, with what is available there. */
|
||||
readonly label: string
|
||||
readonly available: number
|
||||
}
|
||||
|
||||
export function ReserveForm({
|
||||
slugs,
|
||||
itemId,
|
||||
options,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
itemId: string
|
||||
options: readonly ReserveOption[]
|
||||
labels: ReserveFormLabels
|
||||
}): ReactElement {
|
||||
const navigation = usePageNavigation()
|
||||
const reserve = useWrite<Record<string, unknown>, unknown>("inventory.stock.reserve")
|
||||
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
|
||||
const open = options.filter((option) => option.available > 0)
|
||||
|
||||
const submit = async ({ locationId, quantity }: FormValues): Promise<void> => {
|
||||
await reserve.mutate({ ...slugs, itemId, locationId, quantity: Number(quantity), requestId })
|
||||
setRequestId(crypto.randomUUID())
|
||||
// The page is rendered on the server: a reload shows the new balance and the reservation.
|
||||
await navigation.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection title={labels.heading} variant="card">
|
||||
{open.length === 0 ? (
|
||||
<p>{labels.nothing}</p>
|
||||
) : (
|
||||
<FormLayout
|
||||
submitLabel={labels.submit}
|
||||
busyLabel={labels.busy}
|
||||
onSubmit={submit}
|
||||
describeError={(error) => serviceNotice(error, labels.refusals)?.text ?? describeError(error)}
|
||||
>
|
||||
<SelectField
|
||||
name="locationId"
|
||||
label={labels.location}
|
||||
required
|
||||
defaultValue={open[0]?.locationId}
|
||||
options={open.map((option) => ({ value: option.locationId, label: option.label }))}
|
||||
/>
|
||||
<TextField name="quantity" label={labels.quantity} type="number" required min={1} step={1} defaultValue={1} />
|
||||
</FormLayout>
|
||||
)}
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// An item's stock: the totals and one row per location that holds any. Part of a page's view (F10): it
|
||||
// holds no state, and every string is passed in.
|
||||
|
||||
import { FactList, PageSection, type ResourceColumn, ResourceList } from "@conusai-kit/ui/patterns"
|
||||
import type { ReactElement } from "react"
|
||||
import type { AvailabilityView } from "../contract/index.js"
|
||||
|
||||
export interface StockLabels {
|
||||
/** `Counted in {unit}`, already filled. */
|
||||
readonly unit: string
|
||||
/** How many locations hold stock, already filled with the plural form. */
|
||||
readonly locations: string
|
||||
readonly onHand: string
|
||||
readonly reserved: string
|
||||
readonly available: string
|
||||
readonly caption: string
|
||||
readonly location: string
|
||||
readonly none: string
|
||||
}
|
||||
|
||||
type Level = AvailabilityView["locations"][number]
|
||||
|
||||
export function Stock({
|
||||
availability,
|
||||
locationName,
|
||||
labels,
|
||||
}: {
|
||||
availability: AvailabilityView
|
||||
/** The location's name for a person; the service knows locations by id. */
|
||||
locationName: (locationId: string) => string
|
||||
labels: StockLabels
|
||||
}): ReactElement {
|
||||
const columns: ResourceColumn<Level>[] = [
|
||||
{ id: "locationId", header: labels.location, cell: (level) => locationName(level.locationId) },
|
||||
{ id: "onHand", header: labels.onHand, align: "end" },
|
||||
{ id: "reserved", header: labels.reserved, align: "end" },
|
||||
{ id: "available", header: labels.available, align: "end" },
|
||||
]
|
||||
return (
|
||||
<PageSection title={labels.caption} description={labels.unit}>
|
||||
<FactList
|
||||
facts={[
|
||||
{ label: labels.onHand, value: availability.onHand },
|
||||
{ label: labels.reserved, value: availability.reserved },
|
||||
{ label: labels.available, value: availability.available },
|
||||
]}
|
||||
/>
|
||||
<ResourceList
|
||||
label={labels.caption}
|
||||
columns={columns}
|
||||
data={availability.locations}
|
||||
rowId={(level) => level.locationId}
|
||||
count={labels.locations}
|
||||
rowLabel={(level) => locationName(level.locationId)}
|
||||
empty={{ title: labels.none }}
|
||||
/>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user