feat(10-03): search, sort, filter, page and bulk-delete any list

- List state (search, sort, dir, page, per_page, filter[<name>]) lives in
  the URL query through parseListQuery/toListQuery; every change is a
  router replace and clears the selection; search is debounced 300 ms and
  resets the page
- DataTable renders the schema columns with a tri-state page checkbox,
  asc/desc/none sorting with aria-sort, selected rows, a sticky header,
  eight skeleton rows while loading and an empty slot
- CellValue renders text (muted dash when empty, arrays comma-joined),
  datetime as YYYY-MM-DD HH:mm and switch as the Tak/Nie pills
- The heading shows the plural recordCount and the create button;
  delete sits in the toolbar, disabled without a selection, and confirms
  with the plural deleteConfirm in a Reka alert dialog before POSTing
  bulk-delete; a 409 shows a danger toast
- FilterBar renders switch (JSON of the option value), daterange
  (from..to) and scope filters (choices from filters/{scope}/options)
- Pagination shows the range, the per-page select over perPageOptions
  (hidden with one choice) and a pager with ellipsis
- The tracer smoke test skips the new checkbox column; new backend::lang
  list keys; boardwalk/dist rebuilt
This commit is contained in:
Jakub Zych
2026-09-27 16:51:00 +02:00
parent 126ca5b8ed
commit 8b5f85603f
22 changed files with 1421 additions and 118 deletions

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import type { ListMeta } from '../../api/types'
import { t, tc } from '../../app/i18n'
// List footer (design screen 3): the range ":from–:to z :total", the
// per-page select over the schema's options (hidden with one choice,
// research Gap 7) and a pager with ellipsis. Zero rows show the plural
// results message and no pager.
const props = defineProps<{
meta: ListMeta | null
loading: boolean
perPageOptions: number[]
perPage: number
}>()
const emit = defineEmits<{ page: [page: number]; perPage: [perPage: number] }>()
const total = computed(() => props.meta?.total ?? 0)
const page = computed(() => props.meta?.page ?? 1)
const lastPage = computed(() => Math.max(props.meta?.last_page ?? 1, 1))
const range = computed(() => {
const m = props.meta
if (!m || m.total === 0) {
return tc('backend::lang.list.results', 0)
}
const from = (m.page - 1) * m.per_page + 1
const to = Math.min(m.page * m.per_page, m.total)
return t('backend::lang.list.pagination_range', { from, to, total: m.total })
})
/** Page numbers with null for an ellipsis: 1 … p-1 p p+1 … last. */
const pages = computed<Array<number | null>>(() => {
const last = lastPage.value
const current = page.value
const wanted = new Set([1, last, current - 1, current, current + 1].filter((n) => n >= 1 && n <= last))
if (current <= 3) {
;[2, 3].forEach((n) => n <= last && wanted.add(n))
}
if (current >= last - 2) {
;[last - 1, last - 2].forEach((n) => n >= 1 && wanted.add(n))
}
const sorted = [...wanted].sort((a, b) => a - b)
const out: Array<number | null> = []
sorted.forEach((n, index) => {
const prev = sorted[index - 1]
if (prev !== undefined && n - prev > 1) {
out.push(null)
}
out.push(n)
})
return out
})
function onPerPage(event: Event): void {
const value = Number((event.target as HTMLSelectElement).value)
if (Number.isFinite(value) && value > 0) {
emit('perPage', value)
}
}
const pagerButton =
'inline-flex h-pager min-w-pager items-center justify-center gap-1 rounded-pager border border-border px-2.5 text-[13px] text-text disabled:cursor-not-allowed disabled:opacity-60 hover:bg-hover'
</script>
<template>
<footer class="flex flex-wrap items-center gap-x-6 gap-y-3 border-t border-border px-5 py-3.5 text-[13px] text-muted">
<span data-range>{{ loading ? t('backend::lang.list.loading') : range }}</span>
<label v-if="perPageOptions.length > 1" class="flex items-center gap-2">
<span>{{ t('backend::lang.list.records_per_page') }}</span>
<select
data-per-page
:value="String(perPage)"
class="h-pager rounded-pager border border-border-strong bg-surface px-2 text-text"
@change="onPerPage"
>
<option v-for="option in perPageOptions" :key="option" :value="String(option)">{{ option }}</option>
</select>
</label>
<nav
v-if="!loading && total > 0"
:aria-label="t('backend::lang.list.pagination')"
class="ml-auto flex items-center gap-1.5"
>
<button type="button" data-page="prev" :class="pagerButton" :disabled="page <= 1" @click="emit('page', page - 1)">
<ChevronLeft :size="14" aria-hidden="true" />{{ t('backend::lang.list.prev_page') }}
</button>
<template v-for="(n, index) in pages" :key="n ?? `gap-${index}`">
<span v-if="n === null" aria-hidden="true" class="px-1">…</span>
<button
v-else
type="button"
:data-page="n"
:aria-current="n === page ? 'page' : undefined"
:class="[pagerButton, n === page ? 'border-transparent bg-sel font-bold' : '']"
@click="n !== page && emit('page', n)"
>
{{ n }}
</button>
</template>
<button
type="button"
data-page="next"
:class="pagerButton"
:disabled="page >= lastPage"
@click="emit('page', page + 1)"
>
{{ t('backend::lang.list.next_page') }}<ChevronRight :size="14" aria-hidden="true" />
</button>
</nav>
</footer>
</template>