306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
"use client"
|
|
|
|
import { useWrite } from "@conusai-kit/ui/data"
|
|
import {
|
|
CheckField,
|
|
ConfirmDialog,
|
|
FactList,
|
|
FormLayout,
|
|
type FormValues,
|
|
Page,
|
|
PageSection,
|
|
ResourceList,
|
|
SelectField,
|
|
TextField,
|
|
usePageNavigation,
|
|
} from "@conusai-kit/ui/patterns"
|
|
import { Button } from "@conusai-kit/ui/primitives"
|
|
import { useRef, useState } from "react"
|
|
import { type AccountView, recordTerms, type TransactionView } from "../contract/index.js"
|
|
import { ledgerFailure } from "./errors.js"
|
|
import type { LedgerLabels, LedgerMessages } from "./labels.js"
|
|
import { retryIdentity } from "./retry.js"
|
|
|
|
const options = (labels: LedgerLabels, values: (keyof LedgerMessages)[]) =>
|
|
values.map((value) => ({ value, label: labels[value] }))
|
|
|
|
export function AccountForm({
|
|
org,
|
|
account,
|
|
labels,
|
|
base,
|
|
}: {
|
|
org: string
|
|
account?: AccountView
|
|
labels: LedgerLabels
|
|
base: string
|
|
}) {
|
|
const navigation = usePageNavigation()
|
|
const write = useWrite<Record<string, unknown>, AccountView>(
|
|
account ? "ledger.accounts.update" : "ledger.accounts.create",
|
|
)
|
|
const identity = useRef(retryIdentity())
|
|
const [pending, setPending] = useState<Record<string, unknown>>()
|
|
return (
|
|
<PageSection title={account ? labels.configure : labels.create}>
|
|
<FormLayout
|
|
describeError={(error) => ledgerFailure(error, labels)}
|
|
submitLabel={labels.review}
|
|
busyLabel={labels.busy}
|
|
onSubmit={async (values: FormValues) => {
|
|
setPending(
|
|
account
|
|
? {
|
|
org,
|
|
accountId: account.accountId,
|
|
expectedVersion: account.revision,
|
|
name: values.name,
|
|
status: values.status,
|
|
allowNegative: values.allowNegative === "on",
|
|
}
|
|
: { org, ...values, allowNegative: values.allowNegative === "on" },
|
|
)
|
|
}}
|
|
>
|
|
{!account ? (
|
|
<>
|
|
<TextField name="code" label={labels.code} required pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,63}" />
|
|
<TextField name="currency" label={labels.currency} required pattern="[A-Z]{3}" />
|
|
<SelectField
|
|
name="type"
|
|
label={labels.type}
|
|
defaultValue="asset"
|
|
options={options(labels, ["asset", "liability", "equity", "income", "expense"])}
|
|
/>
|
|
<SelectField
|
|
name="normalBalance"
|
|
label={labels.normalBalance}
|
|
defaultValue="debit"
|
|
options={options(labels, ["debit", "credit"])}
|
|
/>
|
|
</>
|
|
) : (
|
|
<SelectField
|
|
name="status"
|
|
label={labels.status}
|
|
defaultValue={account.status}
|
|
options={options(labels, ["active", "archived"])}
|
|
/>
|
|
)}
|
|
<TextField name="name" label={labels.name} defaultValue={account?.name} required maxLength={200} />
|
|
<CheckField name="allowNegative" label={labels.allowNegative} defaultChecked={account?.allowNegative} />
|
|
</FormLayout>
|
|
<ConfirmDialog
|
|
describeError={(error) => ledgerFailure(error, labels)}
|
|
open={pending !== undefined}
|
|
onOpenChange={(open) => {
|
|
if (!open) setPending(undefined)
|
|
}}
|
|
title={labels.confirm}
|
|
description={labels.archiveWarning}
|
|
confirmLabel={labels.save}
|
|
cancelLabel={labels.cancel}
|
|
onConfirm={async () => {
|
|
if (!pending) return
|
|
const saved = await write.mutate({ ...pending, requestId: identity.current(pending) })
|
|
navigation.navigate(`${base}/accounts/${saved.accountId}`)
|
|
}}
|
|
>
|
|
<Page>
|
|
{pending ? (
|
|
<FactList
|
|
facts={[
|
|
{ label: labels.name, value: String(pending.name) },
|
|
{ label: labels.code, value: account?.code ?? String(pending.code) },
|
|
{ label: labels.currency, value: account?.currency ?? String(pending.currency) },
|
|
{ label: labels.type, value: labels[(account?.type ?? String(pending.type)) as keyof LedgerMessages] },
|
|
{
|
|
label: labels.normalBalance,
|
|
value: labels[(account?.normalBalance ?? String(pending.normalBalance)) as keyof LedgerMessages],
|
|
},
|
|
{ label: labels.status, value: pending.status === "archived" ? labels.archived : labels.active },
|
|
{ label: labels.allowNegative, value: pending.allowNegative ? labels.yes : labels.no },
|
|
]}
|
|
/>
|
|
) : null}
|
|
</Page>
|
|
</ConfirmDialog>
|
|
</PageSection>
|
|
)
|
|
}
|
|
|
|
export function RecordForm({
|
|
org,
|
|
labels,
|
|
base,
|
|
fenced,
|
|
}: {
|
|
org: string
|
|
labels: LedgerLabels
|
|
base: string
|
|
fenced: boolean
|
|
}) {
|
|
const navigation = usePageNavigation()
|
|
const write = useWrite<Record<string, unknown>, TransactionView>("ledger.transactions.record")
|
|
const identity = useRef(retryIdentity())
|
|
const [lines, setLines] = useState(2)
|
|
const [pending, setPending] = useState<ReturnType<typeof recordTerms.parse>>()
|
|
return (
|
|
<PageSection title={labels.record}>
|
|
<FormLayout
|
|
describeError={(error) => ledgerFailure(error, labels)}
|
|
submitLabel={labels.review}
|
|
busyLabel={labels.busy}
|
|
submitDisabled={fenced}
|
|
onSubmit={async (values: FormValues) => {
|
|
if (fenced) throw new Error(labels.drift)
|
|
const result = recordTerms.safeParse({
|
|
effectiveAt: values.effectiveAt,
|
|
description: values.description,
|
|
...(values.reference ? { reference: values.reference } : {}),
|
|
status: values.status,
|
|
entries: Array.from({ length: lines }, (_, i) => ({
|
|
accountId: values[`account-${i}`],
|
|
direction: values[`direction-${i}`],
|
|
amountMinor: values[`amount-${i}`],
|
|
})),
|
|
})
|
|
if (!result.success) throw new Error(labels.invalid)
|
|
setPending(result.data)
|
|
}}
|
|
>
|
|
<TextField name="effectiveAt" label={labels.effectiveAt} required />
|
|
<TextField name="description" label={labels.description} required maxLength={200} />
|
|
<TextField name="reference" label={labels.reference} maxLength={200} />
|
|
<SelectField
|
|
name="status"
|
|
label={labels.status}
|
|
defaultValue="pending"
|
|
options={options(labels, ["pending", "posted"])}
|
|
/>
|
|
{Array.from({ length: lines }, (_, i) => (
|
|
<PageSection key={i} title={`${labels.entries} ${i + 1}`}>
|
|
<TextField name={`account-${i}`} label={labels.account} required />
|
|
<SelectField
|
|
name={`direction-${i}`}
|
|
label={labels.normalBalance}
|
|
defaultValue={i === 0 ? "debit" : "credit"}
|
|
options={options(labels, ["debit", "credit"])}
|
|
/>
|
|
<TextField
|
|
name={`amount-${i}`}
|
|
label={labels.amountMinor}
|
|
required
|
|
pattern="[1-9][0-9]{0,18}"
|
|
inputMode="numeric"
|
|
/>
|
|
</PageSection>
|
|
))}
|
|
<Button type="button" disabled={lines >= 200 || fenced} onClick={() => setLines((n) => n + 1)}>
|
|
{labels.addLine}
|
|
</Button>
|
|
<Button type="button" disabled={lines <= 2} onClick={() => setLines((n) => n - 1)}>
|
|
{labels.removeLine}
|
|
</Button>
|
|
</FormLayout>
|
|
<ConfirmDialog
|
|
describeError={(error) => ledgerFailure(error, labels)}
|
|
open={pending !== undefined}
|
|
onOpenChange={(open) => {
|
|
if (!open) setPending(undefined)
|
|
}}
|
|
title={labels.confirm}
|
|
description={labels.confirmDescription}
|
|
confirmLabel={labels.record}
|
|
cancelLabel={labels.cancel}
|
|
onConfirm={async () => {
|
|
if (!pending || fenced) throw new Error(labels.drift)
|
|
const saved = await write.mutate({
|
|
org,
|
|
terms: pending,
|
|
confirmed: true,
|
|
requestId: identity.current({ org, terms: pending }),
|
|
})
|
|
navigation.navigate(`${base}/transactions/${saved.transactionId}`)
|
|
}}
|
|
>
|
|
<Page>
|
|
{pending ? (
|
|
<>
|
|
<FactList
|
|
facts={[
|
|
{ label: labels.effectiveAt, value: pending.effectiveAt },
|
|
{ label: labels.description, value: pending.description },
|
|
{ label: labels.reference, value: pending.reference },
|
|
{ label: labels.status, value: labels[pending.status] },
|
|
]}
|
|
/>
|
|
<ResourceList
|
|
label={labels.entries}
|
|
data={pending.entries.map((entry, index) => ({ ...entry, id: String(index) }))}
|
|
columns={[
|
|
{ id: "accountId", header: labels.account },
|
|
{ id: "direction", header: labels.normalBalance, cell: (row) => labels[row.direction] },
|
|
{ id: "amountMinor", header: labels.amountMinor },
|
|
]}
|
|
empty={{ title: labels.empty }}
|
|
/>
|
|
</>
|
|
) : null}
|
|
</Page>
|
|
</ConfirmDialog>
|
|
</PageSection>
|
|
)
|
|
}
|
|
|
|
export function Transition({
|
|
org,
|
|
transaction,
|
|
action,
|
|
labels,
|
|
base,
|
|
fenced,
|
|
}: {
|
|
org: string
|
|
transaction: TransactionView
|
|
action: "post" | "void" | "reverse"
|
|
labels: LedgerLabels
|
|
base: string
|
|
fenced: boolean
|
|
}) {
|
|
const navigation = usePageNavigation()
|
|
const write = useWrite<Record<string, unknown>, TransactionView>(`ledger.transactions.${action}`)
|
|
const identity = useRef(retryIdentity())
|
|
const payload = {
|
|
org,
|
|
transactionId: transaction.transactionId,
|
|
expectedVersion: transaction.revision,
|
|
confirmed: true,
|
|
}
|
|
return (
|
|
<ConfirmDialog
|
|
describeError={(error) => ledgerFailure(error, labels)}
|
|
title={labels.confirm}
|
|
description={labels.confirmDescription}
|
|
confirmLabel={labels[action]}
|
|
cancelLabel={labels.cancel}
|
|
trigger={<Button disabled={fenced && action !== "void"}>{labels[action]}</Button>}
|
|
onConfirm={async () => {
|
|
if (fenced && action !== "void") throw new Error(labels.drift)
|
|
const saved = await write.mutate({ ...payload, requestId: identity.current(payload) })
|
|
navigation.navigate(`${base}/transactions/${saved.transactionId}`)
|
|
}}
|
|
>
|
|
<Page>
|
|
<FactList
|
|
facts={[
|
|
{ label: labels.transaction, value: transaction.transactionId },
|
|
{ label: labels.revision, value: transaction.revision },
|
|
{ label: labels.status, value: labels[transaction.status] },
|
|
]}
|
|
/>
|
|
</Page>
|
|
</ConfirmDialog>
|
|
)
|
|
}
|