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
+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;