@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
|
||||
// What an administrator does to a document's retention: place or lift a legal hold, and dispose of it.
|
||||
// The service decides whether disposal is allowed (a hold blocks it, and the retention must have ended):
|
||||
// a refusal is shown here, and a disposal asks first, because it cannot be undone.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useWrite } from "@conusai-kit/ui/data"
|
||||
import { ConfirmDialog, serviceNotice, usePageNavigation } from "@conusai-kit/ui/patterns"
|
||||
import { Alert, AlertDescription } from "@conusai-kit/ui/primitives"
|
||||
import type { ReactElement } from "react"
|
||||
import { useState } from "react"
|
||||
|
||||
export interface DocumentActionsLabels {
|
||||
readonly heading: string
|
||||
readonly hold: string
|
||||
readonly release: string
|
||||
readonly dispose: string
|
||||
readonly disposeConfirm: string
|
||||
readonly busy: 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>>
|
||||
}
|
||||
|
||||
export function DocumentActions({
|
||||
slugs,
|
||||
documentId,
|
||||
revision,
|
||||
legalHold,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
documentId: string
|
||||
revision: string
|
||||
legalHold: boolean
|
||||
labels: DocumentActionsLabels
|
||||
}): ReactElement {
|
||||
const navigation = usePageNavigation()
|
||||
const hold = useWrite<Record<string, unknown>, unknown>("archive.document.hold")
|
||||
const dispose = useWrite<Record<string, unknown>, unknown>("archive.document.dispose")
|
||||
const [state, setState] = useState<{ kind: "idle" | "busy" } | { kind: "failed"; detail: string }>({ kind: "idle" })
|
||||
|
||||
const act = async (write: () => Promise<unknown>): Promise<void> => {
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
await write()
|
||||
await navigation.refresh()
|
||||
setState({ kind: "idle" })
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: serviceNotice(error, labels.refusals)?.text ?? describeError(error) })
|
||||
}
|
||||
}
|
||||
|
||||
const request = { ...slugs, documentId, revision, requestId: crypto.randomUUID() }
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>{labels.heading}</h2>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={state.kind === "busy"}
|
||||
onClick={() => void act(() => hold.mutate({ ...request, legalHold: !legalHold }))}
|
||||
>
|
||||
{state.kind === "busy" ? labels.busy : legalHold ? labels.release : labels.hold}
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
title={labels.dispose}
|
||||
description={labels.disposeConfirm}
|
||||
confirmLabel={labels.dispose}
|
||||
busyLabel={labels.busy}
|
||||
trigger={
|
||||
<Button type="button" variant="destructive" disabled={state.kind === "busy" || legalHold}>
|
||||
{labels.dispose}
|
||||
</Button>
|
||||
}
|
||||
onConfirm={async () => {
|
||||
await dispose.mutate(request)
|
||||
await navigation.refresh()
|
||||
}}
|
||||
/>
|
||||
{state.kind === "failed" ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{labels.failed.replace("{detail}", state.detail)}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client"
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useDataClient } from "@conusai-kit/ui/data"
|
||||
import { ResourceList, serviceNotice, useResourceList } from "@conusai-kit/ui/patterns"
|
||||
import { type ReactElement, useMemo } from "react"
|
||||
import type { DocumentView } from "../contract/index.js"
|
||||
|
||||
export interface DocumentListLabels {
|
||||
readonly caption: string
|
||||
readonly empty: string
|
||||
readonly title: string
|
||||
readonly retentionClass: string
|
||||
readonly status: string
|
||||
readonly created: string
|
||||
/** `Open {title}`: the link's accessible name. */
|
||||
readonly open: string
|
||||
readonly search: 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: DocumentView[]; after?: string | undefined }
|
||||
|
||||
export function DocumentList({
|
||||
slugs,
|
||||
seed,
|
||||
statuses,
|
||||
listHref,
|
||||
labels,
|
||||
locale,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
seed: Page
|
||||
locale: string
|
||||
statuses: Readonly<Record<DocumentView["status"], string>>
|
||||
listHref: string
|
||||
labels: DocumentListLabels
|
||||
}): ReactElement {
|
||||
const date = useMemo(() => new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" }), [locale])
|
||||
const client = useDataClient()
|
||||
const { list } = useResourceList<DocumentView>({
|
||||
seed,
|
||||
rowId: (row) => row.documentId,
|
||||
searchLabel: labels.search,
|
||||
describeError: (error) =>
|
||||
labels.failed.replace("{detail}", serviceNotice(error, labels.refusals)?.text ?? describeError(error)),
|
||||
read: async ({ q, after }) =>
|
||||
(
|
||||
await client.call<Page>("archive.document.list", {
|
||||
...slugs,
|
||||
...(q ? { q } : {}),
|
||||
...(after ? { after } : {}),
|
||||
})
|
||||
).value,
|
||||
})
|
||||
return (
|
||||
<ResourceList
|
||||
{...list}
|
||||
label={labels.caption}
|
||||
empty={{ title: labels.empty }}
|
||||
columns={[
|
||||
{ id: "title", header: labels.title, wrap: true },
|
||||
{ id: "retentionClass", header: labels.retentionClass },
|
||||
{ id: "status", header: labels.status, cell: (row) => statuses[row.status] },
|
||||
{ id: "createdAt", header: labels.created, cell: (row) => date.format(new Date(row.createdAt)) },
|
||||
]}
|
||||
rowHref={(row) => `${listHref}/${encodeURIComponent(row.documentId)}`}
|
||||
rowLinkLabel={(row) => labels.open.replace("{title}", row.title)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// The archive module's pages and components. `archivePage` is what the web shell mounts; the rest is
|
||||
// exported for an app that composes its own page from the parts.
|
||||
|
||||
export { DocumentActions, type DocumentActionsLabels } from "./document-actions.js"
|
||||
export { DocumentList, type DocumentListLabels } from "./document-list.js"
|
||||
export {
|
||||
documentActionsLabels,
|
||||
documentListLabels,
|
||||
registerFormLabels,
|
||||
retentionFormLabels,
|
||||
retentionLabels,
|
||||
type Translate,
|
||||
versionFormLabels,
|
||||
versionsLabels,
|
||||
} from "./labels.js"
|
||||
export { type ArchivePage, archivePage, serviceState } from "./pages.js"
|
||||
export { RegisterForm, type RegisterFormLabels } from "./register-form.js"
|
||||
export { RetentionClasses, type RetentionLabels } from "./retention-classes.js"
|
||||
export { RetentionForm, type RetentionFormLabels } from "./retention-form.js"
|
||||
export { VersionForm, type VersionFormLabels } from "./version-form.js"
|
||||
export { Versions, type VersionsLabels } from "./versions.js"
|
||||
@@ -0,0 +1,125 @@
|
||||
// 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 (`documents.title`); the page binds it to
|
||||
// `i18n.message("archive." + key)`. The app's `app.failed` is passed in already looked up, and so is
|
||||
// `refusals`, the kit's sentence for each business refusal by problem code (`businessRefusalNotices`).
|
||||
|
||||
import { RETENTION_BASES, type RetentionClassView } from "../contract/index.js"
|
||||
import type { DocumentActionsLabels } from "./document-actions.js"
|
||||
import type { DocumentListLabels } from "./document-list.js"
|
||||
import type { RegisterFormLabels } from "./register-form.js"
|
||||
import type { RetentionLabels } from "./retention-classes.js"
|
||||
import type { RetentionFormLabels } from "./retention-form.js"
|
||||
import type { VersionFormLabels } from "./version-form.js"
|
||||
import type { VersionsLabels } from "./versions.js"
|
||||
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
/** The kit's sentence for each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
|
||||
export type Refusals = Readonly<Record<string, string>>
|
||||
|
||||
export const baseNames = (t: Translate): Readonly<Record<RetentionClassView["from"], string>> =>
|
||||
Object.fromEntries(RETENTION_BASES.map((base) => [base, t(`retention.bases.${base}`)])) as Record<
|
||||
RetentionClassView["from"],
|
||||
string
|
||||
>
|
||||
|
||||
export function documentListLabels(t: Translate, failed: string, refusals: Refusals): DocumentListLabels {
|
||||
return {
|
||||
caption: t("documents.caption"),
|
||||
empty: t("documents.empty"),
|
||||
title: t("documents.title"),
|
||||
retentionClass: t("documents.retentionClass"),
|
||||
status: t("documents.status"),
|
||||
created: t("documents.created"),
|
||||
// The placeholder is the component's to fill, so it is passed through untouched.
|
||||
open: t("documents.open", { title: "{title}" }),
|
||||
search: t("documents.search"),
|
||||
searchSubmit: t("documents.searchSubmit"),
|
||||
more: t("documents.more"),
|
||||
failed,
|
||||
refusals,
|
||||
}
|
||||
}
|
||||
|
||||
export function registerFormLabels(t: Translate, failed: string, refusals: Refusals): RegisterFormLabels {
|
||||
return {
|
||||
heading: t("register.heading"),
|
||||
title: t("register.title"),
|
||||
kind: t("register.kind"),
|
||||
kindHint: t("register.kindHint"),
|
||||
retentionClass: t("register.retentionClass"),
|
||||
file: t("register.file"),
|
||||
submit: t("register.submit"),
|
||||
busy: t("register.busy"),
|
||||
noClass: t("register.noClass"),
|
||||
failed,
|
||||
refusals,
|
||||
}
|
||||
}
|
||||
|
||||
export function versionsLabels(t: Translate, count: number): VersionsLabels {
|
||||
return {
|
||||
title: t("versions.title"),
|
||||
count: t("versions.count", { count }),
|
||||
caption: t("versions.caption"),
|
||||
sequence: t("versions.sequence"),
|
||||
mediaType: t("versions.mediaType"),
|
||||
size: t("versions.size"),
|
||||
hash: t("versions.hash"),
|
||||
created: t("versions.created"),
|
||||
}
|
||||
}
|
||||
|
||||
export function versionFormLabels(t: Translate, failed: string, refusals: Refusals): VersionFormLabels {
|
||||
return {
|
||||
heading: t("versionForm.heading"),
|
||||
file: t("versionForm.file"),
|
||||
submit: t("versionForm.submit"),
|
||||
busy: t("versionForm.busy"),
|
||||
failed,
|
||||
refusals,
|
||||
}
|
||||
}
|
||||
|
||||
export function documentActionsLabels(t: Translate, failed: string, refusals: Refusals): DocumentActionsLabels {
|
||||
return {
|
||||
heading: t("actions.heading"),
|
||||
hold: t("actions.hold"),
|
||||
release: t("actions.release"),
|
||||
dispose: t("actions.dispose"),
|
||||
disposeConfirm: t("actions.disposeConfirm"),
|
||||
busy: t("actions.busy"),
|
||||
failed,
|
||||
refusals,
|
||||
}
|
||||
}
|
||||
|
||||
export function retentionLabels(t: Translate): RetentionLabels {
|
||||
return {
|
||||
caption: t("retention.caption"),
|
||||
empty: t("retention.empty"),
|
||||
code: t("retention.code"),
|
||||
keepFor: t("retention.keepFor"),
|
||||
from: t("retention.from"),
|
||||
dueCaption: t("retention.dueCaption"),
|
||||
dueEmpty: t("retention.dueEmpty"),
|
||||
}
|
||||
}
|
||||
|
||||
export function retentionFormLabels(t: Translate, failed: string, refusals: Refusals): RetentionFormLabels {
|
||||
return {
|
||||
heading: t("retentionForm.heading"),
|
||||
code: t("retentionForm.code"),
|
||||
keepFor: t("retentionForm.keepFor"),
|
||||
keepForHint: t("retentionForm.keepForHint"),
|
||||
from: t("retentionForm.from"),
|
||||
submit: t("retentionForm.submit"),
|
||||
busy: t("retentionForm.busy"),
|
||||
bases: baseNames(t),
|
||||
failed,
|
||||
refusals,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// The archive 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>/archive`, and the view of what it returned.
|
||||
//
|
||||
// /archive the organization's documents, and the form that registers one
|
||||
// /archive/retention the retention classes, what is due for disposal, and the form that adds a class
|
||||
// /archive/<documentId> one document: its versions, the form that adds one, its legal hold and disposal
|
||||
//
|
||||
// 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.
|
||||
|
||||
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 {
|
||||
ARCHIVE_PERMISSIONS,
|
||||
type DocumentView,
|
||||
disposalList,
|
||||
documentGet,
|
||||
documentList,
|
||||
type RetentionClassView,
|
||||
retentionList,
|
||||
type VersionView,
|
||||
} from "../contract/index.js"
|
||||
import { DocumentActions, type DocumentActionsLabels } from "./document-actions.js"
|
||||
import { DocumentList, type DocumentListLabels } from "./document-list.js"
|
||||
import {
|
||||
baseNames,
|
||||
documentActionsLabels,
|
||||
documentListLabels,
|
||||
registerFormLabels,
|
||||
retentionFormLabels,
|
||||
retentionLabels,
|
||||
type Translate,
|
||||
versionFormLabels,
|
||||
versionsLabels,
|
||||
} from "./labels.js"
|
||||
import { RegisterForm, type RegisterFormLabels } from "./register-form.js"
|
||||
import { RetentionClasses, type RetentionLabels } from "./retention-classes.js"
|
||||
import { RetentionForm, type RetentionFormLabels } from "./retention-form.js"
|
||||
import { VersionForm, type VersionFormLabels } from "./version-form.js"
|
||||
import { Versions, type VersionsLabels } from "./versions.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<DocumentView["status"], string>>
|
||||
|
||||
export interface ArchiveListData {
|
||||
readonly kind: "list"
|
||||
readonly title: string
|
||||
readonly intro: string
|
||||
readonly retentionLink: string
|
||||
readonly form: RegisterFormLabels
|
||||
readonly list: DocumentListLabels
|
||||
readonly statuses: StatusNames
|
||||
readonly classes: readonly RetentionClassView[]
|
||||
readonly documents: { rows: DocumentView[]; after?: string | undefined }
|
||||
}
|
||||
|
||||
export interface ArchiveDocumentData {
|
||||
readonly kind: "document"
|
||||
readonly title: string
|
||||
readonly back: string
|
||||
readonly document: DocumentView
|
||||
readonly status: string
|
||||
readonly versions: readonly VersionView[]
|
||||
readonly labels: {
|
||||
readonly retentionClass: string
|
||||
readonly status: string
|
||||
readonly revision: string
|
||||
readonly created: string
|
||||
readonly hold: string
|
||||
readonly held: string
|
||||
readonly disposedAt: string
|
||||
readonly versions: VersionsLabels
|
||||
readonly versionForm: VersionFormLabels
|
||||
readonly actions: DocumentActionsLabels
|
||||
}
|
||||
}
|
||||
|
||||
export interface ArchiveRetentionData {
|
||||
readonly kind: "retention"
|
||||
readonly title: string
|
||||
readonly intro: string
|
||||
readonly back: string
|
||||
readonly list: RetentionLabels
|
||||
readonly form: RetentionFormLabels
|
||||
readonly bases: Readonly<Record<RetentionClassView["from"], string>>
|
||||
readonly classes: readonly RetentionClassView[]
|
||||
readonly due: readonly DocumentView[]
|
||||
}
|
||||
|
||||
/** The service cannot answer: the notice in place of the page. */
|
||||
export interface ArchiveStateData {
|
||||
readonly kind: "state"
|
||||
readonly title: string
|
||||
readonly notice: ServiceNotice
|
||||
}
|
||||
|
||||
export type ArchivePageData = ArchiveListData | ArchiveDocumentData | ArchiveRetentionData | ArchiveStateData
|
||||
|
||||
export type ArchivePage = ProjectPage<TransportCaller, I18n, Container, ArchivePageData>
|
||||
|
||||
/** A document id as the service mints it: one path segment. */
|
||||
const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
|
||||
|
||||
/** The document a path segment names; a segment that does not decode names none. */
|
||||
function documentIdOf(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"),
|
||||
disposed: t("statuses.disposed"),
|
||||
})
|
||||
|
||||
export const archivePage: ArchivePage = projectPage({
|
||||
async load({ slugs, segments, caller, i18n }): Promise<ArchivePageData> {
|
||||
const t: Translate = (key, values) => i18n.message(`archive.${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 documentId = segment === undefined || segment === "retention" ? undefined : documentIdOf(segment)
|
||||
if (documentId !== undefined && !DOCUMENT_ID.test(documentId)) throw notFound("no such page")
|
||||
|
||||
try {
|
||||
if (segment === "retention") {
|
||||
const [classes, due] = await Promise.all([caller.call(retentionList, slugs), caller.call(disposalList, slugs)])
|
||||
return {
|
||||
kind: "retention",
|
||||
title: t("retention.title"),
|
||||
intro: t("retention.intro"),
|
||||
back: t("nav.documents"),
|
||||
list: retentionLabels(t),
|
||||
form: retentionFormLabels(t, failed, refusals),
|
||||
bases: baseNames(t),
|
||||
classes: classes.rows,
|
||||
due: due.rows,
|
||||
}
|
||||
}
|
||||
if (documentId === undefined) {
|
||||
const [documents, classes] = await Promise.all([
|
||||
caller.call(documentList, slugs),
|
||||
caller.call(retentionList, slugs),
|
||||
])
|
||||
return {
|
||||
kind: "list",
|
||||
title,
|
||||
intro: t("intro"),
|
||||
retentionLink: t("nav.retention"),
|
||||
form: registerFormLabels(t, failed, refusals),
|
||||
list: documentListLabels(t, failed, refusals),
|
||||
statuses: statusNames(t),
|
||||
classes: classes.rows,
|
||||
documents,
|
||||
}
|
||||
}
|
||||
const { document, versions } = await caller.call(documentGet, { ...slugs, documentId })
|
||||
return {
|
||||
kind: "document",
|
||||
title,
|
||||
back: t("document.back"),
|
||||
document,
|
||||
status: statusNames(t)[document.status],
|
||||
versions,
|
||||
labels: {
|
||||
retentionClass: t("document.retentionClass"),
|
||||
status: t("document.status"),
|
||||
revision: t("document.revision"),
|
||||
created: t("document.created"),
|
||||
hold: t("document.hold"),
|
||||
held: document.legalHold ? t("document.yes") : t("document.no"),
|
||||
disposedAt: t("document.disposedAt"),
|
||||
versions: versionsLabels(t, versions.length),
|
||||
versionForm: versionFormLabels(t, failed, refusals),
|
||||
actions: documentActionsLabels(t, failed, refusals),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
const notice = serviceState(error, i18n.locale)
|
||||
if (!notice) throw error
|
||||
return { kind: "state", title, notice }
|
||||
}
|
||||
},
|
||||
|
||||
View({ slugs, data, locale }: ProjectPageViewProps<ArchivePageData>): ReactElement {
|
||||
const listHref = `/${slugs.org}/${slugs.workspace}/${slugs.project}/archive`
|
||||
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}/retention`}>{data.retentionLink}</a>
|
||||
</p>
|
||||
<Can permission={ARCHIVE_PERMISSIONS.write}>
|
||||
<RegisterForm slugs={slugs} classes={data.classes} listHref={listHref} labels={data.form} />
|
||||
</Can>
|
||||
<DocumentList
|
||||
locale={locale}
|
||||
slugs={slugs}
|
||||
seed={data.documents}
|
||||
statuses={data.statuses}
|
||||
listHref={listHref}
|
||||
labels={data.list}
|
||||
/>
|
||||
</>
|
||||
) : data.kind === "retention" ? (
|
||||
<>
|
||||
<p>
|
||||
<a href={listHref}>{data.back}</a>
|
||||
</p>
|
||||
<p>{data.intro}</p>
|
||||
<Can permission={ARCHIVE_PERMISSIONS.manage}>
|
||||
<RetentionForm slugs={slugs} labels={data.form} />
|
||||
</Can>
|
||||
<RetentionClasses
|
||||
classes={data.classes}
|
||||
due={data.due}
|
||||
bases={data.bases}
|
||||
listHref={listHref}
|
||||
labels={data.list}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
<a href={listHref}>{data.back}</a>
|
||||
</p>
|
||||
<h2>{data.document.title}</h2>
|
||||
<dl>
|
||||
<dt>{data.labels.status}</dt>
|
||||
<dd>{data.status}</dd>
|
||||
<dt>{data.labels.retentionClass}</dt>
|
||||
<dd>{data.document.retentionClass}</dd>
|
||||
<dt>{data.labels.created}</dt>
|
||||
<dd>{data.document.createdAt.slice(0, 10)}</dd>
|
||||
<dt>{data.labels.revision}</dt>
|
||||
<dd>{data.document.revision}</dd>
|
||||
<dt>{data.labels.hold}</dt>
|
||||
<dd>{data.labels.held}</dd>
|
||||
{data.document.disposedAt === null ? null : (
|
||||
<>
|
||||
<dt>{data.labels.disposedAt}</dt>
|
||||
<dd>{data.document.disposedAt.slice(0, 10)}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
<Versions versions={data.versions} labels={data.labels.versions} />
|
||||
{data.document.disposed ? null : (
|
||||
<>
|
||||
<Can permission={ARCHIVE_PERMISSIONS.write}>
|
||||
<VersionForm
|
||||
slugs={slugs}
|
||||
documentId={data.document.documentId}
|
||||
revision={data.document.revision}
|
||||
labels={data.labels.versionForm}
|
||||
/>
|
||||
</Can>
|
||||
<Can permission={ARCHIVE_PERMISSIONS.manage}>
|
||||
<DocumentActions
|
||||
slugs={slugs}
|
||||
documentId={data.document.documentId}
|
||||
revision={data.document.revision}
|
||||
legalHold={data.document.legalHold}
|
||||
labels={data.labels.actions}
|
||||
/>
|
||||
</Can>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
|
||||
// Registers a document: the browser stores the file through the files module, then asks this module to
|
||||
// register it, which takes the version's hash, size and type from the stored file. Then it opens the
|
||||
// document.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useUpload, useWrite } from "@conusai-kit/ui/data"
|
||||
import { SelectField, serviceNotice, usePageNavigation } 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 { RetentionClassView } from "../contract/index.js"
|
||||
|
||||
export interface RegisterFormLabels {
|
||||
readonly heading: string
|
||||
readonly title: string
|
||||
readonly kind: string
|
||||
readonly kindHint: string
|
||||
readonly retentionClass: string
|
||||
readonly file: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly noClass: 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>>
|
||||
}
|
||||
|
||||
export function RegisterForm({
|
||||
slugs,
|
||||
classes,
|
||||
listHref,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
classes: readonly RetentionClassView[]
|
||||
listHref: string
|
||||
labels: RegisterFormLabels
|
||||
}): ReactElement {
|
||||
const navigation = usePageNavigation()
|
||||
const upload = useUpload()
|
||||
const register = useWrite<Record<string, unknown>, { documentId: string }>("archive.document.register")
|
||||
const [requestId] = useState(() => crypto.randomUUID())
|
||||
const [state, setState] = useState<{ kind: "idle" | "busy" } | { kind: "failed"; detail: string }>({ kind: "idle" })
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const form = new FormData(event.currentTarget)
|
||||
const file = form.get("file")
|
||||
if (!(file instanceof File) || file.size === 0) return
|
||||
const kind = String(form.get("kind") ?? "").trim()
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
const stored = await upload(slugs, file)
|
||||
const created = await register.mutate({
|
||||
...slugs,
|
||||
title: String(form.get("title") ?? "").trim(),
|
||||
...(kind ? { kind } : {}),
|
||||
retentionClass: String(form.get("retentionClass") ?? ""),
|
||||
fileId: stored.fileId,
|
||||
requestId,
|
||||
})
|
||||
navigation.navigate(`${listHref}/${encodeURIComponent(created.documentId)}`)
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: serviceNotice(error, labels.refusals)?.text ?? describeError(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return classes.length === 0 ? (
|
||||
<p>{labels.noClass}</p>
|
||||
) : (
|
||||
<form onSubmit={submit} aria-busy={state.kind === "busy"}>
|
||||
<h2>{labels.heading}</h2>
|
||||
<Field>
|
||||
<FieldLabel>{labels.title}</FieldLabel>
|
||||
<Input name="title" type="text" required maxLength={200} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{labels.kind}</FieldLabel>
|
||||
<Input name="kind" type="text" pattern="[0-9]{1,4}" />
|
||||
<FieldDescription>{labels.kindHint}</FieldDescription>
|
||||
</Field>
|
||||
<SelectField
|
||||
name="retentionClass"
|
||||
label={labels.retentionClass}
|
||||
required
|
||||
defaultValue={classes[0]?.code}
|
||||
options={classes.map((retention) => ({ value: retention.code, label: retention.code }))}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel>{labels.file}</FieldLabel>
|
||||
<Input name="file" type="file" required />
|
||||
</Field>
|
||||
<Button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.busy : labels.submit}
|
||||
</Button>
|
||||
{state.kind === "failed" ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{labels.failed.replace("{detail}", state.detail)}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@conusai-kit/ui/primitives"
|
||||
// The retention classes, and the documents due for disposal. A server component: it holds no state, and
|
||||
// every string is passed in.
|
||||
|
||||
import type { ReactElement } from "react"
|
||||
import type { DocumentView, RetentionClassView } from "../contract/index.js"
|
||||
|
||||
export interface RetentionLabels {
|
||||
readonly caption: string
|
||||
readonly empty: string
|
||||
readonly code: string
|
||||
readonly keepFor: string
|
||||
readonly from: string
|
||||
readonly dueCaption: string
|
||||
readonly dueEmpty: string
|
||||
}
|
||||
|
||||
export function RetentionClasses({
|
||||
classes,
|
||||
due,
|
||||
bases,
|
||||
listHref,
|
||||
labels,
|
||||
}: {
|
||||
classes: readonly RetentionClassView[]
|
||||
due: readonly DocumentView[]
|
||||
bases: Readonly<Record<RetentionClassView["from"], string>>
|
||||
listHref: string
|
||||
labels: RetentionLabels
|
||||
}): ReactElement {
|
||||
return (
|
||||
<>
|
||||
{classes.length === 0 ? (
|
||||
<p>{labels.empty}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableCaption>{labels.caption}</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">{labels.code}</TableHead>
|
||||
<TableHead scope="col">{labels.keepFor}</TableHead>
|
||||
<TableHead scope="col">{labels.from}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{classes.map((retention) => (
|
||||
<TableRow key={retention.code}>
|
||||
<TableHead scope="row">{retention.code}</TableHead>
|
||||
<TableCell>{retention.keepFor}</TableCell>
|
||||
<TableCell>{bases[retention.from]}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
{due.length === 0 ? (
|
||||
<p>{labels.dueEmpty}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableCaption>{labels.dueCaption}</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">{labels.code}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{due.map((document) => (
|
||||
<TableRow key={document.documentId}>
|
||||
<TableHead scope="row">
|
||||
<a href={`${listHref}/${encodeURIComponent(document.documentId)}`}>{document.title}</a>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
|
||||
// Adds a retention class. A class never changes once it exists: a new policy is a new class.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useWrite } from "@conusai-kit/ui/data"
|
||||
import { SelectField, serviceNotice, usePageNavigation } 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 { RETENTION_BASES, type RetentionClassView } from "../contract/index.js"
|
||||
|
||||
export interface RetentionFormLabels {
|
||||
readonly heading: string
|
||||
readonly code: string
|
||||
readonly keepFor: string
|
||||
readonly keepForHint: string
|
||||
readonly from: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly bases: Readonly<Record<RetentionClassView["from"], 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>>
|
||||
}
|
||||
|
||||
export function RetentionForm({ slugs, labels }: { slugs: ProjectSlugs; labels: RetentionFormLabels }): ReactElement {
|
||||
const navigation = usePageNavigation()
|
||||
const create = useWrite<Record<string, unknown>, unknown>("archive.retention.create")
|
||||
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
|
||||
const [state, setState] = useState<{ kind: "idle" | "busy" } | { kind: "failed"; detail: string }>({ kind: "idle" })
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const form = new FormData(event.currentTarget)
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
await create.mutate({
|
||||
...slugs,
|
||||
code: String(form.get("code") ?? "").trim(),
|
||||
keepFor: String(form.get("keepFor") ?? "").trim(),
|
||||
from: String(form.get("from") ?? "created"),
|
||||
requestId,
|
||||
})
|
||||
await navigation.refresh()
|
||||
setRequestId(crypto.randomUUID())
|
||||
setState({ kind: "idle" })
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: serviceNotice(error, labels.refusals)?.text ?? describeError(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form key={requestId} onSubmit={submit} aria-busy={state.kind === "busy"}>
|
||||
<h2>{labels.heading}</h2>
|
||||
<Field>
|
||||
<FieldLabel>{labels.code}</FieldLabel>
|
||||
<Input name="code" type="text" required maxLength={64} pattern="[A-Za-z0-9_\-]+" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{labels.keepFor}</FieldLabel>
|
||||
<Input name="keepFor" type="text" required maxLength={64} />
|
||||
<FieldDescription>{labels.keepForHint}</FieldDescription>
|
||||
</Field>
|
||||
<SelectField
|
||||
name="from"
|
||||
label={labels.from}
|
||||
defaultValue="created"
|
||||
options={RETENTION_BASES.map((base) => ({ value: base, label: labels.bases[base] }))}
|
||||
/>
|
||||
<Button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.busy : labels.submit}
|
||||
</Button>
|
||||
{state.kind === "failed" ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{labels.failed.replace("{detail}", state.detail)}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
|
||||
// Adds a version: the browser stores the file through the files module, then asks this module to append
|
||||
// it over the revision the page read. A version nobody else has overtaken lands; a stale one is refused.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { describeError, useUpload, useWrite } from "@conusai-kit/ui/data"
|
||||
import { serviceNotice, usePageNavigation } from "@conusai-kit/ui/patterns"
|
||||
import { Alert, AlertDescription, Field, FieldLabel, Input } from "@conusai-kit/ui/primitives"
|
||||
import { type FormEvent, type ReactElement, useState } from "react"
|
||||
|
||||
export interface VersionFormLabels {
|
||||
readonly heading: string
|
||||
readonly file: string
|
||||
readonly submit: string
|
||||
readonly busy: 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>>
|
||||
}
|
||||
|
||||
export function VersionForm({
|
||||
slugs,
|
||||
documentId,
|
||||
revision,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
documentId: string
|
||||
revision: string
|
||||
labels: VersionFormLabels
|
||||
}): ReactElement {
|
||||
const navigation = usePageNavigation()
|
||||
const upload = useUpload()
|
||||
const add = useWrite<Record<string, unknown>, unknown>("archive.version.add")
|
||||
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
|
||||
const [state, setState] = useState<{ kind: "idle" | "busy" } | { kind: "failed"; detail: string }>({ 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
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
const stored = await upload(slugs, file)
|
||||
await add.mutate({ ...slugs, documentId, revision, fileId: stored.fileId, requestId })
|
||||
await navigation.refresh()
|
||||
setRequestId(crypto.randomUUID())
|
||||
setState({ kind: "idle" })
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: serviceNotice(error, labels.refusals)?.text ?? describeError(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form key={requestId} onSubmit={submit} aria-busy={state.kind === "busy"}>
|
||||
<h2>{labels.heading}</h2>
|
||||
<Field>
|
||||
<FieldLabel>{labels.file}</FieldLabel>
|
||||
<Input name="file" type="file" required />
|
||||
</Field>
|
||||
<Button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.busy : labels.submit}
|
||||
</Button>
|
||||
{state.kind === "failed" ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{labels.failed.replace("{detail}", state.detail)}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@conusai-kit/ui/primitives"
|
||||
// A document's versions. A server component: it holds no state, and every string is passed in.
|
||||
|
||||
import type { ReactElement } from "react"
|
||||
import type { VersionView } from "../contract/index.js"
|
||||
|
||||
export interface VersionsLabels {
|
||||
readonly title: string
|
||||
/** How many versions, already filled with the plural form. */
|
||||
readonly count: string
|
||||
readonly caption: string
|
||||
readonly sequence: string
|
||||
readonly mediaType: string
|
||||
readonly size: string
|
||||
readonly hash: string
|
||||
readonly created: string
|
||||
}
|
||||
|
||||
export function Versions({
|
||||
versions,
|
||||
labels,
|
||||
}: {
|
||||
versions: readonly VersionView[]
|
||||
labels: VersionsLabels
|
||||
}): ReactElement {
|
||||
return (
|
||||
<section>
|
||||
<h2>{labels.title}</h2>
|
||||
<p>{labels.count}</p>
|
||||
<Table>
|
||||
<TableCaption>{labels.caption}</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">{labels.sequence}</TableHead>
|
||||
<TableHead scope="col">{labels.mediaType}</TableHead>
|
||||
<TableHead scope="col">{labels.size}</TableHead>
|
||||
<TableHead scope="col">{labels.hash}</TableHead>
|
||||
<TableHead scope="col">{labels.created}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{versions.map((version) => (
|
||||
<TableRow key={version.versionId}>
|
||||
<TableHead scope="row">{version.sequence}</TableHead>
|
||||
<TableCell>{version.mediaType}</TableCell>
|
||||
<TableCell>{version.size}</TableCell>
|
||||
<TableCell>
|
||||
<code>{version.sha256.slice(0, 12)}</code>
|
||||
</TableCell>
|
||||
<TableCell>{version.createdAt.slice(0, 10)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user