@@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@conusai-kit/ui/primitives"
|
||||
|
||||
// Imports a CSV file: the browser reads it, hashes it and sends it to the service in chunks of the size one
|
||||
// request may carry, each carrying the import's revision from the chunk before. A row the service refuses
|
||||
// is listed with its reason; the others stay applied. Sending the same file again applies nothing twice.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useWrite } from "@conusai-kit/ui/data"
|
||||
import { serviceNotice } from "@conusai-kit/ui/patterns"
|
||||
import { Alert, AlertDescription, Field, FieldDescription, FieldLabel, Input } from "@conusai-kit/ui/primitives"
|
||||
import { type FormEvent, type ReactElement, useState } from "react"
|
||||
import { type ImportOutcomeView, type ImportRow, MAX_IMPORT_BYTES } from "../contract/index.js"
|
||||
import { chunksOf, parseProductCsv } from "./import-rows.js"
|
||||
|
||||
export interface ImportFormLabels {
|
||||
readonly heading: string
|
||||
readonly file: string
|
||||
readonly hint: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
/** `{created} created, ...`: the component fills the counts. */
|
||||
readonly done: string
|
||||
readonly empty: string
|
||||
readonly tooLarge: string
|
||||
/** `... lacks: {columns}` */
|
||||
readonly missing: string
|
||||
readonly refusedCaption: string
|
||||
readonly row: string
|
||||
readonly reason: string
|
||||
readonly reasons: Readonly<Record<"invalid_fields" | "conflict" | "not_found", string>>
|
||||
readonly failed: string
|
||||
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
|
||||
readonly refusals: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
type Refused = { sourceRow: number; reason: string | null }
|
||||
|
||||
type State =
|
||||
| { kind: "idle" }
|
||||
| { kind: "busy" }
|
||||
| { kind: "message"; text: string }
|
||||
| { kind: "done"; summary: string; refused: Refused[] }
|
||||
|
||||
async function sha256(bytes: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes)
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
export function ImportForm({ slugs, labels }: { slugs: ProjectSlugs; labels: ImportFormLabels }): ReactElement {
|
||||
const apply = useWrite<
|
||||
ProjectSlugs & {
|
||||
fileHash: string
|
||||
sourceName: string
|
||||
format: "csv"
|
||||
chunk: number
|
||||
finalChunk: boolean
|
||||
revision?: string
|
||||
rows: ImportRow[]
|
||||
requestId: string
|
||||
},
|
||||
ImportOutcomeView
|
||||
>("catalog.import.apply")
|
||||
const [state, setState] = useState<State>({ kind: "idle" })
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const file = new FormData(event.currentTarget).get("file")
|
||||
if (!(file instanceof File) || file.size === 0) return
|
||||
if (file.size > MAX_IMPORT_BYTES) {
|
||||
setState({ kind: "message", text: labels.tooLarge })
|
||||
return
|
||||
}
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
const parsed = parseProductCsv(await file.text())
|
||||
if (parsed.kind === "unreadable") {
|
||||
setState({ kind: "message", text: labels.missing.replace("{columns}", parsed.missing.join(", ")) })
|
||||
return
|
||||
}
|
||||
const chunks = chunksOf(parsed.rows)
|
||||
if (chunks.length === 0) {
|
||||
setState({ kind: "message", text: labels.empty })
|
||||
return
|
||||
}
|
||||
const fileHash = await sha256(await file.arrayBuffer())
|
||||
const refused: Refused[] = []
|
||||
let last: ImportOutcomeView | undefined
|
||||
for (const [chunk, rows] of chunks.entries()) {
|
||||
last = await apply.mutate({
|
||||
...slugs,
|
||||
fileHash,
|
||||
sourceName: file.name,
|
||||
format: "csv",
|
||||
chunk,
|
||||
finalChunk: chunk === chunks.length - 1,
|
||||
...(last === undefined ? {} : { revision: last.import.revision }),
|
||||
rows,
|
||||
requestId: crypto.randomUUID(),
|
||||
})
|
||||
for (const row of last.rows) {
|
||||
if (row.outcome === "refused") refused.push({ sourceRow: row.sourceRow, reason: row.reason })
|
||||
}
|
||||
}
|
||||
const totals = last?.import
|
||||
setState({
|
||||
kind: "done",
|
||||
summary: labels.done
|
||||
.replace("{created}", String(totals?.created ?? 0))
|
||||
.replace("{updated}", String(totals?.updated ?? 0))
|
||||
.replace("{unchanged}", String(totals?.unchanged ?? 0))
|
||||
.replace("{refused}", String(totals?.refused ?? 0)),
|
||||
refused,
|
||||
})
|
||||
} catch (error) {
|
||||
setState({
|
||||
kind: "message",
|
||||
text: labels.failed.replace("{detail}", serviceNotice(error, labels.refusals)?.text ?? describeError(error)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const reasonOf = (reason: string | null): string =>
|
||||
(reason !== null && labels.reasons[reason as keyof typeof labels.reasons]) || (reason ?? "")
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} aria-busy={state.kind === "busy"}>
|
||||
<h2>{labels.heading}</h2>
|
||||
<Field>
|
||||
<FieldLabel>{labels.file}</FieldLabel>
|
||||
<Input name="file" type="file" required accept=".csv,text/csv" />
|
||||
<FieldDescription>{labels.hint}</FieldDescription>
|
||||
</Field>
|
||||
<Button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.busy : labels.submit}
|
||||
</Button>
|
||||
{state.kind === "message" ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{state.text}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{state.kind === "done" ? (
|
||||
<>
|
||||
<p role="status">{state.summary}</p>
|
||||
{state.refused.length > 0 ? (
|
||||
<Table>
|
||||
<TableCaption>{labels.refusedCaption}</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">{labels.row}</TableHead>
|
||||
<TableHead scope="col">{labels.reason}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{state.refused.map((row) => (
|
||||
<TableRow key={row.sourceRow}>
|
||||
<TableHead scope="row">{row.sourceRow}</TableHead>
|
||||
<TableCell>{reasonOf(row.reason)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@conusai-kit/ui/primitives"
|
||||
// The imports applied so far. A server component: it holds no state, and every string is passed in.
|
||||
|
||||
import type { ReactElement } from "react"
|
||||
import type { ImportView } from "../contract/index.js"
|
||||
|
||||
export interface ImportListLabels {
|
||||
/** How many imports, already filled with the plural form. */
|
||||
readonly count: string
|
||||
readonly caption: string
|
||||
readonly empty: string
|
||||
readonly source: string
|
||||
readonly status: string
|
||||
readonly created: string
|
||||
readonly updated: string
|
||||
readonly unchanged: string
|
||||
readonly refused: string
|
||||
}
|
||||
|
||||
export function ImportList({
|
||||
imports,
|
||||
labels,
|
||||
}: {
|
||||
imports: readonly ImportView[]
|
||||
labels: ImportListLabels
|
||||
}): ReactElement {
|
||||
return imports.length === 0 ? (
|
||||
<p>{labels.empty}</p>
|
||||
) : (
|
||||
<>
|
||||
<p>{labels.count}</p>
|
||||
<Table>
|
||||
<TableCaption>{labels.caption}</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">{labels.source}</TableHead>
|
||||
<TableHead scope="col">{labels.status}</TableHead>
|
||||
<TableHead scope="col">{labels.created}</TableHead>
|
||||
<TableHead scope="col">{labels.updated}</TableHead>
|
||||
<TableHead scope="col">{labels.unchanged}</TableHead>
|
||||
<TableHead scope="col">{labels.refused}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{imports.map((row) => (
|
||||
<TableRow key={row.importId}>
|
||||
<TableHead scope="row">{row.sourceName}</TableHead>
|
||||
<TableCell>{row.status}</TableCell>
|
||||
<TableCell>{row.created}</TableCell>
|
||||
<TableCell>{row.updated}</TableCell>
|
||||
<TableCell>{row.unchanged}</TableCell>
|
||||
<TableCell>{row.refused}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// What the import form reads out of a file in the browser: a small CSV reader (RFC 4180: quoted fields,
|
||||
// doubled quotes, LF or CRLF) and the rows as the service takes them. No component here, so a test reads
|
||||
// it directly; the service still validates every row and reports the ones it refuses.
|
||||
|
||||
import { IMPORT_CHUNK_ROWS, type ImportRow } from "../contract/index.js"
|
||||
|
||||
const REQUIRED = ["code", "name", "base_unit"] as const
|
||||
|
||||
/** The records of a CSV text, each a list of fields. Blank lines are records of one empty field. */
|
||||
export function csvRows(text: string): string[][] {
|
||||
const records: string[][] = []
|
||||
let record: string[] = []
|
||||
let field = ""
|
||||
let quoted = false
|
||||
for (let at = 0; at < text.length; at += 1) {
|
||||
const char = text[at]
|
||||
if (quoted) {
|
||||
if (char !== '"') field += char
|
||||
else if (text[at + 1] === '"') {
|
||||
field += '"'
|
||||
at += 1
|
||||
} else quoted = false
|
||||
} else if (char === '"') quoted = true
|
||||
else if (char === ",") {
|
||||
record.push(field)
|
||||
field = ""
|
||||
} else if (char === "\n" || char === "\r") {
|
||||
if (char === "\r" && text[at + 1] === "\n") at += 1
|
||||
record.push(field)
|
||||
records.push(record)
|
||||
record = []
|
||||
field = ""
|
||||
} else field += char
|
||||
}
|
||||
if (field !== "" || record.length > 0) {
|
||||
record.push(field)
|
||||
records.push(record)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
export type ParsedProducts = { kind: "rows"; rows: ImportRow[] } | { kind: "unreadable"; missing: string[] }
|
||||
|
||||
/** The product rows of a CSV file, by header name, or the required columns its header lacks. */
|
||||
export function parseProductCsv(text: string): ParsedProducts {
|
||||
const records = csvRows(text.replace(/^/, ""))
|
||||
const header = (records[0] ?? []).map((name) => name.trim().toLowerCase())
|
||||
const missing = REQUIRED.filter((name) => !header.includes(name))
|
||||
if (missing.length > 0) return { kind: "unreadable", missing }
|
||||
const column = (record: readonly string[], name: string): string => {
|
||||
const at = header.indexOf(name)
|
||||
return at < 0 ? "" : (record[at] ?? "").trim()
|
||||
}
|
||||
const rows: ImportRow[] = []
|
||||
records.slice(1).forEach((record, at) => {
|
||||
if (record.every((value) => value.trim() === "")) return
|
||||
const description = column(record, "description")
|
||||
const revision = column(record, "revision")
|
||||
rows.push({
|
||||
// The line a person sees in the file: the header is line 1.
|
||||
sourceRow: at + 2,
|
||||
...(revision === "" ? {} : { revision }),
|
||||
product: {
|
||||
code: column(record, "code"),
|
||||
name: column(record, "name"),
|
||||
baseUnit: column(record, "base_unit"),
|
||||
...(description === "" ? {} : { description }),
|
||||
},
|
||||
})
|
||||
})
|
||||
return { kind: "rows", rows }
|
||||
}
|
||||
|
||||
/** Rows in chunks of the size one request may carry. */
|
||||
export function chunksOf<T>(rows: readonly T[]): T[][] {
|
||||
const chunks: T[][] = []
|
||||
for (let at = 0; at < rows.length; at += IMPORT_CHUNK_ROWS) chunks.push(rows.slice(at, at + IMPORT_CHUNK_ROWS))
|
||||
return chunks
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// The catalog module's pages and components. `catalogPage` is what the web shell mounts; the rest is
|
||||
// exported for an app that composes its own page from the parts.
|
||||
|
||||
export { ImportForm, type ImportFormLabels } from "./import-form.js"
|
||||
export { ImportList, type ImportListLabels } from "./import-list.js"
|
||||
export { chunksOf, csvRows, parseProductCsv } from "./import-rows.js"
|
||||
export {
|
||||
importFormLabels,
|
||||
importListLabels,
|
||||
productListLabels,
|
||||
type Translate,
|
||||
} from "./labels.js"
|
||||
export { type CatalogPage, catalogPage, serviceState } from "./pages.js"
|
||||
export { ProductList, type ProductListLabels } from "./product-list.js"
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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 (`products.name`); the page binds it to
|
||||
// `i18n.message("catalog." + key)`. The app's `app.failed` is passed in already looked up, and so is
|
||||
// `refusals`, the kit's text for each business refusal in the reader's language (`businessRefusalNotices`).
|
||||
|
||||
import type { ImportFormLabels } from "./import-form.js"
|
||||
import type { ImportListLabels } from "./import-list.js"
|
||||
import type { ProductListLabels } from "./product-list.js"
|
||||
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
export type Refusals = Readonly<Record<string, string>>
|
||||
|
||||
export function productListLabels(t: Translate, failed: string, refusals: Refusals): ProductListLabels {
|
||||
return {
|
||||
caption: t("products.caption"),
|
||||
empty: t("products.empty"),
|
||||
code: t("products.code"),
|
||||
name: t("products.name"),
|
||||
unit: t("products.unit"),
|
||||
status: t("products.status"),
|
||||
// The placeholder is the component's to fill, so it is passed through untouched.
|
||||
open: t("products.open", { name: "{name}" }),
|
||||
search: t("products.search"),
|
||||
searchIn: t("products.searchIn"),
|
||||
fields: {
|
||||
name: t("products.byName"),
|
||||
code: t("products.byCode"),
|
||||
gtin: t("products.byGtin"),
|
||||
erp: t("products.byErp"),
|
||||
},
|
||||
searchSubmit: t("products.searchSubmit"),
|
||||
more: t("products.more"),
|
||||
failed,
|
||||
refusals,
|
||||
}
|
||||
}
|
||||
|
||||
export function importListLabels(t: Translate, count: number): ImportListLabels {
|
||||
return {
|
||||
count: t("imports.count", { count }),
|
||||
caption: t("imports.caption"),
|
||||
empty: t("imports.empty"),
|
||||
source: t("imports.source"),
|
||||
status: t("imports.status"),
|
||||
created: t("imports.created"),
|
||||
updated: t("imports.updated"),
|
||||
unchanged: t("imports.unchanged"),
|
||||
refused: t("imports.refused"),
|
||||
}
|
||||
}
|
||||
|
||||
export function importFormLabels(t: Translate, failed: string, refusals: Refusals): ImportFormLabels {
|
||||
return {
|
||||
heading: t("importForm.heading"),
|
||||
file: t("importForm.file"),
|
||||
hint: t("importForm.hint"),
|
||||
submit: t("importForm.submit"),
|
||||
busy: t("importForm.busy"),
|
||||
// The counts are the component's to fill, so the placeholders are passed through untouched.
|
||||
done: t("importForm.done", {
|
||||
created: "{created}",
|
||||
updated: "{updated}",
|
||||
unchanged: "{unchanged}",
|
||||
refused: "{refused}",
|
||||
}),
|
||||
empty: t("importForm.empty"),
|
||||
tooLarge: t("importForm.tooLarge"),
|
||||
missing: t("importForm.missing", { columns: "{columns}" }),
|
||||
refusedCaption: t("importForm.refusedCaption"),
|
||||
row: t("importForm.row"),
|
||||
reason: t("importForm.reason"),
|
||||
reasons: {
|
||||
invalid_fields: t("importForm.invalidFields"),
|
||||
conflict: t("importForm.conflict"),
|
||||
not_found: t("importForm.notFound"),
|
||||
},
|
||||
failed,
|
||||
refusals,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// The catalog 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>/catalog`, and the view of what it returned.
|
||||
//
|
||||
// /catalog the organization's products, and a search
|
||||
// /catalog/imports the imports so far, and the form that applies one
|
||||
// /catalog/<productId> one product
|
||||
//
|
||||
// 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, Locale } from "@conusai-kit/i18n"
|
||||
import { type ProjectPage, type ProjectPageViewProps, projectPage } from "@conusai-kit/ui"
|
||||
import { Can } from "@conusai-kit/ui/data"
|
||||
import { PageHeader, ServiceStatePage, serviceNotice } from "@conusai-kit/ui/patterns"
|
||||
import type { ReactElement } from "react"
|
||||
import {
|
||||
CATALOG_PERMISSIONS,
|
||||
type ImportView,
|
||||
importList,
|
||||
type ProductView,
|
||||
productGet,
|
||||
productList,
|
||||
} from "../contract/index.js"
|
||||
import { ImportForm, type ImportFormLabels } from "./import-form.js"
|
||||
import { ImportList, type ImportListLabels } from "./import-list.js"
|
||||
import { importFormLabels, importListLabels, productListLabels, type Translate } from "./labels.js"
|
||||
import { ProductList, type ProductListLabels } from "./product-list.js"
|
||||
|
||||
/** A platform refusal as the page shows it: the problem's code and its text in the reader's language. */
|
||||
export interface ServiceNotice {
|
||||
readonly code: string
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
type StatusNames = Readonly<Record<ProductView["status"], string>>
|
||||
|
||||
export interface CatalogListData {
|
||||
readonly kind: "list"
|
||||
readonly title: string
|
||||
readonly intro: string
|
||||
readonly importsLink: string
|
||||
readonly list: ProductListLabels
|
||||
readonly statuses: StatusNames
|
||||
readonly products: { rows: ProductView[]; after?: string | undefined }
|
||||
}
|
||||
|
||||
export interface CatalogProductData {
|
||||
readonly kind: "product"
|
||||
readonly title: string
|
||||
readonly back: string
|
||||
readonly product: ProductView
|
||||
readonly status: string
|
||||
readonly labels: {
|
||||
readonly code: string
|
||||
readonly unit: string
|
||||
readonly status: string
|
||||
readonly group: string
|
||||
readonly description: string
|
||||
readonly revision: string
|
||||
readonly identifiers: string
|
||||
readonly none: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface CatalogImportsData {
|
||||
readonly kind: "imports"
|
||||
readonly title: string
|
||||
readonly intro: string
|
||||
readonly back: string
|
||||
readonly form: ImportFormLabels
|
||||
readonly list: ImportListLabels
|
||||
readonly imports: readonly ImportView[]
|
||||
}
|
||||
|
||||
/** The service cannot answer: the notice in place of the page. */
|
||||
export interface CatalogStateData {
|
||||
readonly kind: "state"
|
||||
readonly title: string
|
||||
readonly notice: ServiceNotice
|
||||
}
|
||||
|
||||
export type CatalogPageData = CatalogListData | CatalogProductData | CatalogImportsData | CatalogStateData
|
||||
|
||||
export type CatalogPage = ProjectPage<TransportCaller, I18n, Container, CatalogPageData>
|
||||
|
||||
/** A product id as the service mints it: one path segment. */
|
||||
const PRODUCT_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
|
||||
|
||||
/** The product a path segment names; a segment that does not decode names none. */
|
||||
function productIdOf(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment)
|
||||
} catch {
|
||||
throw notFound("no such page")
|
||||
}
|
||||
}
|
||||
|
||||
/** The notice for a platform refusal, or undefined for any other error (which the shell answers). */
|
||||
export function serviceState(error: unknown, locale: Locale): ServiceNotice | undefined {
|
||||
return serviceNotice(error, businessServiceNotices(locale))
|
||||
}
|
||||
|
||||
const statusNames = (t: Translate): StatusNames => ({
|
||||
active: t("statuses.active"),
|
||||
archived: t("statuses.archived"),
|
||||
deleted: t("statuses.deleted"),
|
||||
})
|
||||
|
||||
export const catalogPage: CatalogPage = projectPage({
|
||||
async load({ slugs, segments, caller, i18n }): Promise<CatalogPageData> {
|
||||
const t: Translate = (key, values) => i18n.message(`catalog.${key}`, values)
|
||||
const failed = i18n.message("app.failed")
|
||||
const refusals = businessRefusalNotices(i18n.locale)
|
||||
const title = t("title")
|
||||
|
||||
const [segment, ...rest] = segments
|
||||
if (rest.length > 0) throw notFound("no such page")
|
||||
const productId = segment === undefined || segment === "imports" ? undefined : productIdOf(segment)
|
||||
if (productId !== undefined && !PRODUCT_ID.test(productId)) throw notFound("no such page")
|
||||
|
||||
try {
|
||||
if (segment === "imports") {
|
||||
const { rows } = await caller.call(importList, slugs)
|
||||
return {
|
||||
kind: "imports",
|
||||
title: t("imports.title"),
|
||||
intro: t("imports.intro"),
|
||||
back: t("nav.products"),
|
||||
form: importFormLabels(t, failed, refusals),
|
||||
list: importListLabels(t, rows.length),
|
||||
imports: rows,
|
||||
}
|
||||
}
|
||||
if (productId === undefined) {
|
||||
return {
|
||||
kind: "list",
|
||||
title,
|
||||
intro: t("intro"),
|
||||
importsLink: t("nav.imports"),
|
||||
list: productListLabels(t, failed, refusals),
|
||||
statuses: statusNames(t),
|
||||
products: await caller.call(productList, slugs),
|
||||
}
|
||||
}
|
||||
const product = await caller.call(productGet, { ...slugs, productId })
|
||||
return {
|
||||
kind: "product",
|
||||
title,
|
||||
back: t("product.back"),
|
||||
product,
|
||||
status: statusNames(t)[product.status],
|
||||
labels: {
|
||||
code: t("product.code"),
|
||||
unit: t("product.unit"),
|
||||
status: t("product.status"),
|
||||
group: t("product.group"),
|
||||
description: t("product.description"),
|
||||
revision: t("product.revision"),
|
||||
identifiers: t("product.identifiers"),
|
||||
none: t("product.none"),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
const notice = serviceState(error, i18n.locale)
|
||||
if (!notice) throw error
|
||||
return { kind: "state", title, notice }
|
||||
}
|
||||
},
|
||||
|
||||
View({ slugs, data }: ProjectPageViewProps<CatalogPageData>): ReactElement {
|
||||
const listHref = `/${slugs.org}/${slugs.workspace}/${slugs.project}/catalog`
|
||||
return data.kind === "state" ? (
|
||||
<ServiceStatePage title={data.title} notice={data.notice} />
|
||||
) : (
|
||||
<>
|
||||
<PageHeader title={data.title} />
|
||||
{data.kind === "list" ? (
|
||||
<>
|
||||
<p>{data.intro}</p>
|
||||
<p>
|
||||
<a href={`${listHref}/imports`}>{data.importsLink}</a>
|
||||
</p>
|
||||
<ProductList
|
||||
slugs={slugs}
|
||||
seed={data.products}
|
||||
statuses={data.statuses}
|
||||
listHref={listHref}
|
||||
labels={data.list}
|
||||
/>
|
||||
</>
|
||||
) : data.kind === "imports" ? (
|
||||
<>
|
||||
<p>
|
||||
<a href={listHref}>{data.back}</a>
|
||||
</p>
|
||||
<p>{data.intro}</p>
|
||||
<Can permission={CATALOG_PERMISSIONS.write}>
|
||||
<ImportForm slugs={slugs} labels={data.form} />
|
||||
</Can>
|
||||
<ImportList imports={data.imports} labels={data.list} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
<a href={listHref}>{data.back}</a>
|
||||
</p>
|
||||
<h2>{data.product.name}</h2>
|
||||
<dl>
|
||||
<dt>{data.labels.code}</dt>
|
||||
<dd>{data.product.code}</dd>
|
||||
<dt>{data.labels.unit}</dt>
|
||||
<dd>{data.product.baseUnit}</dd>
|
||||
<dt>{data.labels.status}</dt>
|
||||
<dd>{data.status}</dd>
|
||||
<dt>{data.labels.revision}</dt>
|
||||
<dd>{data.product.revision}</dd>
|
||||
{data.product.groupCode === null ? null : (
|
||||
<>
|
||||
<dt>{data.labels.group}</dt>
|
||||
<dd>{data.product.groupCode}</dd>
|
||||
</>
|
||||
)}
|
||||
{data.product.description === "" ? null : (
|
||||
<>
|
||||
<dt>{data.labels.description}</dt>
|
||||
<dd>{data.product.description}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>{data.labels.identifiers}</dt>
|
||||
<dd>
|
||||
{data.product.identifiers.length === 0 ? (
|
||||
data.labels.none
|
||||
) : (
|
||||
<ul>
|
||||
{data.product.identifiers.map((identifier) => (
|
||||
<li key={`${identifier.kind}:${identifier.value}`}>
|
||||
{identifier.kind}: {identifier.value}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useDataClient } from "@conusai-kit/ui/data"
|
||||
import { ResourceList, SelectField, serviceNotice, useResourceList } from "@conusai-kit/ui/patterns"
|
||||
import type { ReactElement } from "react"
|
||||
import { PRODUCT_SEARCH_FIELDS, type ProductSearchField, type ProductView } from "../contract/index.js"
|
||||
|
||||
export interface ProductListLabels {
|
||||
readonly caption: string
|
||||
readonly empty: string
|
||||
readonly code: string
|
||||
readonly name: string
|
||||
readonly unit: string
|
||||
readonly status: string
|
||||
/** `Open {name}`: the link's accessible name. */
|
||||
readonly open: string
|
||||
readonly search: string
|
||||
readonly searchIn: string
|
||||
readonly fields: Readonly<Record<ProductSearchField, string>>
|
||||
readonly searchSubmit: string
|
||||
readonly more: string
|
||||
readonly failed: 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: ProductView[]; after?: string | undefined }
|
||||
|
||||
export function ProductList({
|
||||
slugs,
|
||||
seed,
|
||||
statuses,
|
||||
listHref,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
seed: Page
|
||||
statuses: Readonly<Record<ProductView["status"], string>>
|
||||
listHref: string
|
||||
labels: ProductListLabels
|
||||
}): ReactElement {
|
||||
const client = useDataClient()
|
||||
const { list, filters, setFilter } = useResourceList<ProductView>({
|
||||
seed,
|
||||
rowId: (row) => row.productId,
|
||||
searchLabel: labels.search,
|
||||
describeError: (error) =>
|
||||
labels.failed.replace("{detail}", serviceNotice(error, labels.refusals)?.text ?? describeError(error)),
|
||||
read: async ({ q, after, filters }) =>
|
||||
(
|
||||
await client.call<Page>(q ? "catalog.product.search" : "catalog.product.list", {
|
||||
...slugs,
|
||||
...(q ? { q, by: filters.by ?? "name" } : {}),
|
||||
...(after ? { after } : {}),
|
||||
})
|
||||
).value,
|
||||
})
|
||||
return (
|
||||
<ResourceList
|
||||
{...list}
|
||||
label={labels.caption}
|
||||
empty={{ title: labels.empty }}
|
||||
filters={
|
||||
<SelectField
|
||||
label={labels.searchIn}
|
||||
value={filters.by ?? "name"}
|
||||
options={PRODUCT_SEARCH_FIELDS.map((value) => ({ value, label: labels.fields[value] }))}
|
||||
onValueChange={(value) => setFilter("by", value)}
|
||||
/>
|
||||
}
|
||||
columns={[
|
||||
{ id: "name", header: labels.name, wrap: true },
|
||||
{ id: "code", header: labels.code },
|
||||
{ id: "baseUnit", header: labels.unit },
|
||||
{ id: "status", header: labels.status, cell: (row) => statuses[row.status] },
|
||||
]}
|
||||
rowHref={(row) => `${listHref}/${encodeURIComponent(row.productId)}`}
|
||||
rowLinkLabel={(row) => labels.open.replace("{name}", row.name)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user