From 0a2466ca0c7bab53489036afc322ec66aa4d0088 Mon Sep 17 00:00:00 2001 From: "s.esmaeili" Date: Tue, 13 Oct 2026 09:08:38 +0330 Subject: [PATCH] ui(admin-panel): responsive persons menu, XTable phone cards, full-screen form modal Co-authored-by: Cursor --- .../src/assets/styles/utilities.less | 47 +++ .../src/components/common/BaseFormModal.vue | 42 +++ .../src/components/x-table/XTable.vue | 296 +++++++++++++++++- .../src/views/apps/customer/AddCustomer.vue | 2 +- .../views/apps/customer/EditBulkCustomer.vue | 2 +- .../src/views/apps/customer/EditReagent.vue | 2 +- .../apps/customer/EditSingleCustomer.vue | 2 +- .../views/apps/customer/ReagentCustomer.vue | 8 +- .../src/views/apps/customer/bulkCustomer.vue | 2 +- .../views/apps/customer/singleCustomer.vue | 2 +- .../src/views/customer/CustomerDetail.vue | 25 +- .../views/customer/omde/OmdeCustomerList.vue | 15 +- .../customer/reagentList/AgentDetail.vue | 25 +- .../customer/reagentList/ReagentList.vue | 4 +- .../single-customer/SingleCustomer.vue | 4 +- 15 files changed, 453 insertions(+), 25 deletions(-) diff --git a/apps/admin-panel/src/assets/styles/utilities.less b/apps/admin-panel/src/assets/styles/utilities.less index e008cc2..6e419cf 100644 --- a/apps/admin-panel/src/assets/styles/utilities.less +++ b/apps/admin-panel/src/assets/styles/utilities.less @@ -125,3 +125,50 @@ body #app .ant-menu-item-selected { .content-area__content > .ant-card { flex: 1; } + +// ── Phone page chrome ──────────────────────────────────────────────── +// Opt-in per page (class="page-card" on the page's root card) as each page is +// made responsive. Base-form modals get the same treatment for their body. +@media (max-width: 575px) { + .page-card, + .base-form-modal .ant-modal-body { + .ant-card-head { + min-height: 44px; + padding: 0 12px; + } + .ant-card-head-title { + font-size: 14px; + padding: 10px 0; + } + .ant-card-extra { + padding: 6px 0; + } + .ant-card-body { + padding: 12px; + } + .mb-base { + margin-bottom: 12px; + } + .ant-form-item { + margin-bottom: 12px; + } + .grid-2, + .grid-3, + .grid-4 { + row-gap: 0; + column-gap: 12px; + } + .ant-tabs-nav { + margin-bottom: 12px; + } + .form-submit { + width: 100%; + margin: 0; + } + } + // a nested section card inside the page card: drop the double frame + .page-card .ant-card .ant-card, + .page-card > .ant-card-body .ant-card { + box-shadow: none; + } +} diff --git a/apps/admin-panel/src/components/common/BaseFormModal.vue b/apps/admin-panel/src/components/common/BaseFormModal.vue index 8d516c6..dbd63e5 100644 --- a/apps/admin-panel/src/components/common/BaseFormModal.vue +++ b/apps/admin-panel/src/components/common/BaseFormModal.vue @@ -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'); } + + + diff --git a/apps/admin-panel/src/components/x-table/XTable.vue b/apps/admin-panel/src/components/x-table/XTable.vue index d0cb865..14e3bb5 100644 --- a/apps/admin-panel/src/components/x-table/XTable.vue +++ b/apps/admin-panel/src/components/x-table/XTable.vue @@ -11,15 +11,15 @@ backend does not implement. The buttons are omitted rather than left as dead controls that silently fail. --> -
+
@@ -57,6 +57,18 @@ + + +
@@ -109,7 +121,52 @@
+
+ + +
+
+
+ #{{ cellValue(record, cardIdCol) }} + + + +
+
+ +
+
+
+
+
{{ col.title }}
+
+ + +
+
+
+
+
+ +
+ {{ query.total }} مورد + +
+
+ @@ -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[]; + /** 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[]): 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(() => + 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(() => { + 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, col: CardCol): unknown { + return col.path.reduce( + (v, k) => (v && typeof v === 'object' ? (v as Record)[k] : undefined), + record, + ); +} + +function displayValue(record: Record, 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; + } +} diff --git a/apps/admin-panel/src/views/apps/customer/AddCustomer.vue b/apps/admin-panel/src/views/apps/customer/AddCustomer.vue index 9e4e940..d984e0b 100644 --- a/apps/admin-panel/src/views/apps/customer/AddCustomer.vue +++ b/apps/admin-panel/src/views/apps/customer/AddCustomer.vue @@ -15,7 +15,7 @@ -->
- + diff --git a/apps/admin-panel/src/views/apps/customer/EditBulkCustomer.vue b/apps/admin-panel/src/views/apps/customer/EditBulkCustomer.vue index ac3ea2e..567de18 100644 --- a/apps/admin-panel/src/views/apps/customer/EditBulkCustomer.vue +++ b/apps/admin-panel/src/views/apps/customer/EditBulkCustomer.vue @@ -4,7 +4,7 @@ a 19-line wrapper whose entire template is (bulkCustomer). bulkCustomer reads the :id route param to switch between create and edit. --> - + + + diff --git a/apps/admin-panel/src/views/customer/reagentList/AgentDetail.vue b/apps/admin-panel/src/views/customer/reagentList/AgentDetail.vue index bab235a..2681d41 100644 --- a/apps/admin-panel/src/views/customer/reagentList/AgentDetail.vue +++ b/apps/admin-panel/src/views/customer/reagentList/AgentDetail.vue @@ -11,8 +11,8 @@ those customers' orders instead. --> - - + + {{ agent?.name ?? '-' }} {{ agent?.mobile ?? '-' }} {{ agent?.branch?.name ?? '-' }} @@ -33,7 +33,7 @@
- + { diff --git a/apps/admin-panel/src/views/customer/reagentList/ReagentList.vue b/apps/admin-panel/src/views/customer/reagentList/ReagentList.vue index f9354a3..50e2557 100644 --- a/apps/admin-panel/src/views/customer/reagentList/ReagentList.vue +++ b/apps/admin-panel/src/views/customer/reagentList/ReagentList.vue @@ -16,8 +16,8 @@ endpoints it used to hit (which would 404/target the wrong row entirely). -->
- - + +