159 lines
6.0 KiB
TypeScript
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>
|
|
)
|
|
},
|
|
})
|