241 lines
7.8 KiB
TypeScript
241 lines
7.8 KiB
TypeScript
"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}
|
|
</>
|
|
)
|
|
}
|