@@ -0,0 +1,240 @@
|
||||
"use client"
|
||||
import { formatMoney } from "@conusai-kit/i18n/values"
|
||||
import { Money } from "@conusai-kit/money"
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useDataClient, useWrite } from "@conusai-kit/ui/data"
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import {
|
||||
CheckField,
|
||||
FormLayout,
|
||||
type FormValues,
|
||||
PageSection,
|
||||
ResourceList,
|
||||
SelectField,
|
||||
TextField,
|
||||
usePageNavigation,
|
||||
useResourceList,
|
||||
} from "@conusai-kit/ui/patterns"
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
import { useRef, useState } from "react"
|
||||
import type { InvoiceView, SeriesView } from "../contract/index.js"
|
||||
import { DraftFields } from "./fields.js"
|
||||
import { draftData } from "./form-data.js"
|
||||
import { describeFailure, type Labels } from "./labels.js"
|
||||
|
||||
// A lost response retries the same input/key. Editing terms starts a different operation.
|
||||
function useSubmissionId() {
|
||||
const current = useRef<{ value: string; key: string } | null>(null)
|
||||
return (input: unknown) => {
|
||||
const value = JSON.stringify(input)
|
||||
if (current.current?.value !== value) current.current = { value, key: crypto.randomUUID() }
|
||||
return current.current.key
|
||||
}
|
||||
}
|
||||
export function Invoices({
|
||||
slugs,
|
||||
seed,
|
||||
href,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
seed: { rows: InvoiceView[]; after?: string | undefined }
|
||||
href: string
|
||||
labels: Labels
|
||||
}) {
|
||||
const client = useDataClient()
|
||||
const { list } = useResourceList<InvoiceView>({
|
||||
seed,
|
||||
rowId: (row) => row.invoiceId,
|
||||
read: async ({ after }) =>
|
||||
(
|
||||
await client.call<{ rows: InvoiceView[]; after?: string }>("invoicing.invoice.list", {
|
||||
org: slugs.org,
|
||||
...(after ? { after } : {}),
|
||||
})
|
||||
).value,
|
||||
})
|
||||
return (
|
||||
<ResourceList
|
||||
{...list}
|
||||
label={labels.title}
|
||||
columns={[
|
||||
{ id: "number", header: labels.number, cell: (row) => row.number ?? labels.draft },
|
||||
{ id: "status", header: labels.status, cell: (row) => labels[row.status] },
|
||||
{ id: "payable", header: labels.payable, cell: (row) => formatMoney(Money.parse(row.payable), labels.locale) },
|
||||
]}
|
||||
rowHref={(row) => `${href}/${row.invoiceId}`}
|
||||
rowLabel={(row) => row.number ?? labels.draft}
|
||||
empty={{ title: labels.empty }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export function InvoiceForm({
|
||||
slugs,
|
||||
invoice,
|
||||
series,
|
||||
labels,
|
||||
href,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
invoice?: InvoiceView
|
||||
series: SeriesView[]
|
||||
labels: Labels
|
||||
href: string
|
||||
}) {
|
||||
const navigation = usePageNavigation()
|
||||
const [count, setCount] = useState(invoice?.data.lines.length ?? 1)
|
||||
const key = useSubmissionId()
|
||||
const write = useWrite<Record<string, unknown>, InvoiceView>(
|
||||
invoice ? "invoicing.invoice.update" : "invoicing.invoice.draft",
|
||||
)
|
||||
const submit = async (values: FormValues) => {
|
||||
const data = draftData(values, count, labels.locale, invoice?.data)
|
||||
const input = {
|
||||
org: slugs.org,
|
||||
data,
|
||||
...(invoice ? { invoiceId: invoice.invoiceId, expectedVersion: invoice.revision } : {}),
|
||||
}
|
||||
const saved = await write.mutate({ ...input, requestId: key(input) })
|
||||
navigation.navigate(`${href}/${saved.invoiceId}`)
|
||||
}
|
||||
if (!series.some((row) => row.active && row.kind === (invoice?.data.type_code === "381" ? "credit_note" : "invoice")))
|
||||
return <p>{labels.missingSeries}</p>
|
||||
return (
|
||||
<PageSection title={invoice ? labels.save : labels.create} variant="card">
|
||||
<FormLayout
|
||||
describeError={(error) => describeFailure(error, labels)}
|
||||
submitLabel={invoice ? labels.save : labels.create}
|
||||
busyLabel={labels.busy}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<DraftFields labels={labels} {...(invoice ? { data: invoice.data } : {})} series={series} count={count} />
|
||||
<Button type="button" onClick={() => setCount((old) => Math.min(old + 1, 200))}>
|
||||
{labels.addLine}
|
||||
</Button>
|
||||
</FormLayout>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
export function FiscalAction({
|
||||
slugs,
|
||||
invoice,
|
||||
operation,
|
||||
series,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
invoice: InvoiceView
|
||||
operation: "issue" | "credit" | "void"
|
||||
series: SeriesView[]
|
||||
labels: Labels
|
||||
}) {
|
||||
const navigation = usePageNavigation()
|
||||
const key = useSubmissionId()
|
||||
const action = useWrite<Record<string, unknown>, InvoiceView>(`invoicing.invoice.${operation}`)
|
||||
const submit = async (values: FormValues) => {
|
||||
const input = {
|
||||
org: slugs.org,
|
||||
invoiceId: invoice.invoiceId,
|
||||
expectedVersion: invoice.revision,
|
||||
confirmed: values.confirmed === "on",
|
||||
...(operation === "credit" ? { seriesId: values.series } : {}),
|
||||
...(operation !== "issue" ? { reason: values.reason } : {}),
|
||||
}
|
||||
const result = await action.mutate({ ...input, requestId: key(input) })
|
||||
if (operation === "credit")
|
||||
navigation.navigate(
|
||||
`/${slugs.org}/${slugs.workspace}/${slugs.project}/invoicing/${encodeURIComponent(result.invoiceId)}`,
|
||||
)
|
||||
else await navigation.refresh()
|
||||
}
|
||||
return (
|
||||
<PageSection title={labels[operation]}>
|
||||
<FormLayout
|
||||
describeError={(error) => describeFailure(error, labels)}
|
||||
submitLabel={labels[operation]}
|
||||
busyLabel={labels.busy}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{operation === "credit" ? (
|
||||
<SelectField
|
||||
name="series"
|
||||
label={labels.series}
|
||||
required
|
||||
options={series
|
||||
.filter((row) => row.active && row.kind === "credit_note")
|
||||
.map((row) => ({ value: row.seriesId, label: row.code }))}
|
||||
/>
|
||||
) : null}
|
||||
{operation !== "issue" ? <TextField name="reason" label={labels.reason} required maxLength={500} /> : null}
|
||||
<CheckField name="confirmed" label={labels.confirm} required />
|
||||
</FormLayout>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
export function SeriesForm({ slugs, labels }: { slugs: ProjectSlugs; labels: Labels }) {
|
||||
const navigation = usePageNavigation()
|
||||
const key = useSubmissionId()
|
||||
const write = useWrite<Record<string, unknown>, SeriesView>("invoicing.series.create")
|
||||
const submit = async (values: FormValues) => {
|
||||
const input = { org: slugs.org, code: values.code, prefix: values.prefix, kind: values.kind }
|
||||
await write.mutate({ ...input, requestId: key(input) })
|
||||
await navigation.refresh()
|
||||
}
|
||||
return (
|
||||
<PageSection title={labels.createSeries}>
|
||||
<FormLayout
|
||||
describeError={(error) => describeFailure(error, labels)}
|
||||
submitLabel={labels.createSeries}
|
||||
busyLabel={labels.busy}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<TextField name="code" label={labels.seriesCode} required maxLength={32} />
|
||||
<TextField name="prefix" label={labels.seriesPrefix} required maxLength={32} />
|
||||
<SelectField
|
||||
name="kind"
|
||||
label={labels.kind}
|
||||
required
|
||||
options={[
|
||||
{ value: "invoice", label: labels.invoiceKind },
|
||||
{ value: "credit_note", label: labels.creditKind },
|
||||
]}
|
||||
/>
|
||||
</FormLayout>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
export function UblDownload({ slugs, invoice, labels }: { slugs: ProjectSlugs; invoice: InvoiceView; labels: Labels }) {
|
||||
const client = useDataClient()
|
||||
const [problem, setProblem] = useState<string>()
|
||||
const download = async () => {
|
||||
try {
|
||||
const { value } = await client.call<{ xml: string; filename: string }>("invoicing.invoice.ubl", {
|
||||
org: slugs.org,
|
||||
invoiceId: invoice.invoiceId,
|
||||
})
|
||||
const url = URL.createObjectURL(new Blob([value.xml], { type: "application/xml" }))
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = value.filename
|
||||
link.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
setProblem(undefined)
|
||||
} catch (error) {
|
||||
setProblem(describeFailure(error, labels))
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void download()
|
||||
}}
|
||||
>
|
||||
{labels.download}
|
||||
</Button>
|
||||
{problem ? <p role="alert">{problem}</p> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { SelectField, TextField } from "@conusai-kit/ui/patterns"
|
||||
import { FieldLegend, FieldSet } from "@conusai-kit/ui/primitives"
|
||||
import type { InvoiceTerms, SeriesView } from "../contract/index.js"
|
||||
import type { Labels } from "./labels.js"
|
||||
export function DraftFields({
|
||||
labels,
|
||||
data,
|
||||
series,
|
||||
count,
|
||||
}: {
|
||||
labels: Labels
|
||||
data?: InvoiceTerms
|
||||
series: SeriesView[]
|
||||
count: number
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<SelectField
|
||||
name="series"
|
||||
label={labels.series}
|
||||
required
|
||||
defaultValue={data?.series_id}
|
||||
options={series
|
||||
.filter((row) => row.active && row.kind === (data?.type_code === "381" ? "credit_note" : "invoice"))
|
||||
.map((row) => ({ value: row.seriesId, label: row.code }))}
|
||||
/>
|
||||
<TextField name="currency" label={labels.currency} required maxLength={3} defaultValue={data?.currency} />
|
||||
<TextField name="issueDate" type="date" label={labels.issueDate} required defaultValue={data?.issue_date} />
|
||||
<TextField name="dueDate" type="date" label={labels.dueDate} defaultValue={data?.due_date ?? undefined} />
|
||||
<TextField name="buyerReference" label={labels.buyerReference} required defaultValue={data?.buyer_reference} />
|
||||
{(["seller", "buyer"] as const).map((side) => (
|
||||
<FieldSet key={side}>
|
||||
<FieldLegend>{labels[side]}</FieldLegend>
|
||||
<TextField
|
||||
name={`${side}.reference`}
|
||||
label={labels.partyRef}
|
||||
required
|
||||
defaultValue={data?.[side].reference}
|
||||
/>
|
||||
<TextField name={`${side}.name`} label={labels.name} required defaultValue={data?.[side].name} />
|
||||
<SelectField
|
||||
name={`${side}.vatScheme`}
|
||||
label={labels.vatScheme}
|
||||
defaultValue={data?.[side].vat_identifier?.scheme ?? "vat"}
|
||||
options={[
|
||||
{ value: "vat", label: labels.vatDefault },
|
||||
{ value: "9937", label: labels.vatLithuanian },
|
||||
]}
|
||||
/>
|
||||
<TextField
|
||||
name={`${side}.vatValue`}
|
||||
label={labels.vatIdentifier}
|
||||
defaultValue={data?.[side].vat_identifier?.value ?? undefined}
|
||||
/>
|
||||
<TextField
|
||||
name={`${side}.country`}
|
||||
label={labels.country}
|
||||
required
|
||||
maxLength={2}
|
||||
defaultValue={data?.[side].address.country}
|
||||
/>
|
||||
<TextField
|
||||
name={`${side}.street`}
|
||||
label={labels.street}
|
||||
defaultValue={data?.[side].address.street ?? undefined}
|
||||
/>
|
||||
<TextField name={`${side}.city`} label={labels.city} defaultValue={data?.[side].address.city ?? undefined} />
|
||||
<TextField
|
||||
name={`${side}.postcode`}
|
||||
label={labels.postcode}
|
||||
defaultValue={data?.[side].address.postal_code ?? undefined}
|
||||
/>
|
||||
<TextField
|
||||
name={`${side}.scheme`}
|
||||
label={labels.endpointScheme}
|
||||
required
|
||||
defaultValue={data?.[side].endpoint.scheme}
|
||||
/>
|
||||
<TextField
|
||||
name={`${side}.endpoint`}
|
||||
label={labels.endpoint}
|
||||
required
|
||||
defaultValue={data?.[side].endpoint.value}
|
||||
/>
|
||||
</FieldSet>
|
||||
))}
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<FieldSet key={`line-${i}`}>
|
||||
<FieldLegend>
|
||||
{labels.item} {i + 1}
|
||||
</FieldLegend>
|
||||
<TextField name={`line.${i}.name`} label={labels.item} required defaultValue={data?.lines[i]?.item_name} />
|
||||
<TextField
|
||||
name={`line.${i}.quantity`}
|
||||
label={labels.quantity}
|
||||
required
|
||||
defaultValue={data?.lines[i]?.quantity}
|
||||
/>
|
||||
<TextField name={`line.${i}.unit`} label={labels.unit} required defaultValue={data?.lines[i]?.unit} />
|
||||
<TextField
|
||||
name={`line.${i}.price`}
|
||||
label={labels.price}
|
||||
required
|
||||
defaultValue={data?.lines[i]?.net_price.amount}
|
||||
/>
|
||||
<TextField
|
||||
name={`line.${i}.category`}
|
||||
label={labels.vatCategory}
|
||||
required
|
||||
defaultValue={data?.lines[i]?.vat_category}
|
||||
/>
|
||||
<TextField
|
||||
name={`line.${i}.rate`}
|
||||
label={labels.vatRate}
|
||||
defaultValue={data?.lines[i]?.vat_rate ?? undefined}
|
||||
/>
|
||||
<TextField
|
||||
name={`line.${i}.exemption`}
|
||||
label={labels.exemption}
|
||||
defaultValue={data?.lines[i]?.exemption_reason ?? undefined}
|
||||
/>
|
||||
</FieldSet>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import type { Locale } from "@conusai-kit/i18n"
|
||||
import { parseDecimalInput } from "@conusai-kit/i18n/values"
|
||||
import { type InvoiceTerms, invoiceTerms } from "../contract/index.js"
|
||||
/**
|
||||
* Merge edited fields, retaining all unedited terms/identifiers/notes/extensions. No float coercion: a price or a
|
||||
* rate is read as the person typed it in `locale` (`12,50` in LT) into the service's exact text; text that is not a
|
||||
* number is passed on as typed, so the contract refuses it by name.
|
||||
*/
|
||||
export function draftData(
|
||||
values: Record<string, string>,
|
||||
count: number,
|
||||
locale: Locale,
|
||||
prior?: InvoiceTerms,
|
||||
): InvoiceTerms {
|
||||
const exact = (text: string | undefined) => (text ? (parseDecimalInput(text, locale)?.toString() ?? text) : text)
|
||||
const party = (side: "seller" | "buyer") => ({
|
||||
...prior?.[side],
|
||||
reference: values[`${side}.reference`],
|
||||
name: values[`${side}.name`],
|
||||
vat_identifier: values[`${side}.vatValue`]
|
||||
? { scheme: values[`${side}.vatScheme`], value: values[`${side}.vatValue`] }
|
||||
: null,
|
||||
endpoint: { scheme: values[`${side}.scheme`], value: values[`${side}.endpoint`] },
|
||||
address: {
|
||||
...prior?.[side].address,
|
||||
country: values[`${side}.country`],
|
||||
street: values[`${side}.street`] || null,
|
||||
city: values[`${side}.city`] || null,
|
||||
postal_code: values[`${side}.postcode`] || null,
|
||||
},
|
||||
})
|
||||
return invoiceTerms.parse({
|
||||
...prior,
|
||||
type_code: prior?.type_code ?? "380",
|
||||
series_id: values.series,
|
||||
currency: values.currency,
|
||||
issue_date: values.issueDate,
|
||||
due_date: values.dueDate || null,
|
||||
buyer_reference: values.buyerReference,
|
||||
seller: party("seller"),
|
||||
buyer: party("buyer"),
|
||||
lines: Array.from({ length: count }, (_, i) => ({
|
||||
...prior?.lines[i],
|
||||
item_name: values[`line.${i}.name`],
|
||||
quantity: values[`line.${i}.quantity`],
|
||||
unit: values[`line.${i}.unit`],
|
||||
net_price: { currency: values.currency, amount: exact(values[`line.${i}.price`]) },
|
||||
vat_category: values[`line.${i}.category`],
|
||||
vat_rate: exact(values[`line.${i}.rate`]) || null,
|
||||
exemption_reason: values[`line.${i}.exemption`] || null,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
|
||||
import type { Locale } from "@conusai-kit/i18n"
|
||||
import { serviceNotice } from "@conusai-kit/ui/patterns"
|
||||
import { ZodError } from "zod"
|
||||
import type { en } from "../i18n/en.js"
|
||||
export type Messages = { [K in keyof typeof en]: string }
|
||||
/**
|
||||
* What the loader hands the views: the module's messages, the reader's locale (money is formatted with it) and
|
||||
* the kit's refusal sentences by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`).
|
||||
*/
|
||||
export type Labels = Messages & { readonly locale: Locale; readonly refusals: Readonly<Record<string, string>> }
|
||||
export function describeFailure(error: unknown, labels: Labels): string {
|
||||
if (error instanceof ZodError) return labels.invalid
|
||||
return serviceNotice(error, labels.refusals)?.text ?? labels.unavailable
|
||||
}
|
||||
@@ -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