ui(admin-panel): responsive persons menu, XTable phone cards, full-screen form modal

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
s.esmaeili
2026-10-13 09:08:38 +03:30
co-authored by Cursor
parent 28b0a4be38
commit 0a2466ca0c
15 changed files with 453 additions and 25 deletions
@@ -14,6 +14,7 @@
:ok-text="okText"
cancel-text="انصراف"
:mask-closable="false"
wrap-class-name="base-form-modal"
@update:open="onUpdateOpen"
@ok="$emit('submit')"
@cancel="onCancel"
@@ -74,3 +75,44 @@ function onCancel() {
emit('cancel');
}
</script>
<!-- Unscoped: <a-modal> teleports to <body>; contained by wrap-class-name. -->
<style>
@media (max-width: 575px) {
.base-form-modal .ant-modal {
/* ant sets the width inline from :width */
width: 100vw !important;
max-width: 100vw;
top: 0;
margin: 0;
padding-bottom: 0;
}
.base-form-modal .ant-modal-content {
display: flex;
flex-direction: column;
min-height: calc(var(--vh, 1vh) * 100);
border-radius: 0;
padding: 14px 16px 0;
}
.base-form-modal .ant-modal-header {
margin-bottom: 12px;
}
.base-form-modal .ant-modal-body {
flex: 1;
}
.base-form-modal .ant-modal-footer {
position: sticky;
bottom: 0;
display: flex;
gap: 8px;
margin: 0 -16px;
padding: 10px 16px;
background: #fff;
border-top: 1px solid #f0f0f0;
}
.base-form-modal .ant-modal-footer > .ant-btn {
flex: 1;
margin: 0 !important;
}
}
</style>
@@ -11,15 +11,15 @@
backend does not implement. The buttons are omitted rather than left as
dead controls that silently fail.
-->
<div class="x-table">
<div class="x-table" :class="{ 'x-table--cards': cardMode }">
<div class="x-table__toolbar">
<div class="x-table__search-group">
<a-input-search
v-if="!model._hideSearch"
v-model:value="query.search"
class="x-table__search"
placeholder="جستجو"
allow-clear
style="max-width: 260px"
@search="onSearch"
/>
@@ -57,6 +57,18 @@
<template #icon><ReloadOutlined /></template>
</a-button>
</a-tooltip>
<!-- card view has no column headers to click, so sorting moves here -->
<a-select
v-if="cardMode && sortOptions.length"
:value="sortValue"
class="x-table__sort"
size="small"
placeholder="مرتب‌سازی"
allow-clear
:options="sortOptions"
@change="onSortSelect"
/>
</div>
</div>
@@ -109,7 +121,52 @@
</div>
</div>
<div v-if="cardMode" class="x-table__cards">
<a-spin :spinning="loading">
<a-empty v-if="!data.length && !loading" :description="locale.emptyText" />
<div v-for="record in data" :key="String(record.id)" class="x-card">
<div class="x-card__head">
<div class="x-card__title">
<span v-if="cardIdCol" class="x-card__id">#{{ cellValue(record, cardIdCol) }}</span>
<span class="x-card__name">
<template v-for="col in cardTitleCols" :key="col.key">
<slot v-if="col.slot" :name="col.slot" :text="cellValue(record, col)" :record="record" />
<template v-else>{{ displayValue(record, col) }} </template>
</template>
</span>
</div>
<div v-if="cardActionCol" class="x-card__actions">
<slot :name="cardActionCol.slot" :text="cellValue(record, cardActionCol)" :record="record" />
</div>
</div>
<dl v-if="cardBodyCols.length" class="x-card__body">
<div v-for="col in cardBodyCols" :key="col.key" class="x-card__row">
<dt>{{ col.title }}</dt>
<dd>
<slot v-if="col.slot" :name="col.slot" :text="cellValue(record, col)" :record="record" />
<template v-else>{{ displayValue(record, col) }}</template>
</dd>
</div>
</dl>
</div>
</a-spin>
<div v-if="!model._hidePagination && query.total > 0" class="x-table__card-pager">
<span class="x-table__card-total">{{ query.total }} مورد</span>
<a-pagination
size="small"
show-less-items
:current="query.page"
:page-size="query.limit"
:total="query.total"
:show-size-changer="false"
@change="onCardPage"
/>
</div>
</div>
<a-table
v-else
:columns="antColumns"
:data-source="data"
:loading="loading"
@@ -117,7 +174,8 @@
:locale="locale"
:row-selection="rowSelection"
row-key="id"
size="middle"
:size="layout.isMobile ? 'small' : 'middle'"
:scroll="layout.isSmallScreen ? { x: 'max-content' } : undefined"
bordered
@change="onTableChange"
>
@@ -140,8 +198,9 @@
import { computed, onMounted, reactive, ref } from 'vue';
import { FilterOutlined, PrinterOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons-vue';
import api from '../../api/client';
import type { XTbl } from './model';
import type { XTbl, Xtc } from './model';
import { gridDate } from '../../utils/filters';
import { useLayoutStore } from '../../stores/layout';
const props = withDefaults(
defineProps<{
@@ -150,10 +209,16 @@ const props = withDefaults(
hasSelection?: boolean;
/** legacy used v-model for the selected rows */
modelValue?: Record<string, unknown>[];
/** On phones, render each row as a card instead of a wide table. Opt-in per page. */
mobileCards?: boolean;
}>(),
{ hasSelection: false, modelValue: () => [] },
{ hasSelection: false, modelValue: () => [], mobileCards: false },
);
const layout = useLayoutStore();
// row selection needs the table's checkbox column, so it always keeps the table
const cardMode = computed(() => props.mobileCards && !props.hasSelection && layout.isMobile);
const emit = defineEmits<{
(e: 'query', q: unknown): void;
(e: 'update:modelValue', rows: Record<string, unknown>[]): void;
@@ -234,6 +299,84 @@ function slotNameFor(column: { __slot?: string }) {
return column.__slot;
}
// ── phone card view ──────────────────────────────────────────────────
interface CardCol {
key: string;
title: string;
path: string[];
slot?: string;
date: boolean;
}
const ACTION_KEYS = ['opt', 'op', 'action'];
const cardCols = computed<CardCol[]>(() =>
visibleCols.value.map((c: Xtc) => ({
key: c.key,
title: c.title,
path: c.dataIndex.split('.'),
slot: c.scopedSlots?.customRender,
date: c.filterType === 'date',
})),
);
const cardActionCol = computed(() => cardCols.value.find((c) => ACTION_KEYS.includes(c.key) && c.slot));
const cardIdCol = computed(() => cardCols.value.find((c) => c.key === 'id'));
const cardTitleCols = computed<CardCol[]>(() => {
const rest = cardCols.value.filter((c) => c !== cardActionCol.value && c !== cardIdCol.value);
const named = rest.find((c) => c.key === 'name' || c.key === 'title');
if (named) return [named];
const fname = rest.find((c) => c.key === 'fname');
if (fname) return [fname, ...rest.filter((c) => c.key === 'lname')];
return rest.slice(0, 1);
});
const cardBodyCols = computed(() =>
cardCols.value.filter(
(c) => c !== cardActionCol.value && c !== cardIdCol.value && !cardTitleCols.value.includes(c),
),
);
function cellValue(record: Record<string, unknown>, col: CardCol): unknown {
return col.path.reduce<unknown>(
(v, k) => (v && typeof v === 'object' ? (v as Record<string, unknown>)[k] : undefined),
record,
);
}
function displayValue(record: Record<string, unknown>, col: CardCol) {
const v = cellValue(record, col);
if (v === null || v === undefined || v === '') return '-';
return col.date ? gridDate(v) : v;
}
const sortOptions = computed(() =>
visibleCols.value
.filter((c) => c.__sorter)
.flatMap((c) => [
{ value: `${c.key}|asc`, label: `${c.title} ↑` },
{ value: `${c.key}|desc`, label: `${c.title} ↓` },
]),
);
const sortValue = computed(() => (sort.value ? `${sort.value}|${sortDirection.value}` : undefined));
function onSortSelect(value?: string) {
if (value) {
const [key, dir] = value.split('|');
sort.value = key;
sortDirection.value = dir as 'asc' | 'desc';
} else {
sort.value = undefined;
}
query.page = 1;
fetch();
}
function onCardPage(page: number, pageSize: number) {
query.page = page;
query.limit = pageSize;
fetch();
document.getElementById('content-area')?.scrollTo({ top: 0, behavior: 'smooth' });
}
/** Resolves a filter row's column back to its Xtc definition. */
function colForField(field?: string) {
if (!field) return undefined;
@@ -401,4 +544,147 @@ defineExpose({ fetch });
.refresh-btn {
transition: all 0.15s ease;
}
.x-table__search {
max-width: 260px;
}
.x-table__sort {
width: 150px;
}
/* ── phone card view ── */
.x-table__cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.x-table__cards :deep(.ant-spin-container) {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 80px;
}
.x-card {
border: 1px solid #eef0f3;
border-radius: 10px;
background: #fff;
overflow: hidden;
}
.x-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
background: #fafbfc;
border-bottom: 1px solid #eef0f3;
}
.x-card__title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.x-card__id {
flex-shrink: 0;
font-size: 11px;
color: #8a909c;
background: #eef0f3;
border-radius: 4px;
padding: 1px 6px;
direction: ltr;
}
.x-card__name {
font-weight: 600;
color: #1c2d56;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.x-card__actions {
flex-shrink: 0;
font-size: 16px;
}
.x-card__body {
margin: 0;
padding: 4px 12px;
}
.x-card__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 7px 0;
font-size: 13px;
}
.x-card__row + .x-card__row {
border-top: 1px dashed #f0f0f0;
}
.x-card__row dt {
color: #8a909c;
flex-shrink: 0;
}
.x-card__row dd {
margin: 0;
color: #333;
text-align: left;
min-width: 0;
word-break: break-word;
}
.x-card__row dd :deep(.ant-tag) {
margin: 0;
}
.x-table__card-pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.x-table__card-total {
font-size: 12px;
color: #8a909c;
}
@media (max-width: 575px) {
.x-table__toolbar {
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
/* flatten so search takes row one and actions + add share row two */
.x-table__search-group {
display: contents;
}
.x-table__search {
max-width: none;
flex: 1 1 100%;
}
.x-table__actions {
gap: 2px;
}
.x-table__sort {
width: 130px;
}
.x-table__add {
margin-inline-start: auto;
}
.x-table__filters {
padding: 10px;
}
.x-table__filter-row {
display: grid;
grid-template-columns: 1fr 1fr;
padding-bottom: 8px;
border-bottom: 1px dashed #e8e8e8;
}
/* the filter inputs carry fixed inline widths for desktop */
.x-table__filter-row > * {
width: 100% !important;
}
.x-table__filter-row > :nth-child(n + 3) {
grid-column: 1 / -1;
}
.x-table__filter-actions > * {
flex: 1;
}
}
</style>