@@ -0,0 +1,11 @@
|
||||
import { describeError } from "@conusai-kit/ui/data"
|
||||
import { serviceNotice } from "@conusai-kit/ui/patterns"
|
||||
import { LEDGER_ERRORS } from "../contract/index.js"
|
||||
import type { LedgerLabels } from "./labels.js"
|
||||
|
||||
/** What a form shows for a refused write: the module's drift fence, a Ledger refusal in the reader's words, or the kit's text. */
|
||||
export function ledgerFailure(error: unknown, labels: LedgerLabels): string {
|
||||
const code = (error as { problem?: { code?: unknown } } | null)?.problem?.code
|
||||
if (code === LEDGER_ERRORS.drift) return labels.drift
|
||||
return serviceNotice(error, labels.refusals)?.text ?? describeError(error)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { businessRefusalNotices } from "@conusai-kit/business/refusals"
|
||||
import type { Locale } from "@conusai-kit/i18n"
|
||||
import { en as keys } from "../i18n/en.js"
|
||||
|
||||
export type LedgerMessages = { [K in keyof typeof keys]: string }
|
||||
/**
|
||||
* The module's messages, and every Ledger refusal's problem code with the sentence a person reads for it
|
||||
* (the kit's, in the loader's locale; a revision or idempotency conflict in this module's own words). Plain
|
||||
* strings, so the loader hands them to the forms, which read a refusal with `serviceNotice`.
|
||||
*/
|
||||
export type LedgerLabels = LedgerMessages & { readonly refusals: Readonly<Record<string, string>> }
|
||||
|
||||
export function ledgerLabels(t: (key: string) => string, locale: Locale): LedgerLabels {
|
||||
const messages = Object.fromEntries(Object.keys(keys).map((key) => [key, t(key)])) as LedgerMessages
|
||||
return {
|
||||
...messages,
|
||||
refusals: businessRefusalNotices(locale, {
|
||||
revision_conflict: messages.revisionConflict,
|
||||
idempotency_conflict: messages.idempotencyConflict,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { businessServiceNotices } from "@conusai-kit/business/refusals"
|
||||
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
|
||||
import type { I18n } from "@conusai-kit/i18n"
|
||||
import { type ProjectPage, projectPage } from "@conusai-kit/ui"
|
||||
import { Can } from "@conusai-kit/ui/data"
|
||||
import {
|
||||
FactList,
|
||||
Page,
|
||||
PageHeader,
|
||||
PageSection,
|
||||
type ResourceColumn,
|
||||
type ServiceNotice,
|
||||
ServiceStatePage,
|
||||
serviceNotice,
|
||||
} from "@conusai-kit/ui/patterns"
|
||||
import * as c from "../contract/index.js"
|
||||
import { AccountForm, RecordForm, Transition } from "./forms.js"
|
||||
import { type LedgerLabels, ledgerLabels } from "./labels.js"
|
||||
import { LedgerList } from "./resource.js"
|
||||
|
||||
type Loaded = { labels: LedgerLabels; verification: c.VerificationView } & (
|
||||
| {
|
||||
kind: "list"
|
||||
accounts: { rows: c.AccountView[]; after?: string | undefined }
|
||||
transactions: { rows: c.TransactionView[]; after?: string | undefined }
|
||||
}
|
||||
| {
|
||||
kind: "account"
|
||||
account: c.AccountView
|
||||
balances: { rows: c.BalanceView[]; after?: string | undefined }
|
||||
entries: { rows: c.EntryView[]; after?: string | undefined }
|
||||
}
|
||||
| { kind: "transaction"; transaction: c.TransactionView }
|
||||
)
|
||||
type Data = Loaded | { kind: "state"; labels: LedgerLabels; notice: ServiceNotice }
|
||||
export type LedgerPage = ProjectPage<TransportCaller, I18n, Container, Data>
|
||||
export const ledgerPage: LedgerPage = projectPage({
|
||||
async load({ caller, slugs, segments, i18n }): Promise<Data> {
|
||||
const labels = ledgerLabels((key) => i18n.message(`ledger.${key}`), i18n.locale)
|
||||
const input = { org: slugs.org }
|
||||
const [kind, id, ...rest] = segments
|
||||
if (rest.length || (kind !== undefined && ((kind !== "accounts" && kind !== "transactions") || !id)))
|
||||
throw notFound()
|
||||
try {
|
||||
const verification = await caller.call(c.verify, input)
|
||||
if (kind === "accounts" && id) {
|
||||
const [account, balances, entries] = await Promise.all([
|
||||
caller.call(c.account, { ...input, accountId: id }),
|
||||
caller.call(c.balances, { ...input, accountId: id }),
|
||||
caller.call(c.entries, { ...input, accountId: id }),
|
||||
])
|
||||
return { kind: "account", labels, verification, account, balances, entries }
|
||||
}
|
||||
if (kind === "transactions" && id)
|
||||
return {
|
||||
kind: "transaction",
|
||||
labels,
|
||||
verification,
|
||||
transaction: await caller.call(c.transaction, { ...input, transactionId: id }),
|
||||
}
|
||||
const [accounts, transactions] = await Promise.all([
|
||||
caller.call(c.accounts, input),
|
||||
caller.call(c.transactions, input),
|
||||
])
|
||||
return { kind: "list", labels, verification, accounts, transactions }
|
||||
} catch (error) {
|
||||
const notice = serviceNotice(error, businessServiceNotices(i18n.locale))
|
||||
if (!notice) throw error
|
||||
return { kind: "state", labels, notice }
|
||||
}
|
||||
},
|
||||
View({ slugs, data }) {
|
||||
if (data.kind === "state") return <ServiceStatePage title={data.labels.title} notice={data.notice} />
|
||||
const { labels: l, verification } = data
|
||||
const base = `/${slugs.org}/${slugs.workspace}/${slugs.project}/ledger`
|
||||
const org = slugs.org
|
||||
const fenced = verification.drift.length > 0
|
||||
const entryColumns: ResourceColumn<c.EntryView>[] = [
|
||||
{ id: "accountId", header: l.account },
|
||||
{ id: "direction", header: l.normalBalance, cell: (row: c.EntryView) => l[row.direction] },
|
||||
{ id: "amountMinor", header: l.amountMinor },
|
||||
{ id: "currency", header: l.currency },
|
||||
{ id: "transactionId", header: l.transaction },
|
||||
]
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
title={l.title}
|
||||
description={l.intro}
|
||||
breadcrumb={data.kind === "list" ? undefined : <a href={base}>{l.back}</a>}
|
||||
/>
|
||||
<PageSection title={l.verifier}>
|
||||
<FactList
|
||||
facts={[
|
||||
{ label: l.status, value: fenced ? l.drift : l.healthy },
|
||||
{ label: l.checked, value: String(verification.checkedAccounts) },
|
||||
]}
|
||||
/>
|
||||
</PageSection>
|
||||
{data.kind === "list" ? (
|
||||
<>
|
||||
<PageSection title={l.accounts}>
|
||||
<LedgerList
|
||||
seed={data.accounts}
|
||||
procedure="ledger.accounts.list"
|
||||
input={{ org }}
|
||||
rowId={(row) => row.accountId}
|
||||
href={(row) => `${base}/accounts/${row.accountId}`}
|
||||
labels={l}
|
||||
title={l.accounts}
|
||||
columns={[
|
||||
{ id: "code", header: l.code },
|
||||
{ id: "name", header: l.name },
|
||||
{ id: "currency", header: l.currency },
|
||||
{ id: "status", header: l.status, cell: (row) => l[row.status] },
|
||||
]}
|
||||
/>
|
||||
</PageSection>
|
||||
<Can permission="ledger.accounts.manage">
|
||||
<AccountForm org={org} labels={l} base={base} />
|
||||
</Can>
|
||||
<PageSection title={l.transactions}>
|
||||
<LedgerList
|
||||
seed={data.transactions}
|
||||
procedure="ledger.transactions.list"
|
||||
input={{ org }}
|
||||
rowId={(row) => row.transactionId}
|
||||
href={(row) => `${base}/transactions/${row.transactionId}`}
|
||||
labels={l}
|
||||
title={l.transactions}
|
||||
columns={[
|
||||
{ id: "transactionId", header: l.transaction },
|
||||
{ id: "description", header: l.description },
|
||||
{ id: "status", header: l.status, cell: (row) => l[row.status] },
|
||||
{ id: "effectiveAt", header: l.effectiveAt },
|
||||
]}
|
||||
/>
|
||||
</PageSection>
|
||||
<Can permission="ledger.transactions.write">
|
||||
<RecordForm org={org} labels={l} base={base} fenced={fenced} />
|
||||
</Can>
|
||||
</>
|
||||
) : data.kind === "account" ? (
|
||||
<>
|
||||
<FactList
|
||||
facts={[
|
||||
{ label: l.account, value: data.account.accountId },
|
||||
{ label: l.code, value: data.account.code },
|
||||
{ label: l.name, value: data.account.name },
|
||||
{ label: l.currency, value: data.account.currency },
|
||||
{ label: l.type, value: l[data.account.type] },
|
||||
{ label: l.normalBalance, value: l[data.account.normalBalance] },
|
||||
{ label: l.allowNegative, value: data.account.allowNegative ? l.yes : l.no },
|
||||
{ label: l.status, value: l[data.account.status] },
|
||||
{ label: l.revision, value: data.account.revision },
|
||||
]}
|
||||
/>
|
||||
<PageSection title={l.balances}>
|
||||
<LedgerList
|
||||
seed={data.balances}
|
||||
procedure="ledger.balances.list"
|
||||
input={{ org, accountId: data.account.accountId }}
|
||||
rowId={(row) => row.accountId}
|
||||
labels={l}
|
||||
title={l.balances}
|
||||
columns={[
|
||||
{ id: "currency", header: l.currency },
|
||||
{ id: "posted", header: l.postedBalance },
|
||||
{ id: "pending", header: l.pendingBalance },
|
||||
{ id: "available", header: l.availableBalance },
|
||||
]}
|
||||
/>
|
||||
</PageSection>
|
||||
<PageSection title={l.entries}>
|
||||
<LedgerList
|
||||
seed={data.entries}
|
||||
procedure="ledger.entries.list"
|
||||
input={{ org, accountId: data.account.accountId }}
|
||||
rowId={(row) => row.entryId}
|
||||
href={(row) => `${base}/transactions/${row.transactionId}`}
|
||||
labels={l}
|
||||
title={l.entries}
|
||||
columns={entryColumns}
|
||||
/>
|
||||
</PageSection>
|
||||
<Can permission="ledger.accounts.manage">
|
||||
<AccountForm org={org} account={data.account} labels={l} base={base} />
|
||||
</Can>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FactList
|
||||
facts={[
|
||||
{ label: l.transaction, value: data.transaction.transactionId },
|
||||
{ label: l.description, value: data.transaction.description },
|
||||
{ label: l.reference, value: data.transaction.reference },
|
||||
{ label: l.effectiveAt, value: data.transaction.effectiveAt },
|
||||
{ label: l.status, value: l[data.transaction.status] },
|
||||
{ label: l.revision, value: data.transaction.revision },
|
||||
{ label: l.reverses, value: data.transaction.reverses },
|
||||
]}
|
||||
/>
|
||||
<LedgerList
|
||||
seed={{ rows: data.transaction.entries }}
|
||||
procedure="ledger.transactions.get"
|
||||
input={{ org, transactionId: data.transaction.transactionId }}
|
||||
rowId={(row) => row.entryId}
|
||||
labels={l}
|
||||
title={l.entries}
|
||||
columns={entryColumns}
|
||||
/>
|
||||
{data.transaction.status === "pending" ? (
|
||||
<Can permission="ledger.transactions.write">
|
||||
{(["post", "void"] as const).map((action) => (
|
||||
<Transition
|
||||
key={action}
|
||||
org={org}
|
||||
transaction={data.transaction}
|
||||
action={action}
|
||||
labels={l}
|
||||
base={base}
|
||||
fenced={fenced}
|
||||
/>
|
||||
))}
|
||||
</Can>
|
||||
) : data.transaction.status === "posted" ? (
|
||||
<Can permission="ledger.transactions.reverse">
|
||||
<Transition
|
||||
org={org}
|
||||
transaction={data.transaction}
|
||||
action="reverse"
|
||||
labels={l}
|
||||
base={base}
|
||||
fenced={fenced}
|
||||
/>
|
||||
</Can>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Page>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import { useDataClient } from "@conusai-kit/ui/data"
|
||||
import { type ResourceColumn, ResourceList } from "@conusai-kit/ui/patterns"
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
import { useState } from "react"
|
||||
import type { LedgerLabels } from "./labels.js"
|
||||
|
||||
/** Cursor reads happen only on user action; no local financial persistence. */
|
||||
export function LedgerList<T extends object>({
|
||||
seed,
|
||||
procedure,
|
||||
input,
|
||||
columns,
|
||||
rowId,
|
||||
href,
|
||||
labels,
|
||||
title,
|
||||
}: {
|
||||
seed: { rows: T[]; after?: string | undefined }
|
||||
procedure: string
|
||||
input: Record<string, unknown>
|
||||
columns: ResourceColumn<T>[]
|
||||
rowId: (row: T) => string
|
||||
href?: ((row: T) => string) | undefined
|
||||
labels: LedgerLabels
|
||||
title: string
|
||||
}) {
|
||||
const client = useDataClient()
|
||||
const [page, setPage] = useState(seed)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<ResourceList
|
||||
label={title}
|
||||
data={page.rows}
|
||||
columns={columns}
|
||||
rowId={rowId}
|
||||
rowHref={href}
|
||||
rowLabel={rowId}
|
||||
rowLinkLabel={() => labels.open}
|
||||
empty={{ title: labels.empty }}
|
||||
/>
|
||||
{failed ? <p role="alert">{labels.unavailable}</p> : null}
|
||||
{page.after ? (
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setFailed(false)
|
||||
try {
|
||||
const next = (await client.call<typeof seed>(procedure, { ...input, after: page.after })).value
|
||||
setPage({ ...next, rows: [...page.rows, ...next.rows] })
|
||||
} catch {
|
||||
setFailed(true)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{labels.more}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Component-memory only. Identical retries retain their key; every changed submission gets a new one. */
|
||||
export function retryIdentity() {
|
||||
let previous: string | undefined
|
||||
let key: string
|
||||
return (payload: unknown): string => {
|
||||
const next = JSON.stringify(payload)
|
||||
if (next !== previous) {
|
||||
previous = next
|
||||
key = crypto.randomUUID()
|
||||
}
|
||||
return key
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user