Files
2026-11-14 09:56:45 +00:00

159 lines
6.0 KiB
TypeScript

// The crm 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>/crm`, and the view of what it returned.
//
// /crm the organization's parties, and the form that adds one
// /crm/<partyId> one party: how to reach it, and the form that adds a contact point
//
// 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,
type ServiceNotice,
ServiceStatePage,
serviceNotice,
} from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
import { type ContactView, type PartyView, partyGet, partyList } from "../contract/index.js"
import { ContactForm, type ContactFormLabels } from "./contact-form.js"
import { Contacts, type ContactsLabels } from "./contacts.js"
import { contactFormLabels, contactsLabels, partyFormLabels, partyListLabels, type Translate } from "./labels.js"
import { PartyForm, type PartyFormLabels } from "./party-form.js"
import { PartyList, type PartyListLabels } from "./party-list.js"
/** The parties, and the form that adds one. */
export interface CrmListData {
readonly kind: "list"
readonly title: string
readonly intro: string
readonly form: PartyFormLabels
readonly list: PartyListLabels
readonly parties: { rows: PartyView[]; after?: string | undefined }
}
/** One party, its kind and country already in a sentence. */
export interface CrmPartyData {
readonly kind: "party"
readonly title: string
readonly back: string
readonly partyId: string
readonly displayName: string
readonly summary: string
/** The note that the party is a member's account, when it is. */
readonly linked?: string
readonly contacts: readonly ContactView[]
readonly contactLabels: ContactsLabels
readonly form: ContactFormLabels
}
/** The service cannot answer: the notice in place of the page. */
export interface CrmStateData {
readonly kind: "state"
readonly title: string
readonly notice: ServiceNotice
}
export type CrmPageData = CrmListData | CrmPartyData | CrmStateData
export type CrmPage = ProjectPage<TransportCaller, I18n, Container, CrmPageData>
/** A party id as the service mints it: one path segment. */
const PARTY_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/
/** The party a path segment names; a segment that does not decode names none. */
function partyIdOf(segment: string): string {
try {
return decodeURIComponent(segment)
} catch {
throw notFound("no such page")
}
}
export const crmPage: CrmPage = projectPage({
async load({ slugs, segments, caller, i18n }): Promise<CrmPageData> {
const t: Translate = (key, values) => i18n.message(`crm.${key}`, values)
const title = t("title")
const refusals = businessRefusalNotices(i18n.locale)
const [segment, ...rest] = segments
const partyId = segment === undefined ? undefined : partyIdOf(segment)
if (rest.length > 0 || (partyId !== undefined && !PARTY_ID.test(partyId))) throw notFound("no such page")
try {
if (partyId === undefined) {
return {
kind: "list",
title,
intro: t("intro"),
form: partyFormLabels(t, refusals),
list: partyListLabels(t),
parties: await caller.call(partyList, slugs),
}
}
const { party, contacts } = await caller.call(partyGet, { ...slugs, partyId })
return {
kind: "party",
title,
back: t("party.back"),
partyId: party.partyId,
displayName: party.displayName,
summary: `${t(`kinds.${party.kind}`)}, ${party.country}${party.legalName ? `, ${party.legalName}` : ""}`,
...(party.linked ? { linked: t("party.linked") } : {}),
contacts,
contactLabels: contactsLabels(t, contacts.length),
form: contactFormLabels(t, refusals),
}
} catch (error) {
const notice = serviceNotice(error, businessServiceNotices(i18n.locale))
if (!notice) throw error
return { kind: "state", title, notice }
}
},
View({ slugs, data }: ProjectPageViewProps<CrmPageData>): ReactElement {
const listHref = `/${slugs.org}/${slugs.workspace}/${slugs.project}/crm`
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="crm.party.write">
<PartyForm slugs={slugs} listHref={listHref} labels={data.form} />
</Can>
}
>
<PartyList slugs={slugs} seed={data.parties} listHref={listHref} labels={data.list} />
</DetailLayout>
</Page>
)
}
return (
<Page>
<PageHeader title={data.displayName} description={data.summary} breadcrumb={<a href={listHref}>{data.back}</a>}>
{data.linked ? <span>{data.linked}</span> : null}
</PageHeader>
<DetailLayout
aside={
<Can permission="crm.party.write">
<ContactForm slugs={slugs} partyId={data.partyId} labels={data.form} />
</Can>
}
>
<Contacts contacts={data.contacts} labels={data.contactLabels} />
</DetailLayout>
</Page>
)
},
})