fix(crm): mesghal by metal, jalali fit, scoped group filters
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user