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:
13
admin/src/api/schema.d.ts
vendored
13
admin/src/api/schema.d.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user