fix(crm): mesghal by metal, jalali fit, scoped group filters

This commit is contained in:
2026-10-24 22:25:27 +03:30
parent f8128ebe3a
commit 48815852fc
8 changed files with 202 additions and 27 deletions
@@ -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,
@@ -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<CustomerGroupDto[]> {
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<CompanyDto> {
const row = await this.prisma.company.findFirst({
where: { id, organizationId: actor.organizationId, ...customersCompanyWhere(actor) },
@@ -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,
+33
View File
@@ -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<CustomerGroupDto[]> {
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();
}
+32 -8
View File
@@ -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));
}
@@ -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,9 +248,15 @@ function printPdf() {
<div class="account-row">
<div class="mesghal-box">
<span class="ib-k">قیمت یک مثقال نقره خام:</span>
<div
v-for="label in mesghalLabels"
:key="label"
class="mesghal-line"
>
<span class="ib-k">{{ label }}</span>
<span class="mesghal-blank" aria-hidden="true" />
</div>
</div>
<div class="account-box">
<div class="acc-line">
<span class="acc-title">حساب قبل:</span>
@@ -612,11 +623,17 @@ th {
border-radius: 10px;
background: transparent;
padding: 8px 12px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
min-height: 2.5rem;
}
.mesghal-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 2.5rem;
}
.mesghal-blank {
flex: 1;
+46 -3
View File
@@ -130,6 +130,34 @@ export function invoiceMetalCounts(rows: Record<string, unknown>[]): {
return { gold, silver };
}
/**
* فلزهای وزنی سند برای برچسب مثقال — ردیف‌های صرفاً مالی (وزن ۰) نادیده گرفته می‌شوند
* چون Jens_Felez آن‌ها اغلب طلا (۰) است.
*/
export function invoiceMesghalMetals(rows: Record<string, unknown>[]): {
gold: boolean;
silver: boolean;
} {
let gold = false;
let silver = false;
for (const r of rows) {
if (numOrZero(r.Vazn) === 0 && numOrZero(r.TabdilVazn) === 0) continue;
const m = invoiceRowMetal(r);
if (m === 'gold') gold = true;
else if (m === 'silver') silver = true;
}
return { gold, silver };
}
/** برچسب‌های خط خالی مثقال برای نوشتن دستی روی فاکتور */
export function invoiceMesghalLabels(rows: Record<string, unknown>[]): string[] {
const { gold, silver } = invoiceMesghalMetals(rows);
const labels: string[] = [];
if (gold) labels.push('قیمت یک مثقال طلا:');
if (silver) labels.push('قیمت یک مثقال نقره خام:');
return labels;
}
/**
* عنوان ستون وزن تبدیلی — در ته‌حساب معمولاً نقره به ۹۲۵ و طلا به ۷۵۰ تبدیل می‌شود.
* سند مخلوط → «وزن تبدیلی».
@@ -285,8 +313,17 @@ export function printTahesabInvoicePdf(opts: {
: '—';
const codeFa =
moshtariCode != null ? toFaDigits(String(moshtariCode)) : '—';
// ته‌حساب قیمت مثقال نقرهٔ خامِ لحظهٔ ثبت را در سند برنمی‌گرداند؛ خط خالی برای نوشتن دستی
const mesghalHtml = `<div class="mesghal-box"><span class="ib-k">قیمت یک مثقال نقره خام:</span><span class="mesghal-blank"></span></div>`;
// ته‌حساب قیمت مثقال لحظهٔ ثبت را برنمی‌گرداند؛ خط خالی برای نوشتن دستی (بر اساس فلز سند)
const mesghalLabels = invoiceMesghalLabels(group.rows);
const mesghalHtml =
mesghalLabels.length > 0
? `<div class="mesghal-box">${mesghalLabels
.map(
(label) =>
`<div class="mesghal-line"><span class="ib-k">${escapeHtml(label)}</span><span class="mesghal-blank"></span></div>`,
)
.join('')}</div>`
: `<div class="mesghal-box"></div>`;
const accountHtml = `<div class="account-box">
<div class="acc-line">
<span class="acc-title">حساب قبل:</span>
@@ -553,11 +590,17 @@ export function printTahesabInvoicePdf(opts: {
border-radius: 10px;
background: transparent;
padding: 8px 12px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
min-height: 2.5rem;
}
.mesghal-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 2.5rem;
}
.mesghal-blank {
flex: 1;
+22 -7
View File
@@ -24,6 +24,7 @@ import SavedFiltersBar from '@/components/SavedFiltersBar.vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import type {
CompanyDto,
CustomerGroupDto,
ImportContactsResult,
PaginatedResponse,
PersonDto,
@@ -82,6 +83,8 @@ const discFilter = computed(() => {
const sourceFilters = computed(() => queryList('source'));
const groupFilters = computed(() => queryList('group'));
const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups();
/** گروه‌های فیلتر — با شمارش scoped بر اساس دسترسی کاربر */
const filterGroups = ref<CustomerGroupDto[]>([]);
const canManageGroups = computed(() => canManageSettings(auth.user?.role));
const groupsManagerOpen = ref(false);
@@ -95,7 +98,9 @@ const filterValue = computed<ContactsFilterValue>(() => ({
type FilterTag = { kind: 'group' | 'source' | 'mood' | 'disc'; value: string; label: string; color?: string };
const activeTags = computed<FilterTag[]>(() => {
const tags: FilterTag[] = groupFilters.value.map((id) => {
const g = customerGroups.value.find((x) => x.id === id);
const g =
filterGroups.value.find((x) => x.id === id) ??
customerGroups.value.find((x) => x.id === id);
return { kind: 'group', value: id, label: g?.name ?? 'گروه حذف‌شده', color: g?.color };
});
if (tab.value !== 'people') return tags;
@@ -303,7 +308,7 @@ async function load() {
void loadCustomerGroups(true).catch(() => undefined);
try {
if (tab.value === 'people') {
const [peopleRes, statsRes] = await Promise.all([
const [peopleRes, statsRes, groupStatsRes] = await Promise.all([
api.get<PaginatedResponse<PersonDto>>('/people', {
params: {
q: q.value || undefined,
@@ -319,21 +324,31 @@ async function load() {
api.get<PersonImportSourceStatDto[]>('/people/source-stats').catch(() => ({
data: [] as PersonImportSourceStatDto[],
})),
api.get<CustomerGroupDto[]>('/people/group-stats').catch(() => ({
data: [] as CustomerGroupDto[],
})),
]);
people.value = peopleRes.data;
sourceStats.value = statsRes.data;
filterGroups.value = groupStatsRes.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', {
const [companiesRes, groupStatsRes] = await Promise.all([
api.get<PaginatedResponse<CompanyDto>>('/companies', {
params: {
q: q.value || undefined,
page: page.value,
limit: pageSize.value,
groupId: csv(groupFilters.value),
},
});
companies.value = data;
}),
api.get<CustomerGroupDto[]>('/companies/group-stats').catch(() => ({
data: [] as CustomerGroupDto[],
})),
]);
companies.value = companiesRes.data;
filterGroups.value = groupStatsRes.data;
}
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
@@ -704,7 +719,7 @@ onMounted(load);
:open="filterOpen"
:tab="tab"
:value="filterValue"
:groups="customerGroups"
:groups="filterGroups"
:source-stats="sourceStats"
:can-behavior="canBehavior"
:sort="sort"
@@ -739,7 +754,7 @@ onMounted(load);
:open="filterOpen"
:tab="tab"
:value="filterValue"
:groups="customerGroups"
:groups="filterGroups"
:source-stats="sourceStats"
:can-behavior="canBehavior"
@apply="applyFilters"