feat(18-01): money list column, danger record actions, preview Edit rule and table kit (task 2)

- columns.yaml type: money with currency or currencyFrom (exactly one),
  never searchable, not sortable by default; every list row carries the
  currencyFrom column's value under its own key; the schema serves
  currency and currencyFrom on the column
- pact.AdminRecordAction.Danger, served as danger on cabana.RecordAction
  (absent when false); RecordActions.vue passes it to the confirm dialog
- CellValue money kind through formatMoney with the row's code, the em
  dash when empty and the amount alone without a code; DataTable aligns
  money headers and cells to the right
- PreviewView renders Edit only when a registered value field allows
  update (F6)
- main.css .summer-table* and .summer-stat__note kit classes (F7) over
  --c-* variables; cabana README kit rows
- fixture record actions void (danger) and resend; TestMoneyListCompile,
  TestMoneyListSmoke and TestRecordActionDangerSmoke; regenerated OpenAPI
  and TS types; rebuilt dist; lists-and-filters and admin-controllers docs
This commit is contained in:
Jakub Zych
2026-10-13 11:22:36 +02:00
parent 20385e53ee
commit 0c8605123e
29 changed files with 615 additions and 56 deletions

View File

@@ -14,7 +14,8 @@ import { useConfirm } from '../ui/confirm'
// that is not listed is not rendered. Every action asks for confirmation; the
// dialog stays open and busy while the POST runs, and every button here is
// disabled meanwhile. Labels, confirm texts and messages come from the server
// and are rendered as text.
// and are rendered as text. A `danger` action (UI-SPEC F5) turns the dialog's
// confirm button into the danger variant; the footer button stays outline.
const props = withDefaults(
defineProps<{
source: ControllerParams
@@ -49,7 +50,7 @@ async function run(action: RecordAction): Promise<void> {
{
message: action.confirm || t('backend::lang.form.action_confirm', { action: action.label }),
confirmLabel: action.label,
danger: false,
danger: action.danger,
},
async () => {
running.value = true

View File

@@ -2,7 +2,8 @@
import { computed } from 'vue'
import { ArrowUpRight } from '@lucide/vue'
import type { ListColumn } from '../../api/types'
import { t } from '../../app/i18n'
import { currentLocale, t } from '../../app/i18n'
import { formatMoney } from '../../app/money'
// One cell rendered by column type (D-12): text by default (arrays
// comma-joined, a muted dash when empty), datetime as YYYY-MM-DD HH:mm with
@@ -10,7 +11,11 @@ import { t } from '../../app/i18n'
// Phase 12.2: a `date` column shows the stored YYYY-MM-DD and a `time`
// column the stored HH:mm, both as strings: a calendar date or a time of day
// has no zone, so it never goes through the global Date (Pitfall 12).
const props = defineProps<{ column: ListColumn; value: unknown }>()
// Phase 18: a `money` column formats the row's decimal string through Intl
// with the column's currency or the row's currencyFrom value (UI-SPEC F1);
// without a code it shows the amount alone. The string never becomes a
// number.
const props = defineProps<{ column: ListColumn; value: unknown; row?: Record<string, unknown> }>()
function isEmpty(value: unknown): boolean {
return value === null || value === undefined || value === ''
@@ -72,10 +77,29 @@ function truthy(value: unknown): boolean {
return Boolean(value)
}
/** The row's currency code for a money column: the literal or the sibling value. */
function currencyOf(): string {
if (props.column.currencyFrom) {
const code = props.row?.[props.column.currencyFrom]
return typeof code === 'string' ? code.trim() : ''
}
return props.column.currency ?? ''
}
function money(value: unknown): string {
if (typeof value !== 'string' || value.trim() === '') {
return ''
}
return formatMoney(value.trim(), currencyOf(), currentLocale.value, 4)
}
const kind = computed(() => {
if (props.column.type === 'switch') {
return 'switch'
}
if (props.column.type === 'money') {
return 'money'
}
if (props.column.type === 'datetime') {
return 'datetime'
}
@@ -96,6 +120,8 @@ const display = computed(() => {
return storedDate(props.value)
case 'time':
return storedTime(props.value)
case 'money':
return money(props.value)
default:
return text(props.value)
}
@@ -120,5 +146,6 @@ const display = computed(() => {
</template>
<span v-else-if="display === ''" class="text-muted" data-empty>—</span>
<span v-else-if="kind === 'datetime' || kind === 'date' || kind === 'time'" class="text-[13px] text-muted tabular-nums">{{ display }}</span>
<span v-else-if="kind === 'money'" data-money class="tabular-nums whitespace-nowrap">{{ display }}</span>
<template v-else>{{ display }}</template>
</template>

View File

@@ -172,10 +172,16 @@ function statesOf(row: AdminRecord): string[] {
function cellClass(column: ListColumn, columnIndex: number, states: string[] = []): string {
// A deleted or disabled row mutes every cell; the first keeps weight 600.
const muted = states.includes('deleted') || states.includes('disabled')
const align = alignClass(column)
if (columnIndex === 0) {
return muted ? 'font-semibold text-muted' : 'font-semibold'
return [muted ? 'font-semibold text-muted' : 'font-semibold', align].filter(Boolean).join(' ')
}
return muted || relation.value || column.relation ? 'text-muted' : ''
return [muted || relation.value || column.relation ? 'text-muted' : '', align].filter(Boolean).join(' ')
}
/** A money column aligns its header and cells to the right (UI-SPEC F1). */
function alignClass(column: ListColumn): string {
return column.type === 'money' ? 'text-right' : ''
}
/** First-cell text of a row with states: struck through, danger or muted. */
@@ -228,7 +234,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
:key="column.key"
scope="col"
:aria-sort="canSort(column) ? (sortState(column) ?? 'none') : undefined"
:class="sortState(column) ? 'text-text' : 'text-muted'"
:class="[sortState(column) ? 'text-text' : 'text-muted', alignClass(column)]"
class="px-3.5 text-[12px] font-semibold first:pl-5 last:pr-5"
>
<button
@@ -313,23 +319,23 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
class="min-w-0 truncate text-left font-semibold text-text"
@click="openFrom($event, row)"
>
<CellValue :column="column" :value="row[column.key]" />
<CellValue :column="column" :value="row[column.key]" :row="row" />
</button>
<CellValue v-else :column="column" :value="row[column.key]" />
<CellValue v-else :column="column" :value="row[column.key]" :row="row" />
</span>
<span v-else-if="columnIndex === 0 && statesOf(row).length > 0" class="flex items-center gap-2">
<RouterLink v-if="linkOf(row)" :to="linkOf(row)!" :class="stateTextClass(statesOf(row))">
<CellValue :column="column" :value="row[column.key]" />
<CellValue :column="column" :value="row[column.key]" :row="row" />
</RouterLink>
<span v-else :class="stateTextClass(statesOf(row))">
<CellValue :column="column" :value="row[column.key]" />
<CellValue :column="column" :value="row[column.key]" :row="row" />
</span>
<RowStateBadges :states="statesOf(row)" :labels="stateLabels" />
</span>
<RouterLink v-else-if="columnIndex === 0 && linkOf(row)" :to="linkOf(row)!" class="text-text no-underline">
<CellValue :column="column" :value="row[column.key]" />
<CellValue :column="column" :value="row[column.key]" :row="row" />
</RouterLink>
<CellValue v-else :column="column" :value="row[column.key]" />
<CellValue v-else :column="column" :value="row[column.key]" :row="row" />
</td>
<td v-if="trailing" data-trailing class="w-[56px] pr-5">
<slot name="trailing" :row="row" />