@@ -0,0 +1,171 @@
|
||||
// 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(" · "),
|
||||
),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user