Add row selection, bulk delete, row numbers and page size picker to tables; redesign person profile header

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-05 20:00:21 +03:30
co-authored by Cursor
parent 71b3a7255a
commit 48de552f36
15 changed files with 565 additions and 79 deletions
+10
View File
@@ -1,5 +1,7 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsEmail,
IsIn,
@@ -15,6 +17,14 @@ import {
} from 'class-validator';
import { PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared';
export class BulkDeletePeopleDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(500)
@IsString({ each: true })
ids!: string[];
}
export class ListQueryDto {
@IsOptional()
@IsString()
+6 -1
View File
@@ -22,7 +22,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { CreatePersonDto, ListQueryDto, UpdatePersonDto } from './dto';
import { BulkDeletePeopleDto, CreatePersonDto, ListQueryDto, UpdatePersonDto } from './dto';
import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service';
import { brand } from '@mento/shared';
@@ -110,6 +110,11 @@ export class PeopleController {
return this.peopleService.get(user, id);
}
@Post('bulk-delete')
bulkDelete(@CurrentUser() user: Actor, @Body() dto: BulkDeletePeopleDto) {
return this.peopleService.removeMany(user, dto.ids);
}
@Post()
create(@CurrentUser() user: Actor, @Body() dto: CreatePersonDto) {
return this.peopleService.create(user, {
+13
View File
@@ -309,6 +309,19 @@ export class PeopleService {
await this.prisma.person.delete({ where: { id } });
}
async removeMany(actor: Actor, ids: string[]): Promise<{ deleted: number }> {
const unique = [...new Set(ids.filter(Boolean))];
if (!unique.length) return { deleted: 0 };
const res = await this.prisma.person.deleteMany({
where: {
id: { in: unique },
organizationId: actor.organizationId,
...ownerWhere(actor),
},
});
return { deleted: res.count };
}
private async ensureExists(actor: Actor, id: string) {
const found = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
+53 -4
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import UiButton from './UiButton.vue';
import UiIcon from '@/components/UiIcon.vue';
import { toFaDigits } from '@/lib/jalali';
const props = withDefaults(
defineProps<{
@@ -9,6 +10,8 @@ const props = withDefaults(
totalPages?: number;
totalItems?: number;
pageSize?: number;
/** با مقداردهی، انتخاب «تعداد در صفحه» نمایش داده می‌شود */
pageSizeOptions?: number[];
}>(),
{
page: 1,
@@ -17,6 +20,7 @@ const props = withDefaults(
const emit = defineEmits<{
'update:page': [page: number];
'update:pageSize': [size: number];
change: [page: number];
}>();
@@ -28,22 +32,45 @@ const effectiveTotalPages = computed(() => {
return 1;
});
const showSizePicker = computed(() => Boolean(props.pageSizeOptions?.length && props.pageSize));
function goTo(p: number) {
if (p < 1 || p > effectiveTotalPages.value || p === props.page) return;
emit('update:page', p);
emit('change', p);
}
function onSizeChange(e: Event) {
const size = Number((e.target as HTMLSelectElement).value);
if (!size || size === props.pageSize) return;
emit('update:pageSize', size);
if (props.page !== 1) {
emit('update:page', 1);
emit('change', 1);
}
}
</script>
<template>
<nav v-if="effectiveTotalPages > 1 || totalItems !== undefined" class="ui-pagination" aria-label="صفحه‌بندی">
<nav
v-if="effectiveTotalPages > 1 || totalItems !== undefined || showSizePicker"
class="ui-pagination"
aria-label="صفحه‌بندی"
>
<div class="pagination-info">
<span v-if="totalItems !== undefined">
مجموع: <strong>{{ totalItems }}</strong> مورد
مجموع: <strong>{{ toFaDigits(totalItems.toLocaleString('en-US')) }}</strong> مورد
</span>
<span v-if="effectiveTotalPages > 1" class="page-count">
(صفحه {{ page }} از {{ effectiveTotalPages }})
(صفحه {{ toFaDigits(page) }} از {{ toFaDigits(effectiveTotalPages) }})
</span>
<label v-if="showSizePicker" class="size-picker">
<span>نمایش</span>
<select :value="pageSize" @change="onSizeChange">
<option v-for="n in pageSizeOptions" :key="n" :value="n">{{ toFaDigits(n) }}</option>
</select>
<span>در هر صفحه</span>
</label>
</div>
<div v-if="effectiveTotalPages > 1" class="pagination-controls">
@@ -69,7 +96,7 @@ function goTo(p: number) {
</UiButton>
<span class="current-indicator">
{{ page }} / {{ effectiveTotalPages }}
{{ toFaDigits(page) }} / {{ toFaDigits(effectiveTotalPages) }}
</span>
<UiButton
@@ -112,6 +139,7 @@ function goTo(p: number) {
color: var(--muted);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
}
@@ -119,6 +147,27 @@ function goTo(p: number) {
color: var(--muted-light);
}
.size-picker {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-inline-start: var(--sp-2);
font-size: var(--fs-xs);
}
.size-picker select {
height: 30px;
min-width: 3.6rem;
padding: 0 0.45rem;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink);
cursor: pointer;
}
.pagination-controls {
display: inline-flex;
align-items: center;
+25
View File
@@ -0,0 +1,25 @@
import { ref, watch, type Ref } from 'vue';
export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const STORAGE_PREFIX = 'mento_page_size:';
/** تعداد ردیف هر صفحه‌ی جدول که برای هر جدول در مرورگر به خاطر سپرده می‌شود. */
export function usePageSize(tableKey: string, fallback = 20): Ref<number> {
let initial = fallback;
try {
const stored = Number(localStorage.getItem(STORAGE_PREFIX + tableKey));
if (PAGE_SIZE_OPTIONS.includes(stored)) initial = stored;
} catch {
/* storage unavailable */
}
const size = ref(initial);
watch(size, (next) => {
try {
localStorage.setItem(STORAGE_PREFIX + tableKey, String(next));
} catch {
/* storage unavailable */
}
});
return size;
}
+4
View File
@@ -132,6 +132,10 @@ function syncLabels(el: HTMLElement, key: string) {
if (!(el instanceof HTMLTableElement)) return;
el.dataset.tc = key;
const labels = readHeaderLabels(el);
const wantsRowNum = !labels.includes('#') && !el.hasAttribute('data-no-rownum');
if (wantsRowNum !== el.hasAttribute('data-rownum')) {
el.toggleAttribute('data-rownum', wantsRowNum);
}
const prev = labelsByTable[key];
if (!prev || prev.length !== labels.length || prev.some((v, i) => v !== labels[i])) {
labelsByTable[key] = labels;
+45
View File
@@ -269,6 +269,51 @@ textarea::placeholder {
margin: 0 0 0.5rem;
}
th.num-col,
td.num-col {
width: 2.6rem;
text-align: center;
color: #94a3b8;
font-size: 0.8rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
/* شماره ردیف خودکار برای جدول‌های دارای v-table-cols؛ شروع با --row-start (آفست صفحه) */
table[data-rownum] > tbody {
counter-reset: tc-row var(--row-start, 0);
}
table[data-rownum] > tbody > tr:not(:has(> td[colspan])) {
counter-increment: tc-row;
}
table[data-rownum] > thead > tr::before,
table[data-rownum] > tbody > tr::before {
display: table-cell;
width: 2.6rem;
padding: 0.6rem 0.4rem;
text-align: center;
vertical-align: middle;
color: #94a3b8;
font-size: 0.8rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
border-bottom: 1px solid #f1f5f9;
}
table[data-rownum] > thead > tr::before {
content: '#';
}
table[data-rownum] > tbody > tr::before {
content: counter(tc-row, persian);
}
table[data-rownum] > tbody > tr:has(> td[colspan])::before {
content: '';
}
@media (max-width: 900px) {
:root {
--mobile-tabbar-h: calc(3.65rem + env(safe-area-inset-bottom, 0px));
+28 -6
View File
@@ -44,6 +44,7 @@ import type {
import { brand } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canViewAllRecords } from '@/lib/roles';
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
type TypeFilter = 'all' | ActivityType;
type StatusFilter = 'all' | ActivityStatus;
@@ -78,7 +79,7 @@ const STATUS_FILTERS: Array<{ key: StatusFilter; label: string }> = [
{ key: 'canceled', label: 'لغو شده' },
];
const PAGE_SIZE = 6;
const pageSize = usePageSize('activities', 10);
const route = useRoute();
const router = useRouter();
@@ -179,7 +180,7 @@ const statusIcon: Record<UiStatus, 'check' | 'clock' | 'calendar' | 'warning'> =
const items = computed(() => data.value?.items ?? []);
const total = computed(() => data.value?.total ?? 0);
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
const filterLabel = computed(() => TABS.find((t) => t.key === filter.value)?.label ?? 'امروز');
const activeFacetCount = computed(() => {
let n = 0;
@@ -280,7 +281,7 @@ async function load() {
filter: filter.value,
q: q.value || undefined,
page: page.value,
limit: PAGE_SIZE,
limit: pageSize.value,
desk: showDeskTabs.value ? desk.value : undefined,
type: typeFilter.value === 'all' ? undefined : typeFilter.value,
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
@@ -333,6 +334,11 @@ function setPage(next: number) {
});
}
function onPageSize(size: number) {
pageSize.value = size;
if (page.value === 1) void load();
}
function onSearch() {
void router.replace({
name: 'activities',
@@ -691,7 +697,7 @@ onUnmounted(() => {
<template v-else>
<div class="list-head">
<strong>{{ filterLabel }} — {{ toFaDigits(items.length) }} فعالیت</strong>
<strong>{{ filterLabel }} — {{ toFaDigits(total.toLocaleString('en-US')) }} فعالیت</strong>
<RouterLink class="cal-mini" :to="{ name: 'activities-calendar' }" aria-label="تقویم">
<UiIcon name="calendar" :size="16" />
</RouterLink>
@@ -699,6 +705,7 @@ onUnmounted(() => {
<div class="table desktop-only" role="table">
<div class="thead" role="row">
<span class="row-num">#</span>
<span>مشتری / عنوان فعالیت</span>
<span>نوع فعالیت</span>
<span>زمان شروع</span>
@@ -707,11 +714,12 @@ onUnmounted(() => {
<span />
</div>
<article
v-for="item in items"
v-for="(item, idx) in items"
:key="item.id"
class="trow"
role="row"
>
<span class="row-num">{{ toFaDigits((page - 1) * pageSize + idx + 1) }}</span>
<div class="title-cell">
<UiIcon name="star" :size="16" class="star" />
<div>
@@ -846,7 +854,10 @@ onUnmounted(() => {
:page="page"
:total-pages="pageCount"
:total-items="total"
:page-size="pageSize"
:page-size-options="PAGE_SIZE_OPTIONS"
@update:page="setPage"
@update:page-size="onPageSize"
/>
</template>
@@ -1224,7 +1235,7 @@ h1 {
.thead,
.trow {
display: grid;
grid-template-columns: minmax(0, 2.2fr) 7.6rem 7.2rem 8.4rem 8.6rem 2.4rem;
grid-template-columns: 2.4rem minmax(0, 2.2fr) 7.6rem 7.2rem 8.4rem 8.6rem 2.4rem;
gap: 0.8rem;
align-items: center;
padding: 0.95rem 1.1rem;
@@ -1243,6 +1254,13 @@ h1 {
border-bottom: 1px solid #f8fafc;
}
.row-num {
color: #94a3b8;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
text-align: center;
}
.trow:last-child {
border-bottom: 0;
border-radius: 0 0 var(--radius) var(--radius);
@@ -1590,5 +1608,9 @@ time.note {
.trow {
grid-template-columns: 1fr;
}
.row-num {
display: none;
}
}
</style>
+4 -1
View File
@@ -117,7 +117,10 @@ onMounted(() => {
<TableColumnsMenu table-key="audit-logs" align="end" />
</div>
<div class="table-wrap">
<table v-table-cols="'audit-logs'">
<table
v-table-cols="'audit-logs'"
:style="{ '--row-start': (page - 1) * (data?.limit ?? 30) }"
>
<thead>
<tr>
<th>زمان</th>
+145 -10
View File
@@ -36,11 +36,12 @@ import {
PERSON_IMPORT_SOURCES,
} from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
type Tab = 'people' | 'companies';
type SortKey = 'name' | 'newest';
const PAGE_SIZE = 10;
const pageSize = usePageSize('contacts', 10);
const route = useRoute();
const router = useRouter();
@@ -81,7 +82,53 @@ const contactModal = ref<'person' | 'company' | null>(null);
const total = computed(() =>
tab.value === 'people' ? people.value?.total ?? 0 : companies.value?.total ?? 0,
);
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
const rowOffset = computed(() => (page.value - 1) * pageSize.value);
const selectedIds = ref<string[]>([]);
const pageIds = computed(() => people.value?.items.map((p) => p.id) ?? []);
const allSelected = computed(
() => pageIds.value.length > 0 && pageIds.value.every((id) => selectedIds.value.includes(id)),
);
const someSelected = computed(() => selectedIds.value.length > 0 && !allSelected.value);
function toggleAll() {
selectedIds.value = allSelected.value ? [] : [...pageIds.value];
}
function toggleOne(id: string) {
selectedIds.value = selectedIds.value.includes(id)
? selectedIds.value.filter((x) => x !== id)
: [...selectedIds.value, id];
}
async function deleteSelected() {
const ids = [...selectedIds.value];
if (!ids.length) return;
if (!confirm(`${toFaDigits(ids.length)} شخص انتخاب‌شده حذف شوند؟ این کار قابل بازگشت نیست.`)) {
return;
}
busy.value = true;
pageBusy.value = true;
busyMessage.value = 'در حال حذف اشخاص…';
try {
const { data } = await api.post<{ deleted: number }>('/people/bulk-delete', { ids });
importMsg.value = `${toFaDigits(data.deleted)} شخص حذف شد.`;
selectedIds.value = [];
if (data.deleted >= pageIds.value.length && page.value > 1) setPage(page.value - 1);
else await load();
} catch {
error.value = 'حذف اشخاص ناموفق بود.';
} finally {
busy.value = false;
pageBusy.value = false;
}
}
function onPageSize(size: number) {
pageSize.value = size;
if (page.value === 1) void load();
}
const peopleSavedFilters = computed(() => ({
q: q.value.trim() || undefined,
@@ -171,7 +218,7 @@ async function load() {
params: {
q: q.value || undefined,
page: page.value,
limit: PAGE_SIZE,
limit: pageSize.value,
sort: sort.value,
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
importSource:
@@ -184,9 +231,11 @@ async function load() {
]);
people.value = peopleRes.data;
sourceStats.value = statsRes.data;
const visible = new Set(peopleRes.data.items.map((p) => p.id));
selectedIds.value = selectedIds.value.filter((id) => visible.has(id));
} else {
const { data } = await api.get<PaginatedResponse<CompanyDto>>('/companies', {
params: { q: q.value || undefined, page: page.value, limit: PAGE_SIZE },
params: { q: q.value || undefined, page: page.value, limit: pageSize.value },
});
companies.value = data;
}
@@ -547,10 +596,29 @@ onMounted(load);
</article>
</div>
<div v-if="selectedIds.length" class="bulk-bar">
<span>{{ toFaDigits(selectedIds.length) }} شخص انتخاب شده</span>
<button type="button" class="bulk-clear" @click="selectedIds = []">لغو انتخاب</button>
<UiButton variant="danger" size="sm" :disabled="busy" @click="deleteSelected">
<UiIcon name="trash" :size="15" />
حذف انتخاب‌شده‌ها
</UiButton>
</div>
<div v-if="people?.items.length" class="table-wrap">
<table v-table-cols="'contacts.people'" class="data-table">
<thead>
<tr>
<th class="check-col">
<input
type="checkbox"
:checked="allSelected"
:indeterminate="someSelected"
aria-label="انتخاب همه"
@change="toggleAll"
/>
</th>
<th class="num-col">#</th>
<th>وضعیت</th>
<th>نام شخص</th>
<th>منبع</th>
@@ -564,12 +632,19 @@ onMounted(load);
</tr>
</thead>
<tbody>
<tr v-for="p in people.items" :key="p.id">
<tr v-for="(p, idx) in people.items" :key="p.id" :class="{ picked: selectedIds.includes(p.id) }">
<td class="check-col">
<input
type="checkbox"
:checked="selectedIds.includes(p.id)"
:aria-label="`انتخاب ${p.fullName}`"
@change="toggleOne(p.id)"
/>
</td>
<td class="num-col">{{ toFaDigits(rowOffset + idx + 1) }}</td>
<td>
<div class="status">
<CustomerMoodFace :mood="moodOf(p)" :size="40" />
<strong :class="moodOf(p)">{{ moodLabel(moodOf(p)) }}</strong>
<i class="dot" :class="moodOf(p)" />
<div class="status" :title="moodLabel(moodOf(p))">
<CustomerMoodFace :mood="moodOf(p)" :size="34" />
</div>
</td>
<td>
@@ -660,6 +735,7 @@ onMounted(load);
<table v-table-cols="'contacts.companies'" class="data-table">
<thead>
<tr>
<th class="num-col">#</th>
<th>نام شرکت</th>
<th>تلفن</th>
<th>ایمیل</th>
@@ -667,7 +743,8 @@ onMounted(load);
</tr>
</thead>
<tbody>
<tr v-for="c in companies.items" :key="c.id">
<tr v-for="(c, idx) in companies.items" :key="c.id">
<td class="num-col">{{ toFaDigits(rowOffset + idx + 1) }}</td>
<td>
<RouterLink class="name" :to="{ name: 'company-detail', params: { id: c.id } }">
{{ c.name }}
@@ -692,7 +769,10 @@ onMounted(load);
:page="page"
:total-pages="pageCount"
:total-items="total"
:page-size="pageSize"
:page-size-options="PAGE_SIZE_OPTIONS"
@update:page="setPage"
@update:page-size="onPageSize"
/>
<UiModal
@@ -1020,6 +1100,61 @@ th {
font-size: 0.82rem;
}
.check-col {
width: 2.4rem;
padding-inline: 0.75rem 0.25rem;
}
.check-col input {
width: 1rem;
height: 1rem;
margin: 0;
accent-color: #0d9488;
cursor: pointer;
}
.num-col {
width: 2.6rem;
color: #94a3b8;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
padding-inline: 0.25rem;
}
tr.picked td {
background: #f0fdfa;
}
.bulk-bar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.6rem 0.85rem;
margin-bottom: 0.6rem;
border: 1px solid #99f6e4;
background: #f0fdfa;
border-radius: var(--radius);
font-size: var(--fs-sm);
color: #0f766e;
font-weight: 600;
}
.bulk-bar > :last-child {
margin-inline-start: auto;
}
.bulk-clear {
border: 0;
background: none;
padding: 0;
font: inherit;
font-size: var(--fs-xs);
color: #64748b;
text-decoration: underline;
cursor: pointer;
}
.name {
color: #0f766e;
font-weight: 600;
+7 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -75,7 +76,7 @@ const to = ref(typeof route.query.to === 'string' ? route.query.to : '');
const amountMin = ref('');
const amountMax = ref('');
const page = ref(1);
const pageSize = ref(10);
const pageSize = usePageSize('invoices', 10);
const selected = ref<Set<string>>(new Set());
const menuId = ref<string | null>(null);
const filterSheet = ref(false);
@@ -476,6 +477,7 @@ onMounted(load);
@change="toggleSelectAll"
/>
</th>
<th class="num-col">#</th>
<th>شماره فاکتور</th>
<th>تاریخ</th>
<th>مشتری</th>
@@ -488,7 +490,7 @@ onMounted(load);
</tr>
</thead>
<tbody>
<tr v-for="r in pageItems" :key="r.id">
<tr v-for="(r, idx) in pageItems" :key="r.id">
<td class="check">
<input
type="checkbox"
@@ -496,6 +498,7 @@ onMounted(load);
@change="toggleSelect(r.id)"
/>
</td>
<td class="num-col">{{ toFaDigits((page - 1) * pageSize + idx + 1) }}</td>
<td class="mono" dir="ltr">{{ r.number }}</td>
<td>{{ formatDate(r.date) }}</td>
<td>
@@ -591,7 +594,9 @@ onMounted(load);
:total-pages="pageCount"
:total-items="filtered.length"
:page-size="pageSize"
:page-size-options="PAGE_SIZE_OPTIONS"
@update:page="page = $event"
@update:page-size="pageSize = $event"
/>
</template>
+3 -1
View File
@@ -754,6 +754,7 @@ onBeforeUnmount(() => {
<th class="check">
<input type="checkbox" :checked="allSelected" @change="toggleAll" />
</th>
<th class="num-col">#</th>
<th>نام</th>
<th>شرکت</th>
<th>سطح فعلی</th>
@@ -763,10 +764,11 @@ onBeforeUnmount(() => {
</tr>
</thead>
<tbody>
<tr v-for="m in visibleMembers" :key="m.id">
<tr v-for="(m, idx) in visibleMembers" :key="m.id">
<td class="check">
<input type="checkbox" :checked="selected.includes(m.id)" @change="toggleOne(m.id)" />
</td>
<td class="num-col">{{ toFaDigits(idx + 1) }}</td>
<td>
<div class="who">
<span class="avatar">{{ initials(m.fullName) }}</span>
+207 -48
View File
@@ -620,54 +620,73 @@ watch(() => route.query.tab, readTab, { immediate: true });
<p v-if="error" class="error pad">{{ error }}</p>
<article class="hero desk-hero">
<div class="face-col">
<CustomerMoodFace :mood="mood" :size="104" />
<span class="mood-under" :class="mood">{{ moodLabel }}</span>
<div class="hero-top">
<CustomerMoodFace :mood="mood" :size="76" />
<div class="who">
<div class="name-row">
<h2>{{ fullName }}</h2>
<span class="mood-under" :class="mood">{{ moodLabel }}</span>
<span v-if="vip" class="vip-mark">VIP</span>
</div>
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
<div class="meta-chips">
<span v-if="tahesabMoshtariCode != null" class="meta-chip accent">
کد حسابداری {{ toFaDigits(String(tahesabMoshtariCode)) }}
</span>
<span class="meta-chip">منبع: {{ importSourceLabel }}</span>
<span v-if="externalCode" class="meta-chip">
کد مبدأ: <bdi>{{ toFaDigits(externalCode) }}</bdi>
</span>
<span v-if="person.previousFullName" class="meta-chip">
نام قبلی: {{ person.previousFullName }}
</span>
</div>
</div>
<div class="hero-side">
<span class="status-pill" :class="isActiveCustomer ? 'on' : 'off'">
<i />
{{ isActiveCustomer ? 'مشتری فعال' : 'غیرفعال' }}
</span>
<a v-if="phoneNumber" class="contact-pill" :href="telHref || undefined" dir="ltr">
<UiIcon name="phone" :size="15" />
{{ phoneDisplay }}
</a>
<a v-if="person.email" class="contact-pill" :href="mailHref || undefined" dir="ltr">
<UiIcon name="mail" :size="15" />
{{ person.email }}
</a>
</div>
</div>
<div class="who">
<h2>
{{ fullName }}
<span v-if="tahesabMoshtariCode != null" class="tahesab-code" dir="rtl"
>کد حسابداری {{ toFaDigits(String(tahesabMoshtariCode)) }}</span
>
<span v-if="vip" class="vip-mark">VIP</span>
</h2>
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
<p v-if="phoneNumber" class="line">
<UiIcon name="phone" :size="16" />
{{ phoneDisplay }}
</p>
<p v-if="person.email" class="line">
<UiIcon name="mail" :size="16" />
{{ person.email }}
</p>
</div>
<dl class="facts">
<div><dt>سمت</dt><dd>{{ person.position || '—' }}</dd></div>
<div><dt>منبع ورود</dt><dd>{{ importSourceLabel }}</dd></div>
<div><dt>کد مبدأ</dt><dd dir="ltr">{{ externalCode ? toFaDigits(externalCode) : '—' }}</dd></div>
<div><dt>نام قبلی</dt><dd>{{ person.previousFullName || '—' }}</dd></div>
<div><dt>وب‌سایت</dt><dd>{{ person.website || '—' }}</dd></div>
<div><dt>اینستاگرام</dt><dd dir="ltr">{{ person.instagram || '—' }}</dd></div>
<div><dt>معرف</dt><dd>{{ person.referrer || '—' }}</dd></div>
<div class="wide">
<dl class="facts-grid">
<div class="fact">
<dt>شرکت</dt>
<dd>{{ person.company?.name || '—' }}</dd>
</div>
<div class="fact">
<dt>سمت</dt>
<dd>{{ person.position || '—' }}</dd>
</div>
<div class="fact">
<dt>تاریخ ثبت</dt>
<dd>{{ registeredAt }}</dd>
</div>
<div v-if="person.referrer" class="fact">
<dt>معرف</dt>
<dd>{{ person.referrer }}</dd>
</div>
<div v-if="person.website" class="fact">
<dt>وب‌سایت</dt>
<dd dir="ltr">{{ person.website }}</dd>
</div>
<div v-if="person.instagram" class="fact">
<dt>اینستاگرام</dt>
<dd dir="ltr">{{ person.instagram }}</dd>
</div>
<div v-if="person.addresses?.length" class="fact wide">
<dt>آدرس‌ها</dt>
<dd>
<template v-if="person.addresses?.length">
<p v-for="(addr, i) in person.addresses" :key="i" class="addr-line">{{ addr }}</p>
</template>
<template v-else>—</template>
</dd>
</div>
<div><dt>شرکت</dt><dd>{{ person.company?.name || '—' }}</dd></div>
<div><dt>تلفن</dt><dd>{{ phoneDisplay }}</dd></div>
<div><dt>ایمیل</dt><dd>{{ emailDisplay }}</dd></div>
<div><dt>تاریخ ثبت</dt><dd>{{ registeredAt }}</dd></div>
<div>
<dt>وضعیت</dt>
<dd class="status" :class="isActiveCustomer ? 'on' : 'off'">
<i />
{{ isActiveCustomer ? 'فعال' : 'غیرفعال' }}
<p v-for="(addr, i) in person.addresses" :key="i" class="addr-line">{{ addr }}</p>
</dd>
</div>
</dl>
@@ -1108,10 +1127,150 @@ watch(() => route.query.tab, readTab, { immediate: true });
.desk-hero {
display: none;
grid-template-columns: auto minmax(0, 1.2fr) minmax(240px, 0.9fr);
gap: 1.4rem;
grid-template-columns: minmax(0, 1fr);
gap: 0;
padding: 0;
overflow: hidden;
}
.hero-top {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 1.1rem;
align-items: center;
padding: 1.35rem 1.4rem;
padding: 1.25rem 1.4rem;
background: linear-gradient(135deg, #f0fdfa 0%, #fff 60%);
}
.name-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.desk-hero .who h2 {
font-size: 1.3rem;
line-height: 1.5;
}
.desk-hero .sub {
margin: 0.15rem 0 0;
}
.meta-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.6rem;
}
.meta-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.6rem;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 0.75rem;
font-weight: 600;
}
.meta-chip.accent {
background: #ccfbf1;
color: #0f766e;
}
.hero-side {
display: grid;
justify-items: end;
gap: 0.45rem;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.7rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
background: #f1f5f9;
color: #64748b;
}
.status-pill i {
width: 7px;
height: 7px;
border-radius: 999px;
background: currentColor;
}
.status-pill.on {
background: #dcfce7;
color: #15803d;
}
.contact-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.8rem;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
color: #0f766e;
font-size: 0.86rem;
font-weight: 700;
text-decoration: none;
max-width: 18rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-pill:hover {
border-color: #5eead4;
background: #f0fdfa;
}
.facts-grid {
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
gap: 0;
border-top: 1px solid #f1f5f9;
}
.fact {
padding: 0.85rem 1.4rem;
border-inline-start: 1px solid #f1f5f9;
min-width: 0;
}
.fact:first-child {
border-inline-start: 0;
}
.fact.wide {
grid-column: 1 / -1;
border-inline-start: 0;
border-top: 1px solid #f1f5f9;
}
.fact dt {
color: #94a3b8;
font-size: 0.75rem;
margin-bottom: 0.25rem;
}
.fact dd {
margin: 0;
color: #0f172a;
font-size: 0.9rem;
font-weight: 600;
overflow-wrap: anywhere;
}
.face-col {
+10 -5
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -27,7 +28,7 @@ import { brand } from '@mento/shared';
type SortKey = 'name' | 'newest' | 'price';
type StatusFilter = 'all' | 'active' | 'inactive';
const PAGE_SIZE = 10;
const pageSize = usePageSize('products', 10);
const router = useRouter();
const loading = ref(false);
@@ -87,8 +88,8 @@ const filtered = computed(() => {
});
const pageItems = computed(() => {
const start = (page.value - 1) * PAGE_SIZE;
return filtered.value.slice(start, start + PAGE_SIZE);
const start = (page.value - 1) * pageSize.value;
return filtered.value.slice(start, start + pageSize.value);
});
const allPageSelected = computed(
() =>
@@ -364,6 +365,7 @@ onMounted(load);
@change="toggleSelectAll"
/>
</th>
<th class="num-col">#</th>
<th>نام محصول</th>
<th>کد</th>
<th>دسته‌بندی</th>
@@ -375,7 +377,7 @@ onMounted(load);
</tr>
</thead>
<tbody>
<tr v-for="p in pageItems" :key="p.id">
<tr v-for="(p, idx) in pageItems" :key="p.id">
<td class="check">
<input
type="checkbox"
@@ -383,6 +385,7 @@ onMounted(load);
@change="toggleSelect(p.id)"
/>
</td>
<td class="num-col">{{ toFaDigits((page - 1) * pageSize + idx + 1) }}</td>
<td>
<div class="name-cell">
<span class="thumb" :class="p.kind">
@@ -501,7 +504,9 @@ onMounted(load);
v-if="filtered.length"
v-model:page="page"
:total-items="filtered.length"
:page-size="PAGE_SIZE"
:page-size="pageSize"
:page-size-options="PAGE_SIZE_OPTIONS"
@update:page-size="pageSize = $event; page = 1"
/>
</template>
+5 -1
View File
@@ -818,7 +818,11 @@ onMounted(load);
<TableColumnsMenu table-key="tahesab.customers" align="end" />
</div>
<div class="crm-table-container">
<table v-table-cols="'tahesab.customers'" class="crm-table">
<table
v-table-cols="'tahesab.customers'"
class="crm-table"
:style="{ '--row-start': (syncTablePage - 1) * SYNC_TABLE_PAGE_SIZE }"
>
<thead>
<tr>
<th>کد مشتری</th>