Files
template-app/modules/invoicing/ui/pages.tsx
T
2026-11-14 09:56:45 +00:00

172 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import { businessRefusalNotices, businessServiceNotices } from "@conusai-kit/business/refusals"
import { type Container, notFound, serviceRefusal, type TransportCaller } from "@conusai-kit/core"
import type { I18n } from "@conusai-kit/i18n"
import { Decimal, Money } from "@conusai-kit/money"
import { Quantity } from "@conusai-kit/money/units"
import { type ProjectPage, projectPage } from "@conusai-kit/ui"
import { Can } from "@conusai-kit/ui/data"
import {
FactList,
Page,
PageHeader,
PageSection,
type ServiceNotice,
ServiceStatePage,
serviceNotice,
} from "@conusai-kit/ui/patterns"
import * as contracts from "../contract/index.js"
import { en } from "../i18n/en.js"
import { FiscalAction, InvoiceForm, Invoices, SeriesForm, UblDownload } from "./controls.js"
import type { Labels, Messages } from "./labels.js"
type Data = { labels: Labels } & (
| {
kind: "list"
invoices: { rows: contracts.InvoiceView[]; after?: string | undefined }
series: contracts.SeriesView[]
}
| {
kind: "invoice"
invoice: contracts.InvoiceView
series: contracts.SeriesView[]
/** The amounts, quantities and rates as the reader writes them, formatted on the server. */
shown: { payable: string; lines: string[] }
}
| { kind: "state"; notice: ServiceNotice }
)
export const invoicingPage: ProjectPage<TransportCaller, I18n, Container, Data> = projectPage({
async load({ slugs, segments, caller, i18n }) {
const messages = Object.fromEntries(
Object.keys(en).map((key) => [key, i18n.message(`invoicing.${key}`)]),
) as Messages
const labels: Labels = {
...messages,
locale: i18n.locale,
refusals: businessRefusalNotices(i18n.locale, {
conflict: messages.conflict,
revision_conflict: messages.conflict,
invalid: messages.invalid,
}),
}
const [id, ...rest] = segments
if (rest.length || (id !== undefined && !contracts.invoiceId.safeParse(id).success)) throw notFound()
try {
const allSeries: contracts.SeriesView[] = []
let after: string | undefined
const seen = new Set<string>()
let pages = 0
// Numbering choices are bounded; refusal is preferable to silently missing a series.
do {
if (pages++ >= 20) throw serviceRefusal("degraded")
const page = await caller.call(contracts.series, { org: slugs.org, ...(after ? { after } : {}) })
allSeries.push(...page.rows)
after = page.after
if (allSeries.length > 1000 || (after && seen.has(after))) throw serviceRefusal("degraded")
if (after) seen.add(after)
} while (after)
if (id === undefined)
return {
kind: "list",
labels,
invoices: await caller.call(contracts.invoices, { org: slugs.org }),
series: allSeries,
}
const invoice = await caller.call(contracts.invoice, { org: slugs.org, invoiceId: id })
return { kind: "invoice", labels, invoice, series: allSeries, shown: shownOf(invoice, labels, i18n) }
} catch (error) {
const notice = serviceNotice(error, businessServiceNotices(i18n.locale))
if (!notice) throw error
return { kind: "state", labels, notice }
}
},
View({ slugs, data }) {
const labels = data.labels
const href = `/${slugs.org}/${slugs.workspace}/${slugs.project}/invoicing`
if (data.kind === "state") return <ServiceStatePage title={labels.title} notice={data.notice} />
if (data.kind === "list")
return (
<Page>
<PageHeader title={labels.title} description={labels.intro} />
<p>{labels.fiscalNotice}</p>
<Invoices slugs={slugs} seed={data.invoices} href={href} labels={labels} />
<Can permission="invoicing.invoice.write">
<InvoiceForm slugs={slugs} labels={labels} series={data.series} href={href} />
</Can>
<Can permission="invoicing.series.manage">
<SeriesForm slugs={slugs} labels={labels} />
</Can>
</Page>
)
const invoice = data.invoice
return (
<Page>
<PageHeader
title={invoice.number ?? labels.draft}
description={`${labels[invoice.status]} · ${data.shown.payable}`}
/>
<a href={href}>{labels.back}</a>
<p>{labels.fiscalNotice}</p>
<PageSection title={labels.saved}>
<FactList
facts={[
{ label: labels.revision, value: invoice.revision },
{ label: labels.seller, value: invoice.data.seller.name },
{ label: labels.buyer, value: invoice.data.buyer.name },
{ label: labels.issueDate, value: invoice.data.issue_date },
{ label: labels.dueDate, value: invoice.data.due_date ?? "—" },
{ label: labels.currency, value: invoice.data.currency },
{ label: labels.buyerReference, value: invoice.data.buyer_reference },
...invoice.data.lines.map((line, index) => ({
label: line.item_name,
value: data.shown.lines[index] ?? "",
})),
{ label: labels.payable, value: data.shown.payable },
]}
/>
</PageSection>
{invoice.status === "draft" ? (
<>
<Can permission="invoicing.invoice.write">
<InvoiceForm slugs={slugs} invoice={invoice} labels={labels} series={data.series} href={href} />
</Can>
<Can permission="invoicing.invoice.issue">
<FiscalAction slugs={slugs} invoice={invoice} operation="issue" series={data.series} labels={labels} />
</Can>
</>
) : null}
{invoice.status === "issued" ? (
<>
<Can permission="invoicing.invoice.credit">
<FiscalAction slugs={slugs} invoice={invoice} operation="credit" series={data.series} labels={labels} />
</Can>
<Can permission="invoicing.invoice.void">
<FiscalAction slugs={slugs} invoice={invoice} operation="void" series={data.series} labels={labels} />
</Can>
</>
) : null}
{invoice.status !== "draft" ? (
<PageSection title={labels.download}>
<UblDownload slugs={slugs} invoice={invoice} labels={labels} />
</PageSection>
) : null}
</Page>
)
},
})
/** Exact values in the reader's form (`12,50 €`, `3 vnt.`, `21 %`), never through a float. */
function shownOf(invoice: contracts.InvoiceView, labels: Labels, i18n: I18n): { payable: string; lines: string[] } {
return {
payable: i18n.money(Money.parse(invoice.payable)),
lines: invoice.data.lines.map((line) =>
[
`${i18n.quantity(Quantity.of(line.quantity, line.unit))} × ${i18n.money(Money.parse(line.net_price))}`,
`${labels.vatCategory}: ${line.vat_category}`,
`${labels.vatRate}: ${line.vat_rate ? i18n.percent(Decimal.parse(line.vat_rate)) : "—"}`,
...(line.exemption_reason ? [line.exemption_reason] : []),
].join(" · "),
),
}
}