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:
@@ -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()
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user