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

@@ -5802,6 +5802,14 @@ export interface components {
[key: string]: components["schemas"]["cabana.MessageForms"];
};
"cabana.ListColumn": {
/**
* @description Currency is the ISO 4217 code of a `type: money` column; CurrencyFrom
* names the model column that holds each row's code instead. Every list
* row carries the CurrencyFrom column's value under its own key, so the
* admin never guesses a currency (D-07).
*/
currency?: string;
currencyFrom?: string;
/**
* @description Invisible marks a column that is searched and sorted on the server but
* not shown: the admin renders no header or cell for it and list rows do
@@ -5949,6 +5957,11 @@ export interface components {
};
"cabana.RecordAction": {
confirm?: string;
/**
* @description Danger marks an action whose confirm dialog takes the danger variant
* (pact.AdminRecordAction.Danger); an ordinary action omits the key.
*/
danger?: boolean;
label: string;
name: string;
};

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" />

View File

@@ -384,6 +384,77 @@
font-variant-numeric: tabular-nums;
}
/* A note under a stat value (Phase 18, UI-SPEC F7): placed inside
.summer-stat__value after the value text, because .summer-stat is
column-reverse. */
.summer-stat__note {
display: block;
margin-top: 4px;
font-size: 13px;
font-weight: 400;
line-height: 1.5;
color: var(--c-muted);
}
/* Table kit (Phase 18, UI-SPEC F7): a native-looking table inside a
partial card. The wrapper scrolls horizontally inside the card. */
.summer-table-wrap {
overflow-x: auto;
max-width: 100%;
}
.summer-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
line-height: 1.5;
color: var(--c-text);
}
.summer-table th {
height: var(--spacing-row-head);
padding: 0 14px;
font-size: 12px;
font-weight: 600;
color: var(--c-muted);
text-align: start;
border-bottom: 1px solid var(--c-border);
white-space: nowrap;
}
.summer-table td {
padding: 12px 14px;
vertical-align: top;
border-bottom: 1px solid var(--c-border);
overflow-wrap: anywhere;
}
.summer-table :where(th, td):first-child {
padding-inline-start: 0;
}
.summer-table :where(th, td):last-child {
padding-inline-end: 0;
}
.summer-table__num {
text-align: end;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.summer-table__sub {
display: block;
font-size: 13px;
color: var(--c-muted);
}
.summer-table tfoot td {
padding-top: 16px;
border-bottom: 0;
font-weight: 600;
}
/* Callout (Phase 12.1, UI-SPEC): a status hint above a preview screen. */
.summer-callout {
display: flex;

View File

@@ -13,7 +13,7 @@ import { FIELD_SCOPE, FORM_SESSION, createUploadGate } from '../components/form/
import FormTabs from '../components/form/FormTabs.vue'
import PreviewField from '../components/form/PreviewField.vue'
import RecordActions from '../components/form/RecordActions.vue'
import { RELATION_MANAGER } from '../components/form/registry'
import { RELATION_MANAGER, isRegisteredField } from '../components/form/registry'
import {
DEFAULT_TAB,
contextAllows,
@@ -70,6 +70,13 @@ const fields = computed<FormField[]>(() =>
(schema.value?.fields ?? []).filter((field) => contextAllows(field, 'preview') && !hidden.has(field.type)),
)
// The Edit button renders only when the form holds at least one value field
// the SPA can edit whose context allows update (UI-SPEC F6): a record whose
// form shows only partials has nothing to edit and no primary button.
const hasEditableField = computed(() =>
(schema.value?.fields ?? []).some((field) => isRegisteredField(field) && contextAllows(field, 'update')),
)
// The same tabs as the form; a tab with no preview-visible field never
// appears, because tabs are built from the visible fields only.
const tabs = computed<TabItem[]>(() => {
@@ -290,7 +297,7 @@ void load()
@gone="onGone"
/>
<Button
v-if="!failed"
v-if="!failed && hasEditableField"
variant="primary"
data-action="edit"
:icon="Pencil"