@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user