From 48815852fcfebb69df762474cbb1f2210578b98b Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 24 Oct 2026 22:25:27 +0330 Subject: [PATCH] fix(crm): mesghal by metal, jalali fit, scoped group filters --- apps/api/src/contacts/companies.controller.ts | 5 ++ apps/api/src/contacts/companies.service.ts | 33 +++++++++++++ apps/api/src/contacts/people.controller.ts | 5 ++ apps/api/src/contacts/people.service.ts | 33 +++++++++++++ apps/web/src/components/JalaliDateInput.vue | 40 ++++++++++++--- .../TahesabInvoicePreviewModal.vue | 23 +++++++-- apps/web/src/lib/tahesab-invoice-pdf.ts | 49 +++++++++++++++++-- apps/web/src/views/ContactsView.vue | 41 +++++++++++----- 8 files changed, 202 insertions(+), 27 deletions(-) diff --git a/apps/api/src/contacts/companies.controller.ts b/apps/api/src/contacts/companies.controller.ts index 32dd2e7..6a1c1f4 100644 --- a/apps/api/src/contacts/companies.controller.ts +++ b/apps/api/src/contacts/companies.controller.ts @@ -51,6 +51,11 @@ export class CompaniesController { ); } + @Get('group-stats') + groupStats(@CurrentUser() user: Actor) { + return this.companiesService.groupStats(user); + } + @Get('export') async export( @CurrentUser() user: Actor, diff --git a/apps/api/src/contacts/companies.service.ts b/apps/api/src/contacts/companies.service.ts index b095001..04db5b1 100644 --- a/apps/api/src/contacts/companies.service.ts +++ b/apps/api/src/contacts/companies.service.ts @@ -6,6 +6,7 @@ import { import type { CompanyDto, CreateCompanyRequest, + CustomerGroupDto, PaginatedResponse, UpdateCompanyRequest, } from '@mento/shared'; @@ -135,6 +136,38 @@ export class CompaniesService { }; } + /** آمار گروه برای فیلتر شرکت‌ها — فقط شرکت‌های در دسترس کاربر */ + async groupStats(actor: Actor): Promise { + const counts = await this.prisma.companyGroupLink.groupBy({ + by: ['groupId'], + where: { + company: { + organizationId: actor.organizationId, + ...customersCompanyWhere(actor), + }, + }, + _count: { _all: true }, + }); + if (!counts.length) return []; + const countMap = new Map(counts.map((c) => [c.groupId, c._count._all])); + const groups = await this.prisma.customerGroup.findMany({ + where: { + organizationId: actor.organizationId, + id: { in: counts.map((c) => c.groupId) }, + }, + orderBy: [{ position: 'asc' }, { name: 'asc' }], + }); + return groups.map((g) => ({ + id: g.id, + name: g.name, + color: g.color, + position: g.position, + fromTahesab: Boolean(g.tahesabGid), + personCount: 0, + companyCount: countMap.get(g.id) ?? 0, + })); + } + async get(actor: Actor, id: string): Promise { const row = await this.prisma.company.findFirst({ where: { id, organizationId: actor.organizationId, ...customersCompanyWhere(actor) }, diff --git a/apps/api/src/contacts/people.controller.ts b/apps/api/src/contacts/people.controller.ts index cf7c878..87447b4 100644 --- a/apps/api/src/contacts/people.controller.ts +++ b/apps/api/src/contacts/people.controller.ts @@ -89,6 +89,11 @@ export class PeopleController { return this.peopleService.sourceStats(user); } + @Get('group-stats') + groupStats(@CurrentUser() user: Actor) { + return this.peopleService.groupStats(user); + } + @Get('export') async export( @CurrentUser() user: Actor, diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 7adce20..e918d9d 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -10,6 +10,7 @@ import { } from '@nestjs/common'; import type { CreatePersonRequest, + CustomerGroupDto, FilteredIdsResponse, PaginatedResponse, PersonDto, @@ -303,6 +304,38 @@ export class PeopleService implements OnModuleInit { .sort((a, b) => b.count - a.count); } + /** آمار گروه برای فیلتر اشخاص — فقط افراد در دسترس کاربر */ + async groupStats(actor: Actor): Promise { + const counts = await this.prisma.personGroupLink.groupBy({ + by: ['groupId'], + where: { + person: { + organizationId: actor.organizationId, + ...customersPersonWhere(actor), + }, + }, + _count: { _all: true }, + }); + if (!counts.length) return []; + const countMap = new Map(counts.map((c) => [c.groupId, c._count._all])); + const groups = await this.prisma.customerGroup.findMany({ + where: { + organizationId: actor.organizationId, + id: { in: counts.map((c) => c.groupId) }, + }, + orderBy: [{ position: 'asc' }, { name: 'asc' }], + }); + return groups.map((g) => ({ + id: g.id, + name: g.name, + color: g.color, + position: g.position, + fromTahesab: Boolean(g.tahesabGid), + personCount: countMap.get(g.id) ?? 0, + companyCount: 0, + })); + } + onModuleInit() { void this.backfillStoredBirthDates(); } diff --git a/apps/web/src/components/JalaliDateInput.vue b/apps/web/src/components/JalaliDateInput.vue index dab256f..8435b97 100644 --- a/apps/web/src/components/JalaliDateInput.vue +++ b/apps/web/src/components/JalaliDateInput.vue @@ -318,16 +318,32 @@ function place() { const panel = panelRef.value; if (!trigger) return; const rect = trigger.getBoundingClientRect(); - const width = panel?.offsetWidth || (props.mode === 'time' ? (isMobile.value ? 340 : 280) : 320); - const height = panel?.offsetHeight || (props.mode === 'time' ? (isMobile.value ? 460 : 320) : 360); + const width = + panel?.offsetWidth || + (props.mode === 'time' ? (isMobile.value ? 340 : 280) : 328); + const height = + panel?.offsetHeight || + (props.mode === 'time' ? (isMobile.value ? 460 : 320) : 340); + const margin = 8; let nextLeft = rect.left; if (getComputedStyle(document.documentElement).direction === 'rtl') { nextLeft = rect.right - width; } - nextLeft = Math.min(Math.max(8, nextLeft), window.innerWidth - width - 8); - let nextTop = rect.bottom + 8; - if (nextTop + height > window.innerHeight - 8) { - nextTop = Math.max(8, rect.top - height - 8); + nextLeft = Math.min( + Math.max(margin, nextLeft), + Math.max(margin, window.innerWidth - width - margin), + ); + const spaceBelow = window.innerHeight - rect.bottom - margin * 2; + const spaceAbove = rect.top - margin * 2; + let nextTop: number; + if (height <= spaceBelow) { + nextTop = rect.bottom + margin; + } else if (height <= spaceAbove) { + nextTop = rect.top - height - margin; + } else if (spaceBelow >= spaceAbove) { + nextTop = Math.max(margin, window.innerHeight - height - margin); + } else { + nextTop = margin; } left.value = nextLeft; top.value = nextTop; @@ -852,8 +868,9 @@ onBeforeUnmount(() => { position: fixed; z-index: 120; width: min(20.5rem, calc(100vw - 16px)); - max-height: calc(100vh - 16px); - overflow: auto; + /* تقویم باید یکجا دیده شود؛ اسکرول داخل پنل گیج‌کننده است */ + max-height: none; + overflow: visible; background: #fff; border-radius: var(--radius); box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18); @@ -861,6 +878,13 @@ onBeforeUnmount(() => { font-family: IRANSans, Tahoma, sans-serif; } +@media (max-height: 420px) { + .panel { + max-height: calc(100vh - 16px); + overflow: auto; + } +} + .panel-time { width: min(22.5rem, calc(100vw - 16px)); } diff --git a/apps/web/src/components/person-assets/TahesabInvoicePreviewModal.vue b/apps/web/src/components/person-assets/TahesabInvoicePreviewModal.vue index 5c79846..f8b661a 100644 --- a/apps/web/src/components/person-assets/TahesabInvoicePreviewModal.vue +++ b/apps/web/src/components/person-assets/TahesabInvoicePreviewModal.vue @@ -13,6 +13,7 @@ import { printTahesabInvoicePdf, rowInvoiceDesc, formatInvoiceWeightBalance, + invoiceMesghalLabels, sumInvoiceMali, tabdilVaznColumnLabel, type TahesabInvoiceGroup, @@ -71,6 +72,10 @@ const company = computed( const generatedAtLabel = computed(() => props.open ? formatJalaliDateTime(new Date()) : '', ); +const mesghalLabels = computed(() => + invoiceMesghalLabels(sourceRows.value), +); + function printPdf() { if (!props.group) return; const ok = printTahesabInvoicePdf({ @@ -243,8 +248,14 @@ function printPdf() {