feat: separate prior records behind an owner switch and count last-period questions
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
"prisma:seed": "tsx prisma/seed.ts",
|
"prisma:seed": "tsx prisma/seed.ts",
|
||||||
"lint": "echo \"api lint placeholder\"",
|
"lint": "echo \"api lint placeholder\"",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
|
||||||
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts"
|
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mento/shared": "*",
|
"@mento/shared": "*",
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- بایگانی اطلاعات قبل: مرز محیط، مجوز عضو، و منبع فعالیت
|
||||||
|
ALTER TABLE "Organization" ADD COLUMN "priorArchiveEnabledAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
ALTER TABLE "Membership" ADD COLUMN "priorArchiveEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "Membership" ADD COLUMN "priorArchiveLens" TEXT NOT NULL DEFAULT 'current';
|
||||||
|
|
||||||
|
ALTER TABLE "Activity" ADD COLUMN "importSource" TEXT NOT NULL DEFAULT 'manual';
|
||||||
|
|
||||||
|
CREATE INDEX "Activity_organizationId_importSource_idx" ON "Activity"("organizationId", "importSource");
|
||||||
|
|
||||||
|
-- فعالیت وصل به معاملهٔ دیدار از همین حالا قبلی است. بقیه بعد از روشن شدن ماژول و با تاریخ مرز پر میشوند.
|
||||||
|
UPDATE "Activity" AS a
|
||||||
|
SET "importSource" = 'didar'
|
||||||
|
WHERE a."importSource" = 'manual'
|
||||||
|
AND a."dealId" IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM "Deal" AS d
|
||||||
|
WHERE d."id" = a."dealId" AND d."importSource" = 'didar'
|
||||||
|
);
|
||||||
@@ -150,6 +150,8 @@ model Organization {
|
|||||||
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
|
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
|
||||||
/// ماژولهای فعال منو برای این محیط
|
/// ماژولهای فعال منو برای این محیط
|
||||||
enabledModules String[] @default([])
|
enabledModules String[] @default([])
|
||||||
|
/// اولین لحظهٔ روشن شدن «بایگانی اطلاعات قبل»؛ مرز فعالیتهای قبلی
|
||||||
|
priorArchiveEnabledAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -348,6 +350,10 @@ model Membership {
|
|||||||
dataVisibility String @default("own")
|
dataVisibility String @default("own")
|
||||||
/// سطح دید تفکیکی هر بخش، مثلاً {"contacts":"org","deals":"own"}؛ بخشهای بدون مقدار = dataVisibility
|
/// سطح دید تفکیکی هر بخش، مثلاً {"contacts":"org","deals":"own"}؛ بخشهای بدون مقدار = dataVisibility
|
||||||
dataVisibilityScopes Json @default("{}")
|
dataVisibilityScopes Json @default("{}")
|
||||||
|
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
|
||||||
|
priorArchiveEnabled Boolean @default(false)
|
||||||
|
/// current | archive
|
||||||
|
priorArchiveLens String @default("current")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -695,6 +701,8 @@ model Activity {
|
|||||||
status ActivityStatus @default(open)
|
status ActivityStatus @default(open)
|
||||||
priority ActivityPriority @default(medium)
|
priority ActivityPriority @default(medium)
|
||||||
title String
|
title String
|
||||||
|
/// manual | didar — سابقهٔ منتقلشده از اطلاعات قبل
|
||||||
|
importSource String @default("manual")
|
||||||
description String?
|
description String?
|
||||||
dueAt DateTime?
|
dueAt DateTime?
|
||||||
durationMinutes Int?
|
durationMinutes Int?
|
||||||
@@ -741,6 +749,7 @@ model Activity {
|
|||||||
@@index([typeDefId])
|
@@index([typeDefId])
|
||||||
@@index([leadId])
|
@@index([leadId])
|
||||||
@@index([organizationId, status, dueAt])
|
@@index([organizationId, status, dueAt])
|
||||||
|
@@index([organizationId, importSource])
|
||||||
@@index([organizationId, assigneeId])
|
@@index([organizationId, assigneeId])
|
||||||
@@index([organizationId, createdById])
|
@@index([organizationId, createdById])
|
||||||
@@index([reminderAt, reminderSentAt])
|
@@index([reminderAt, reminderSentAt])
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { normalizeTags } from '../common/tags';
|
|||||||
import { GamificationService } from '../gamification/gamification.service';
|
import { GamificationService } from '../gamification/gamification.service';
|
||||||
import { tehranDayKey } from '../routines/routine-dates';
|
import { tehranDayKey } from '../routines/routine-dates';
|
||||||
import { buildSearchWhere, ci } from '../common/search';
|
import { buildSearchWhere, ci } from '../common/search';
|
||||||
|
import { activityPriorWhere } from '../common/prior-archive';
|
||||||
import { ActivityStagesService, type StageDefaults } from './activity-stages.service';
|
import { ActivityStagesService, type StageDefaults } from './activity-stages.service';
|
||||||
import {
|
import {
|
||||||
ActivityTypesService,
|
ActivityTypesService,
|
||||||
@@ -37,7 +38,7 @@ import {
|
|||||||
|
|
||||||
type DtoContext = { stages?: StageDefaults; types?: SystemTypes };
|
type DtoContext = { stages?: StageDefaults; types?: SystemTypes };
|
||||||
|
|
||||||
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
|
type ActivityActor = Pick<Actor, 'userId' | 'role' | 'priorLens'>;
|
||||||
|
|
||||||
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمیکند (جز عقبافتاده) */
|
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمیکند (جز عقبافتاده) */
|
||||||
type StageScope = { stageId: string };
|
type StageScope = { stageId: string };
|
||||||
@@ -63,6 +64,7 @@ type ActivityRow = {
|
|||||||
reminderNotifySms: boolean;
|
reminderNotifySms: boolean;
|
||||||
reminderSentAt: Date | null;
|
reminderSentAt: Date | null;
|
||||||
completedAt: Date | null;
|
completedAt: Date | null;
|
||||||
|
importSource?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -117,31 +119,47 @@ export class ActivitiesService {
|
|||||||
): Promise<ActivitySummaryDto> {
|
): Promise<ActivitySummaryDto> {
|
||||||
const { start, end } = dayBounds();
|
const { start, end } = dayBounds();
|
||||||
const scope = activityAccessWhere(actor);
|
const scope = activityAccessWhere(actor);
|
||||||
|
const prior = activityPriorWhere(actor.priorLens);
|
||||||
const [todayCount, overdueCount] = await Promise.all([
|
const [todayCount, overdueCount] = await Promise.all([
|
||||||
this.prisma.activity.count({
|
this.prisma.activity.count({
|
||||||
where: {
|
where: {
|
||||||
organizationId,
|
AND: [
|
||||||
...scope,
|
{
|
||||||
status: 'open',
|
organizationId,
|
||||||
dueAt: { gte: start, lt: end },
|
...scope,
|
||||||
|
status: 'open',
|
||||||
|
dueAt: { gte: start, lt: end },
|
||||||
|
},
|
||||||
|
prior,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.activity.count({
|
this.prisma.activity.count({
|
||||||
where: {
|
where: {
|
||||||
organizationId,
|
AND: [
|
||||||
...scope,
|
{
|
||||||
status: 'open',
|
organizationId,
|
||||||
dueAt: { lt: start },
|
...scope,
|
||||||
|
status: 'open',
|
||||||
|
dueAt: { lt: start },
|
||||||
|
},
|
||||||
|
prior,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const [today, overdue, upcoming] = await Promise.all([
|
const [today, overdue, upcoming] = await Promise.all([
|
||||||
this.prisma.activity.findMany({
|
this.prisma.activity.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId,
|
AND: [
|
||||||
...scope,
|
{
|
||||||
status: 'open',
|
organizationId,
|
||||||
dueAt: { gte: start, lt: end },
|
...scope,
|
||||||
|
status: 'open',
|
||||||
|
dueAt: { gte: start, lt: end },
|
||||||
|
},
|
||||||
|
prior,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
include,
|
include,
|
||||||
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||||
@@ -149,10 +167,15 @@ export class ActivitiesService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.activity.findMany({
|
this.prisma.activity.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId,
|
AND: [
|
||||||
...scope,
|
{
|
||||||
status: 'open',
|
organizationId,
|
||||||
dueAt: { lt: start },
|
...scope,
|
||||||
|
status: 'open',
|
||||||
|
dueAt: { lt: start },
|
||||||
|
},
|
||||||
|
prior,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
include,
|
include,
|
||||||
orderBy: [{ dueAt: 'asc' }],
|
orderBy: [{ dueAt: 'asc' }],
|
||||||
@@ -160,10 +183,15 @@ export class ActivitiesService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.activity.findMany({
|
this.prisma.activity.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId,
|
AND: [
|
||||||
...scope,
|
{
|
||||||
status: 'open',
|
organizationId,
|
||||||
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
|
...scope,
|
||||||
|
status: 'open',
|
||||||
|
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
|
||||||
|
},
|
||||||
|
prior,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
include,
|
include,
|
||||||
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||||
@@ -671,6 +699,8 @@ export class ActivitiesService {
|
|||||||
...this.types.searchByName(typeRows, t),
|
...this.types.searchByName(typeRows, t),
|
||||||
]);
|
]);
|
||||||
if (search) andParts.push(search);
|
if (search) andParts.push(search);
|
||||||
|
const onRecord = Boolean(entity?.personId || entity?.companyId || entity?.dealId);
|
||||||
|
if (!onRecord) andParts.push(activityPriorWhere(actor.priorLens));
|
||||||
const base: Prisma.ActivityWhereInput = {
|
const base: Prisma.ActivityWhereInput = {
|
||||||
organizationId,
|
organizationId,
|
||||||
AND: andParts,
|
AND: andParts,
|
||||||
@@ -868,6 +898,7 @@ export class ActivitiesService {
|
|||||||
reminderNotifySms: row.reminderNotifySms,
|
reminderNotifySms: row.reminderNotifySms,
|
||||||
reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
|
reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
|
||||||
completedAt: row.completedAt?.toISOString() ?? null,
|
completedAt: row.completedAt?.toISOString() ?? null,
|
||||||
|
importSource: row.importSource ?? 'manual',
|
||||||
tags: row.tags ?? [],
|
tags: row.tags ?? [],
|
||||||
person: row.person
|
person: row.person
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -32,13 +32,26 @@ import {
|
|||||||
type PersonImportSource,
|
type PersonImportSource,
|
||||||
type UserRole,
|
type UserRole,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { formatTehranJalali, rewriteGregorianDates, startOfJalaliMonth, toJalali } from '../common/jalali';
|
import {
|
||||||
|
addJalaliMonths,
|
||||||
|
formatTehranJalali,
|
||||||
|
rewriteGregorianDates,
|
||||||
|
startOfJalaliMonth,
|
||||||
|
toGregorian,
|
||||||
|
toJalali,
|
||||||
|
} from '../common/jalali';
|
||||||
import { AiService } from './ai.service';
|
import { AiService } from './ai.service';
|
||||||
import {
|
import {
|
||||||
classifyCountQuestion,
|
classifyCountQuestion,
|
||||||
personSourceKeys,
|
personSourceKeys,
|
||||||
type CountIntent,
|
type CountIntent,
|
||||||
} from './ask-count';
|
} from './ask-count';
|
||||||
|
import {
|
||||||
|
activityPriorWhere,
|
||||||
|
companyPriorWhere,
|
||||||
|
dealPriorWhere,
|
||||||
|
personPriorWhere,
|
||||||
|
} from '../common/prior-archive';
|
||||||
|
|
||||||
type CatalogItem = AskMentoSourceDto;
|
type CatalogItem = AskMentoSourceDto;
|
||||||
|
|
||||||
@@ -51,6 +64,10 @@ type PeriodCounts = {
|
|||||||
month: number;
|
month: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function gregorianKey(gy: number, gm: number, gd: number) {
|
||||||
|
return `${gy}-${String(gm).padStart(2, '0')}-${String(gd).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
function clip(value: string | null | undefined, max = 280) {
|
function clip(value: string | null | undefined, max = 280) {
|
||||||
const text = (value ?? '').replace(/\s+/g, ' ').trim();
|
const text = (value ?? '').replace(/\s+/g, ' ').trim();
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
@@ -116,6 +133,14 @@ function tehranPeriods(now = new Date()) {
|
|||||||
start: dayBoundsFromKey(weekStartKey).start,
|
start: dayBoundsFromKey(weekStartKey).start,
|
||||||
end: dayBoundsFromKey(weekEndKey).end,
|
end: dayBoundsFromKey(weekEndKey).end,
|
||||||
};
|
};
|
||||||
|
const lastWeekStartKey = addDaysToDayKey(weekStartKey, -7);
|
||||||
|
const lastWeekEndKey = addDaysToDayKey(weekStartKey, -1);
|
||||||
|
const lastWeek: PeriodRange = {
|
||||||
|
key: `${lastWeekStartKey}..${lastWeekEndKey}`,
|
||||||
|
label: 'last_week',
|
||||||
|
start: dayBoundsFromKey(lastWeekStartKey).start,
|
||||||
|
end: dayBoundsFromKey(lastWeekEndKey).end,
|
||||||
|
};
|
||||||
const { jy, jm } = (() => {
|
const { jy, jm } = (() => {
|
||||||
const [gy, gm, gd] = dayKey.split('-').map(Number);
|
const [gy, gm, gd] = dayKey.split('-').map(Number);
|
||||||
return toJalali(gy!, gm!, gd!);
|
return toJalali(gy!, gm!, gd!);
|
||||||
@@ -127,7 +152,21 @@ function tehranPeriods(now = new Date()) {
|
|||||||
start: monthStart,
|
start: monthStart,
|
||||||
end: now,
|
end: now,
|
||||||
};
|
};
|
||||||
return { now, dayKey, today, yesterday, week, month };
|
const prev = addJalaliMonths(jy, jm, -1);
|
||||||
|
const prevStart = toGregorian(prev.jy, prev.jm, 1);
|
||||||
|
const nextAfterPrev = addJalaliMonths(prev.jy, prev.jm, 1);
|
||||||
|
const nextStart = toGregorian(nextAfterPrev.jy, nextAfterPrev.jm, 1);
|
||||||
|
const lastMonthEndKey = addDaysToDayKey(
|
||||||
|
gregorianKey(nextStart.gy, nextStart.gm, nextStart.gd),
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
const lastMonth: PeriodRange = {
|
||||||
|
key: `${prev.jy}-${String(prev.jm).padStart(2, '0')}`,
|
||||||
|
label: 'last_jalali_month',
|
||||||
|
start: dayBoundsFromKey(gregorianKey(prevStart.gy, prevStart.gm, prevStart.gd)).start,
|
||||||
|
end: dayBoundsFromKey(lastMonthEndKey).end,
|
||||||
|
};
|
||||||
|
return { now, dayKey, today, yesterday, week, lastWeek, month, lastMonth };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPeriodCounts(labelFa: string, counts: PeriodCounts) {
|
function formatPeriodCounts(labelFa: string, counts: PeriodCounts) {
|
||||||
@@ -163,7 +202,7 @@ export class AiAskService {
|
|||||||
"مشتری" means a Person record. "لید" means a Lead record. They are different. Never answer a lead question with the people/customer total.
|
"مشتری" means a Person record. "لید" means a Lead record. They are different. Never answer a lead question with the people/customer total.
|
||||||
Person and company "by source" lines are authoritative. If the user names a source (دستی، اکسل، دیدار، تهحساب، گوشی، فرم، مونتیفای), use that source count only, never the grand total.
|
Person and company "by source" lines are authoritative. If the user names a source (دستی، اکسل، دیدار، تهحساب، گوشی، فرم، مونتیفای), use that source count only, never the grand total.
|
||||||
Lead stage and lead source lines are authoritative the same way.
|
Lead stage and lead source lines are authoritative the same way.
|
||||||
Period stats use Asia/Tehran calendar: today, yesterday, this_week (Saturday–Friday), this_month (current Jalali month).
|
Period stats use Asia/Tehran calendar: today, yesterday, this_week (Saturday–Friday), last_week (the previous Saturday–Friday), this_month (current Jalali month), last_month (the previous Jalali month).
|
||||||
Every date you write must be Shamsi/Jalali, copied from jalali dates in the context, like ۱۴۰۴/۰۷/۲۱. Never use Gregorian month names (ژانویه، سپتامبر، September) or ISO dates.
|
Every date you write must be Shamsi/Jalali, copied from jalali dates in the context, like ۱۴۰۴/۰۷/۲۱. Never use Gregorian month names (ژانویه، سپتامبر، September) or ISO dates.
|
||||||
Workspace facts and Period stats are complete and authoritative for counts.
|
Workspace facts and Period stats are complete and authoritative for counts.
|
||||||
You cannot create, update, or delete records. Never say a customer, lead, or activity was created.
|
You cannot create, update, or delete records. Never say a customer, lead, or activity was created.
|
||||||
@@ -240,6 +279,8 @@ Rules:
|
|||||||
if (key === 'today') return periods.today;
|
if (key === 'today') return periods.today;
|
||||||
if (key === 'yesterday') return periods.yesterday;
|
if (key === 'yesterday') return periods.yesterday;
|
||||||
if (key === 'week') return periods.week;
|
if (key === 'week') return periods.week;
|
||||||
|
if (key === 'last_week') return periods.lastWeek;
|
||||||
|
if (key === 'last_month') return periods.lastMonth;
|
||||||
return periods.month;
|
return periods.month;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,24 +302,38 @@ Rules:
|
|||||||
? 'امروز'
|
? 'امروز'
|
||||||
: intent.period === 'yesterday'
|
: intent.period === 'yesterday'
|
||||||
? 'دیروز'
|
? 'دیروز'
|
||||||
: intent.period === 'week'
|
: intent.period === 'week'
|
||||||
? 'این هفته'
|
? 'این هفته'
|
||||||
: intent.period === 'month'
|
: intent.period === 'last_week'
|
||||||
? 'این ماه'
|
? 'هفته گذشته'
|
||||||
|
: intent.period === 'month'
|
||||||
|
? 'این ماه'
|
||||||
|
: intent.period === 'last_month'
|
||||||
|
? 'ماه گذشته'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
if (intent.entity === 'person' || intent.entity === 'company') {
|
if (intent.entity === 'person' || intent.entity === 'company') {
|
||||||
const where = {
|
const shared = {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
...(intent.entity === 'person' ? ownerWhere(actor) : ownerWhere(actor)),
|
...ownerWhere(actor),
|
||||||
...(intent.personSource ? { importSource: intent.personSource } : {}),
|
...(intent.personSource ? { importSource: intent.personSource } : {}),
|
||||||
...(intent.entity === 'person' && intent.mood ? { mood: intent.mood } : {}),
|
|
||||||
...created,
|
...created,
|
||||||
};
|
};
|
||||||
const n =
|
const n =
|
||||||
intent.entity === 'person'
|
intent.entity === 'person'
|
||||||
? await this.prisma.person.count({ where })
|
? await this.prisma.person.count({
|
||||||
: await this.prisma.company.count({ where });
|
where: {
|
||||||
|
...shared,
|
||||||
|
...(intent.mood ? { mood: intent.mood } : {}),
|
||||||
|
...(intent.personSource ? {} : personPriorWhere(actor.priorLens)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: await this.prisma.company.count({
|
||||||
|
where: {
|
||||||
|
...shared,
|
||||||
|
...(intent.personSource ? {} : companyPriorWhere(actor.priorLens)),
|
||||||
|
},
|
||||||
|
});
|
||||||
const who = intent.entity === 'person' ? 'مشتری' : 'شرکت';
|
const who = intent.entity === 'person' ? 'مشتری' : 'شرکت';
|
||||||
const source = intent.personSource
|
const source = intent.personSource
|
||||||
? PERSON_IMPORT_SOURCE_LABELS[intent.personSource]
|
? PERSON_IMPORT_SOURCE_LABELS[intent.personSource]
|
||||||
@@ -305,15 +360,20 @@ Rules:
|
|||||||
if (intent.entity === 'deal') {
|
if (intent.entity === 'deal') {
|
||||||
const scope = dealAccessWhere(actor);
|
const scope = dealAccessWhere(actor);
|
||||||
const where = {
|
const where = {
|
||||||
organizationId: orgId,
|
AND: [
|
||||||
...scope,
|
{
|
||||||
...created,
|
organizationId: orgId,
|
||||||
...(intent.dealBucket === 'open' ? { status: 'open' as const } : {}),
|
...scope,
|
||||||
...(intent.dealBucket === 'won' ? { status: 'won' as const } : {}),
|
...created,
|
||||||
...(intent.dealBucket === 'lost' ? { status: 'lost' as const } : {}),
|
...(intent.dealBucket === 'open' ? { status: 'open' as const } : {}),
|
||||||
...(intent.dealBucket === 'stale'
|
...(intent.dealBucket === 'won' ? { status: 'won' as const } : {}),
|
||||||
? { status: 'open' as const, OR: [{ isStale: true }, { isRotten: true }] }
|
...(intent.dealBucket === 'lost' ? { status: 'lost' as const } : {}),
|
||||||
: {}),
|
...(intent.dealBucket === 'stale'
|
||||||
|
? { status: 'open' as const, OR: [{ isStale: true }, { isRotten: true }] }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
dealPriorWhere(actor.priorLens),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
const n = await this.prisma.deal.count({ where });
|
const n = await this.prisma.deal.count({ where });
|
||||||
const name =
|
const name =
|
||||||
@@ -342,7 +402,12 @@ Rules:
|
|||||||
this.prisma.product.count({ where: { organizationId: orgId, ...created } }),
|
this.prisma.product.count({ where: { organizationId: orgId, ...created } }),
|
||||||
activity: () =>
|
activity: () =>
|
||||||
this.prisma.activity.count({
|
this.prisma.activity.count({
|
||||||
where: { organizationId: orgId, ...assigneeWhere(actor), ...created },
|
where: {
|
||||||
|
organizationId: orgId,
|
||||||
|
...assigneeWhere(actor),
|
||||||
|
...created,
|
||||||
|
...activityPriorWhere(actor.priorLens),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
ticket: () =>
|
ticket: () =>
|
||||||
this.prisma.ticket.count({
|
this.prisma.ticket.count({
|
||||||
@@ -432,8 +497,10 @@ Rules:
|
|||||||
}),
|
}),
|
||||||
this.prisma.lead.count({ where: base }),
|
this.prisma.lead.count({ where: base }),
|
||||||
]);
|
]);
|
||||||
const when = periodNote ? ` ${periodNote}` : '';
|
if (periodNote) {
|
||||||
return `لیدهای باز${when}: ${this.fa(open)} تا. کل لیدها ${this.fa(total)} تا است؛ ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیلشده.`;
|
return `لیدهای ${periodNote}: ${this.fa(total)} تا. ${this.fa(open)} تا هنوز باز، ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیلشده.`;
|
||||||
|
}
|
||||||
|
return `لیدهای باز: ${this.fa(open)} تا. کل لیدها ${this.fa(total)} تا است؛ ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیلشده.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = {
|
const where = {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export type CountEntity =
|
|||||||
| 'routine'
|
| 'routine'
|
||||||
| 'project';
|
| 'project';
|
||||||
|
|
||||||
export type CountPeriod = 'today' | 'yesterday' | 'week' | 'month';
|
export type CountPeriod = 'today' | 'yesterday' | 'week' | 'last_week' | 'month' | 'last_month';
|
||||||
|
|
||||||
export type LeadBucket = 'summary' | 'open' | 'archived' | 'converted';
|
export type LeadBucket = 'summary' | 'open' | 'archived' | 'converted';
|
||||||
export type DealBucket = 'all' | 'open' | 'won' | 'lost' | 'stale';
|
export type DealBucket = 'all' | 'open' | 'won' | 'lost' | 'stale';
|
||||||
@@ -51,6 +51,7 @@ export function normalizeAskText(raw: string) {
|
|||||||
.replace(/ي/g, 'ی')
|
.replace(/ي/g, 'ی')
|
||||||
.replace(/ك/g, 'ک')
|
.replace(/ك/g, 'ک')
|
||||||
.replace(//g, ' ')
|
.replace(//g, ' ')
|
||||||
|
.replace(/[؟?!.,،؛:]+/g, ' ')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
@@ -64,7 +65,7 @@ function detectEntity(q: string): CountEntity | null {
|
|||||||
if (/لید|سرنخ/.test(q)) return 'lead';
|
if (/لید|سرنخ/.test(q)) return 'lead';
|
||||||
if (/پیش\s*فاکتور/.test(q)) return 'quote';
|
if (/پیش\s*فاکتور/.test(q)) return 'quote';
|
||||||
if (/فاکتور/.test(q)) return 'invoice';
|
if (/فاکتور/.test(q)) return 'invoice';
|
||||||
if (/معامله/.test(q)) return 'deal';
|
if (/معاملات|معامله/.test(q)) return 'deal';
|
||||||
if (/چک/.test(q)) return 'cheque';
|
if (/چک/.test(q)) return 'cheque';
|
||||||
if (/رزرو|نوبت/.test(q)) return 'reservation';
|
if (/رزرو|نوبت/.test(q)) return 'reservation';
|
||||||
if (/روتین/.test(q)) return 'routine';
|
if (/روتین/.test(q)) return 'routine';
|
||||||
@@ -81,7 +82,9 @@ function detectEntity(q: string): CountEntity | null {
|
|||||||
function detectPeriod(q: string): CountPeriod | undefined {
|
function detectPeriod(q: string): CountPeriod | undefined {
|
||||||
if (/دیروز/.test(q)) return 'yesterday';
|
if (/دیروز/.test(q)) return 'yesterday';
|
||||||
if (/امروز/.test(q)) return 'today';
|
if (/امروز/.test(q)) return 'today';
|
||||||
|
if (/هفته\s*(?:ی\s+)?(گذشته|قبل|پیش)/.test(q)) return 'last_week';
|
||||||
if (/این\s*هفته|هفته\s*جاری/.test(q)) return 'week';
|
if (/این\s*هفته|هفته\s*جاری/.test(q)) return 'week';
|
||||||
|
if (/ماه\s*(?:ی\s+)?(گذشته|قبل|پیش)/.test(q)) return 'last_month';
|
||||||
if (/این\s*ماه|ماه\s*جاری/.test(q)) return 'month';
|
if (/این\s*ماه|ماه\s*جاری/.test(q)) return 'month';
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -152,6 +155,9 @@ const HINT_STOP = [
|
|||||||
'مرحله',
|
'مرحله',
|
||||||
'وضعیت',
|
'وضعیت',
|
||||||
'هفته',
|
'هفته',
|
||||||
|
'گذشته',
|
||||||
|
'پیش',
|
||||||
|
'قبل',
|
||||||
'لطفا',
|
'لطفا',
|
||||||
'الان',
|
'الان',
|
||||||
'شده',
|
'شده',
|
||||||
@@ -219,6 +225,11 @@ export function classifyCountQuestion(raw: string): CountIntent | null {
|
|||||||
const entity = detectEntity(q);
|
const entity = detectEntity(q);
|
||||||
if (!entity) return null;
|
if (!entity) return null;
|
||||||
const personSource = SOURCE_PATTERNS.find((item) => item.re.test(q))?.source;
|
const personSource = SOURCE_PATTERNS.find((item) => item.re.test(q))?.source;
|
||||||
|
const period = detectPeriod(q);
|
||||||
|
let hint = leftoverHint(q, entity);
|
||||||
|
if (period && entity === 'lead' && !/مرحله|وضعیت|ستون/.test(q)) {
|
||||||
|
hint = hint.replace(/(^|\s)جدید(?=\s|$)/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
entity,
|
entity,
|
||||||
personSource:
|
personSource:
|
||||||
@@ -226,8 +237,8 @@ export function classifyCountQuestion(raw: string): CountIntent | null {
|
|||||||
mood: entity === 'person' ? detectMood(q) : undefined,
|
mood: entity === 'person' ? detectMood(q) : undefined,
|
||||||
leadBucket: detectLeadBucket(q),
|
leadBucket: detectLeadBucket(q),
|
||||||
dealBucket: detectDealBucket(q),
|
dealBucket: detectDealBucket(q),
|
||||||
period: detectPeriod(q),
|
period,
|
||||||
hint: leftoverHint(q, entity),
|
hint,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -92,6 +93,18 @@ export class AuthController {
|
|||||||
return this.authService.updateMe(user.userId, user.organizationId, dto);
|
return this.authService.updateMe(user.userId, user.organizationId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Patch('me/prior-archive')
|
||||||
|
setPriorLens(
|
||||||
|
@CurrentUser() user: AuthActor,
|
||||||
|
@Body() body: { lens?: string },
|
||||||
|
) {
|
||||||
|
if (body.lens !== 'archive' && body.lens !== 'current') {
|
||||||
|
throw new BadRequestException('حالت بایگانی نامعتبر است');
|
||||||
|
}
|
||||||
|
return this.authService.setPriorLens(user.userId, user.organizationId, body.lens);
|
||||||
|
}
|
||||||
|
|
||||||
/** عکس پروفایل کاربر جاری */
|
/** عکس پروفایل کاربر جاری */
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get('me/avatar')
|
@Get('me/avatar')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -412,6 +413,29 @@ export class AuthService {
|
|||||||
return this.toAuthUserDto(user, organizationId);
|
return this.toAuthUserDto(user, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setPriorLens(
|
||||||
|
userId: string,
|
||||||
|
organizationId: string,
|
||||||
|
lens: 'current' | 'archive',
|
||||||
|
): Promise<AuthUserDto> {
|
||||||
|
const membership = await this.prisma.membership.findFirst({
|
||||||
|
where: { userId, organizationId, isActive: true },
|
||||||
|
include: { organization: { select: { enabledModules: true } } },
|
||||||
|
});
|
||||||
|
if (!membership?.priorArchiveEnabled) {
|
||||||
|
throw new ForbiddenException('بایگانی اطلاعات قبل برای شما فعال نیست');
|
||||||
|
}
|
||||||
|
const modules = this.billing.normalizeModules(membership.organization.enabledModules);
|
||||||
|
if (!modules.includes('prior_data_archive')) {
|
||||||
|
throw new ForbiddenException('بایگانی اطلاعات قبل برای این محیط فعال نیست');
|
||||||
|
}
|
||||||
|
await this.prisma.membership.update({
|
||||||
|
where: { id: membership.id },
|
||||||
|
data: { priorArchiveLens: lens },
|
||||||
|
});
|
||||||
|
return this.me(userId, organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
async updateMe(userId: string, organizationId: string, dto: UpdateProfileDto): Promise<AuthUserDto> {
|
async updateMe(userId: string, organizationId: string, dto: UpdateProfileDto): Promise<AuthUserDto> {
|
||||||
const fullName = dto.fullName.trim();
|
const fullName = dto.fullName.trim();
|
||||||
if (fullName.length < 2) {
|
if (fullName.length < 2) {
|
||||||
@@ -653,9 +677,23 @@ export class AuthService {
|
|||||||
),
|
),
|
||||||
subscription: sub,
|
subscription: sub,
|
||||||
isOrgOwner: role === 'owner',
|
isOrgOwner: role === 'owner',
|
||||||
|
priorArchive: this.priorArchiveFor(membership),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private priorArchiveFor(
|
||||||
|
membership: {
|
||||||
|
priorArchiveEnabled?: boolean;
|
||||||
|
priorArchiveLens?: string;
|
||||||
|
organization?: { enabledModules?: string[] };
|
||||||
|
} | null,
|
||||||
|
): AuthUserDto['priorArchive'] {
|
||||||
|
if (!membership?.priorArchiveEnabled) return undefined;
|
||||||
|
const modules = this.billing.normalizeModules(membership.organization?.enabledModules);
|
||||||
|
if (!modules.includes('prior_data_archive')) return undefined;
|
||||||
|
return { lens: membership.priorArchiveLens === 'archive' ? 'archive' : 'current' };
|
||||||
|
}
|
||||||
|
|
||||||
private async throttleChallenges(
|
private async throttleChallenges(
|
||||||
kind: string,
|
kind: string,
|
||||||
email: string | null | undefined,
|
email: string | null | undefined,
|
||||||
@@ -740,6 +778,7 @@ export class AuthService {
|
|||||||
enabledModules?: OrgModuleKey[];
|
enabledModules?: OrgModuleKey[];
|
||||||
subscription?: SubscriptionSummaryDto | null;
|
subscription?: SubscriptionSummaryDto | null;
|
||||||
isOrgOwner?: boolean;
|
isOrgOwner?: boolean;
|
||||||
|
priorArchive?: AuthUserDto['priorArchive'];
|
||||||
}): AuthUserDto {
|
}): AuthUserDto {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -753,6 +792,7 @@ export class AuthService {
|
|||||||
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
||||||
...(user.subscription ? { subscription: user.subscription } : {}),
|
...(user.subscription ? { subscription: user.subscription } : {}),
|
||||||
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
|
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
|
||||||
|
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
role: true,
|
role: true,
|
||||||
dataVisibility: true,
|
dataVisibility: true,
|
||||||
dataVisibilityScopes: true,
|
dataVisibilityScopes: true,
|
||||||
|
priorArchiveEnabled: true,
|
||||||
|
priorArchiveLens: true,
|
||||||
|
organization: { select: { enabledModules: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
@@ -91,6 +94,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modules = this.billing.normalizeModules(membership.organization.enabledModules);
|
||||||
|
const priorGranted =
|
||||||
|
modules.includes('prior_data_archive') && membership.priorArchiveEnabled;
|
||||||
|
const priorLens = membership.priorArchiveLens === 'archive' ? 'archive' : 'current';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
organizationId: membership.organizationId,
|
organizationId: membership.organizationId,
|
||||||
@@ -98,6 +106,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
dataVisibility,
|
dataVisibility,
|
||||||
visibleUserIds,
|
visibleUserIds,
|
||||||
scopes,
|
scopes,
|
||||||
|
priorLens: priorGranted ? priorLens : 'off',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import type {
|
import type {
|
||||||
ApplyBillingPlanRequest,
|
ApplyBillingPlanRequest,
|
||||||
BillingAccountDto,
|
BillingAccountDto,
|
||||||
@@ -251,13 +252,58 @@ export class BillingService {
|
|||||||
modules.filter(isOrgModuleKey),
|
modules.filter(isOrgModuleKey),
|
||||||
),
|
),
|
||||||
] as OrgModuleKey[];
|
] as OrgModuleKey[];
|
||||||
|
const prev = await this.prisma.organization.findUnique({
|
||||||
|
where: { id: organizationId },
|
||||||
|
select: { priorArchiveEnabledAt: true },
|
||||||
|
});
|
||||||
|
const enablePrior = cleaned.includes('prior_data_archive');
|
||||||
|
const cutoff = prev?.priorArchiveEnabledAt ?? new Date();
|
||||||
await this.prisma.organization.update({
|
await this.prisma.organization.update({
|
||||||
where: { id: organizationId },
|
where: { id: organizationId },
|
||||||
data: { enabledModules: cleaned },
|
data: {
|
||||||
|
enabledModules: cleaned,
|
||||||
|
...(enablePrior && !prev?.priorArchiveEnabledAt
|
||||||
|
? { priorArchiveEnabledAt: cutoff }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
if (enablePrior) await this.backfillPriorActivities(organizationId, cutoff);
|
||||||
return cleaned;
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** فعالیتهای منتقلشده را یکبار (و تکرار بیاثر) بهعنوان اطلاعات قبل علامت میزند. */
|
||||||
|
private async backfillPriorActivities(organizationId: string, cutoff: Date) {
|
||||||
|
await this.prisma.$executeRaw(Prisma.sql`
|
||||||
|
UPDATE "Activity" AS a
|
||||||
|
SET "importSource" = 'didar'
|
||||||
|
WHERE a."organizationId" = ${organizationId}
|
||||||
|
AND a."importSource" = 'manual'
|
||||||
|
AND (
|
||||||
|
(
|
||||||
|
a."dealId" IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM "Deal" AS d
|
||||||
|
WHERE d."id" = a."dealId" AND d."importSource" = 'didar'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
a."dealId" IS NULL
|
||||||
|
AND a."createdAt" < ${cutoff}
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM "Person" AS p
|
||||||
|
WHERE p."id" = a."personId" AND p."importSource" = 'didar'
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM "Company" AS c
|
||||||
|
WHERE c."id" = a."companyId" AND c."importSource" = 'didar'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
normalizeModules(raw: string[] | null | undefined): OrgModuleKey[] {
|
normalizeModules(raw: string[] | null | undefined): OrgModuleKey[] {
|
||||||
if (!raw?.length) return defaultEnabledModules();
|
if (!raw?.length) return defaultEnabledModules();
|
||||||
const cleaned = raw.filter(isOrgModuleKey) as OrgModuleKey[];
|
const cleaned = raw.filter(isOrgModuleKey) as OrgModuleKey[];
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
import type { Actor } from './record-scope';
|
||||||
|
|
||||||
|
export const PRIOR_IMPORT_SOURCE = 'didar';
|
||||||
|
|
||||||
|
export type PriorLens = 'off' | 'current' | 'archive';
|
||||||
|
|
||||||
|
export function actorPriorLens(actor: Pick<Actor, 'priorLens'> | undefined): PriorLens {
|
||||||
|
return actor?.priorLens ?? 'off';
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveDeal(now: Date): Prisma.DealWhereInput {
|
||||||
|
return {
|
||||||
|
importSource: PRIOR_IMPORT_SOURCE,
|
||||||
|
status: 'open',
|
||||||
|
expectedCloseAt: { gt: now },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveActivity(now: Date): Prisma.ActivityWhereInput {
|
||||||
|
return {
|
||||||
|
importSource: PRIOR_IMPORT_SOURCE,
|
||||||
|
status: 'open',
|
||||||
|
dueAt: { gt: now },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personPriorWhere(lens: PriorLens | undefined): Prisma.PersonWhereInput {
|
||||||
|
if (lens === 'archive') return { importSource: PRIOR_IMPORT_SOURCE };
|
||||||
|
if (lens === 'current') return { importSource: { not: PRIOR_IMPORT_SOURCE } };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function companyPriorWhere(lens: PriorLens | undefined): Prisma.CompanyWhereInput {
|
||||||
|
if (lens === 'archive') return { importSource: PRIOR_IMPORT_SOURCE };
|
||||||
|
if (lens === 'current') return { importSource: { not: PRIOR_IMPORT_SOURCE } };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dealPriorWhere(lens: PriorLens | undefined, now = new Date()): Prisma.DealWhereInput {
|
||||||
|
if (lens === 'current') {
|
||||||
|
return { OR: [{ importSource: { not: PRIOR_IMPORT_SOURCE } }, liveDeal(now)] };
|
||||||
|
}
|
||||||
|
if (lens === 'archive') {
|
||||||
|
return { importSource: PRIOR_IMPORT_SOURCE, NOT: liveDeal(now) };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activityPriorWhere(
|
||||||
|
lens: PriorLens | undefined,
|
||||||
|
now = new Date(),
|
||||||
|
): Prisma.ActivityWhereInput {
|
||||||
|
if (lens === 'current') {
|
||||||
|
return { OR: [{ importSource: { not: PRIOR_IMPORT_SOURCE } }, liveActivity(now)] };
|
||||||
|
}
|
||||||
|
if (lens === 'archive') {
|
||||||
|
return { importSource: PRIOR_IMPORT_SOURCE, NOT: liveActivity(now) };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** فهرست شخص/شرکت: در کار جاری، جستجو و فیلتر صریح منبع، قبلیها را هم پیدا میکند. */
|
||||||
|
export function listPriorLens(
|
||||||
|
lens: PriorLens | undefined,
|
||||||
|
q: string | undefined,
|
||||||
|
explicitSource: boolean,
|
||||||
|
): PriorLens {
|
||||||
|
if (explicitSource) return 'off';
|
||||||
|
if (lens === 'current' && q?.trim()) return 'off';
|
||||||
|
return lens ?? 'off';
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ export type Actor = {
|
|||||||
visibleUserIds?: string[] | null;
|
visibleUserIds?: string[] | null;
|
||||||
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
|
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
|
||||||
scopes?: Partial<Record<DataScope, ScopeVisibility>>;
|
scopes?: Partial<Record<DataScope, ScopeVisibility>>;
|
||||||
|
/** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */
|
||||||
|
priorLens?: 'off' | 'current' | 'archive';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
|
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
import { normalizeTags } from '../common/tags';
|
import { normalizeTags } from '../common/tags';
|
||||||
import { normalizePersonImportSource } from './person-source';
|
import { normalizePersonImportSource } from './person-source';
|
||||||
import { buildSearchWhere, ci } from '../common/search';
|
import { buildSearchWhere, ci } from '../common/search';
|
||||||
|
import { companyPriorWhere, listPriorLens } from '../common/prior-archive';
|
||||||
import { GamificationService } from '../gamification/gamification.service';
|
import { GamificationService } from '../gamification/gamification.service';
|
||||||
|
|
||||||
function parseDateOrNull(raw?: string | null): Date | null {
|
function parseDateOrNull(raw?: string | null): Date | null {
|
||||||
@@ -97,6 +98,7 @@ export class CompaniesService {
|
|||||||
const where: Prisma.CompanyWhereInput = {
|
const where: Prisma.CompanyWhereInput = {
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
...ownerWhere(actor),
|
...ownerWhere(actor),
|
||||||
|
...companyPriorWhere(listPriorLens(actor.priorLens, q, false)),
|
||||||
...(search ? { AND: [search] } : {}),
|
...(search ? { AND: [search] } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { selectMonthBirthdays } from './birthday-month';
|
|||||||
import { renderSmsText } from '../common/sms-text';
|
import { renderSmsText } from '../common/sms-text';
|
||||||
import { normalizeTags } from '../common/tags';
|
import { normalizeTags } from '../common/tags';
|
||||||
import { buildSearchWhere, ci } from '../common/search';
|
import { buildSearchWhere, ci } from '../common/search';
|
||||||
|
import { listPriorLens, personPriorWhere } from '../common/prior-archive';
|
||||||
import {
|
import {
|
||||||
normalizePersonImportSource,
|
normalizePersonImportSource,
|
||||||
personImportSourceLabel,
|
personImportSourceLabel,
|
||||||
@@ -137,6 +138,7 @@ export class PeopleService implements OnModuleInit {
|
|||||||
...ownerWhere(actor),
|
...ownerWhere(actor),
|
||||||
...(opts?.mood ? { mood: opts.mood } : {}),
|
...(opts?.mood ? { mood: opts.mood } : {}),
|
||||||
...(sourceFilter ? { importSource: sourceFilter } : {}),
|
...(sourceFilter ? { importSource: sourceFilter } : {}),
|
||||||
|
...personPriorWhere(listPriorLens(actor.priorLens, q, Boolean(sourceFilter))),
|
||||||
...(search ? { AND: [search] } : {}),
|
...(search ? { AND: [search] } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
import { dealAccessWhere, resolveOwnerId } from '../common/record-scope';
|
import { dealAccessWhere, resolveOwnerId } from '../common/record-scope';
|
||||||
|
import { dealPriorWhere } from '../common/prior-archive';
|
||||||
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
|
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
|
||||||
import { normalizePhone } from '../contacts/normalize';
|
import { normalizePhone } from '../contacts/normalize';
|
||||||
import {
|
import {
|
||||||
@@ -110,7 +111,11 @@ export class DealsExcelService {
|
|||||||
async export(actor: Actor): Promise<Buffer> {
|
async export(actor: Actor): Promise<Buffer> {
|
||||||
const [rows, defs] = await Promise.all([
|
const [rows, defs] = await Promise.all([
|
||||||
this.prisma.deal.findMany({
|
this.prisma.deal.findMany({
|
||||||
where: { organizationId: actor.organizationId, ...dealAccessWhere(actor) },
|
where: {
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
...dealAccessWhere(actor),
|
||||||
|
...dealPriorWhere(actor.priorLens),
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
person: {
|
person: {
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ export class PipelinesController {
|
|||||||
board(
|
board(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
@Query('pipelineId') pipelineId?: string,
|
@Query('pipelineId') pipelineId?: string,
|
||||||
|
@Query('prior') prior?: string,
|
||||||
) {
|
) {
|
||||||
return this.pipelinesService.getBoard(user.organizationId, pipelineId, user);
|
return this.pipelinesService.getBoard(user.organizationId, pipelineId, user, prior === 'all');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { asCustomFields } from '../common/custom-fields';
|
import { asCustomFields } from '../common/custom-fields';
|
||||||
import { type Actor, dealAccessWhere } from '../common/record-scope';
|
import { type Actor, dealAccessWhere } from '../common/record-scope';
|
||||||
|
import { dealPriorWhere } from '../common/prior-archive';
|
||||||
import { decimalToBase, loadOrgRates } from '../common/currency';
|
import { decimalToBase, loadOrgRates } from '../common/currency';
|
||||||
import { assertStageMap, dealStatusForStage } from './pipeline-merge.util';
|
import { assertStageMap, dealStatusForStage } from './pipeline-merge.util';
|
||||||
|
|
||||||
@@ -294,8 +295,9 @@ export class PipelinesService {
|
|||||||
pipelineId?: string,
|
pipelineId?: string,
|
||||||
actor?: Pick<
|
actor?: Pick<
|
||||||
Actor,
|
Actor,
|
||||||
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'
|
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'priorLens'
|
||||||
>,
|
>,
|
||||||
|
includePrior = false,
|
||||||
): Promise<PipelineBoardDto> {
|
): Promise<PipelineBoardDto> {
|
||||||
const pipeline = pipelineId
|
const pipeline = pipelineId
|
||||||
? await this.prisma.pipeline.findFirst({
|
? await this.prisma.pipeline.findFirst({
|
||||||
@@ -313,7 +315,7 @@ export class PipelinesService {
|
|||||||
|
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
const created = await this.ensureDefaultPipeline(organizationId);
|
const created = await this.ensureDefaultPipeline(organizationId);
|
||||||
return this.getBoard(organizationId, created.id, actor);
|
return this.getBoard(organizationId, created.id, actor, includePrior);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deals = await this.prisma.deal.findMany({
|
const deals = await this.prisma.deal.findMany({
|
||||||
@@ -321,6 +323,7 @@ export class PipelinesService {
|
|||||||
organizationId,
|
organizationId,
|
||||||
pipelineId: resolved.id,
|
pipelineId: resolved.id,
|
||||||
...(actor ? dealAccessWhere(actor) : {}),
|
...(actor ? dealAccessWhere(actor) : {}),
|
||||||
|
...(includePrior ? {} : dealPriorWhere(actor?.priorLens)),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
person: { select: { id: true, firstName: true, lastName: true } },
|
person: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ import {
|
|||||||
ticketAccessWhere,
|
ticketAccessWhere,
|
||||||
} from '../common/record-scope';
|
} from '../common/record-scope';
|
||||||
import { decimalToBase, loadOrgRates, toBaseAmount } from '../common/currency';
|
import { decimalToBase, loadOrgRates, toBaseAmount } from '../common/currency';
|
||||||
|
import {
|
||||||
|
activityPriorWhere,
|
||||||
|
dealPriorWhere,
|
||||||
|
personPriorWhere,
|
||||||
|
} from '../common/prior-archive';
|
||||||
import {
|
import {
|
||||||
addJalaliMonths,
|
addJalaliMonths,
|
||||||
dateToJalali,
|
dateToJalali,
|
||||||
@@ -57,6 +62,9 @@ export class ReportsService {
|
|||||||
const prevStart = new Date(prevEnd.getTime() - durationMs);
|
const prevStart = new Date(prevEnd.getTime() - durationMs);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const peoplePrior = personPriorWhere(actor.priorLens);
|
||||||
|
const dealsPrior = dealPriorWhere(actor.priorLens, now);
|
||||||
|
const activitiesPrior = activityPriorWhere(actor.priorLens, now);
|
||||||
const { jy, jm } = dateToJalali(now);
|
const { jy, jm } = dateToJalali(now);
|
||||||
const sixAgo = addJalaliMonths(jy, jm, -5);
|
const sixAgo = addJalaliMonths(jy, jm, -5);
|
||||||
const sixMonthStart = startOfJalaliMonth(sixAgo.jy, sixAgo.jm);
|
const sixMonthStart = startOfJalaliMonth(sixAgo.jy, sixAgo.jm);
|
||||||
@@ -76,15 +84,20 @@ export class ReportsService {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.prisma.deal.findMany({
|
this.prisma.deal.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId: orgId,
|
AND: [
|
||||||
...dealScope,
|
|
||||||
OR: [
|
|
||||||
{ status: 'open' },
|
|
||||||
{
|
{
|
||||||
status: { in: ['won', 'lost'] },
|
organizationId: orgId,
|
||||||
updatedAt: { gte: dealEarliest },
|
...dealScope,
|
||||||
|
OR: [
|
||||||
|
{ status: 'open' },
|
||||||
|
{
|
||||||
|
status: { in: ['won', 'lost'] },
|
||||||
|
updatedAt: { gte: dealEarliest },
|
||||||
|
},
|
||||||
|
{ createdAt: { gte: dealEarliest } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{ createdAt: { gte: dealEarliest } },
|
dealsPrior,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -118,6 +131,7 @@ export class ReportsService {
|
|||||||
where: {
|
where: {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
...activityScope,
|
...activityScope,
|
||||||
|
...activitiesPrior,
|
||||||
createdAt: { gte: prevStart, lte: end },
|
createdAt: { gte: prevStart, lte: end },
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -146,6 +160,7 @@ export class ReportsService {
|
|||||||
where: {
|
where: {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
...personScope,
|
...personScope,
|
||||||
|
...peoplePrior,
|
||||||
},
|
},
|
||||||
select: { mood: true, createdAt: true },
|
select: { mood: true, createdAt: true },
|
||||||
}),
|
}),
|
||||||
@@ -1536,6 +1551,9 @@ export class ReportsService {
|
|||||||
const yesterdayEnd = endOfDay(yesterday);
|
const yesterdayEnd = endOfDay(yesterday);
|
||||||
const weekAgo = new Date(now);
|
const weekAgo = new Date(now);
|
||||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||||
|
const peoplePrior = personPriorWhere(actor.priorLens);
|
||||||
|
const dealsPrior = dealPriorWhere(actor.priorLens, now);
|
||||||
|
const activitiesPrior = activityPriorWhere(actor.priorLens, now);
|
||||||
|
|
||||||
const [
|
const [
|
||||||
wonDeals,
|
wonDeals,
|
||||||
@@ -1554,6 +1572,7 @@ export class ReportsService {
|
|||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
status: 'won',
|
status: 'won',
|
||||||
...dealScope,
|
...dealScope,
|
||||||
|
...dealsPrior,
|
||||||
updatedAt: { gte: addMonthsStart(now, -5) },
|
updatedAt: { gte: addMonthsStart(now, -5) },
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -1568,17 +1587,22 @@ export class ReportsService {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.deal.count({
|
this.prisma.deal.count({
|
||||||
where: { organizationId: orgId, status: 'open', ...dealScope },
|
where: { organizationId: orgId, status: 'open', ...dealScope, ...dealsPrior },
|
||||||
}),
|
}),
|
||||||
this.prisma.deal.findMany({
|
this.prisma.deal.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId: orgId,
|
AND: [
|
||||||
status: 'open',
|
{
|
||||||
...dealScope,
|
organizationId: orgId,
|
||||||
OR: [
|
status: 'open',
|
||||||
{ isStale: true },
|
...dealScope,
|
||||||
{ isRotten: true },
|
OR: [
|
||||||
{ updatedAt: { lt: daysAgo(5) } },
|
{ isStale: true },
|
||||||
|
{ isRotten: true },
|
||||||
|
{ updatedAt: { lt: daysAgo(5) } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
dealsPrior,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -1594,6 +1618,7 @@ export class ReportsService {
|
|||||||
where: {
|
where: {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
...personScope,
|
...personScope,
|
||||||
|
...peoplePrior,
|
||||||
createdAt: { gte: curMonthStart, lte: curMonthEnd },
|
createdAt: { gte: curMonthStart, lte: curMonthEnd },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -1601,6 +1626,7 @@ export class ReportsService {
|
|||||||
where: {
|
where: {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
...personScope,
|
...personScope,
|
||||||
|
...peoplePrior,
|
||||||
createdAt: { gte: prevMonthStart, lte: prevMonthEnd },
|
createdAt: { gte: prevMonthStart, lte: prevMonthEnd },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -1608,6 +1634,7 @@ export class ReportsService {
|
|||||||
where: {
|
where: {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
...activityScope,
|
...activityScope,
|
||||||
|
...activitiesPrior,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
dueAt: { gte: todayStart, lte: todayEnd },
|
dueAt: { gte: todayStart, lte: todayEnd },
|
||||||
},
|
},
|
||||||
@@ -1616,6 +1643,7 @@ export class ReportsService {
|
|||||||
where: {
|
where: {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
...activityScope,
|
...activityScope,
|
||||||
|
...activitiesPrior,
|
||||||
dueAt: { gte: yesterdayStart, lte: yesterdayEnd },
|
dueAt: { gte: yesterdayStart, lte: yesterdayEnd },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -1624,6 +1652,7 @@ export class ReportsService {
|
|||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
...dealScope,
|
...dealScope,
|
||||||
|
...dealsPrior,
|
||||||
updatedAt: { lte: weekAgo },
|
updatedAt: { lte: weekAgo },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -1639,6 +1668,7 @@ export class ReportsService {
|
|||||||
where: {
|
where: {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
...activityScope,
|
...activityScope,
|
||||||
|
...activitiesPrior,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
dueAt: { lt: now },
|
dueAt: { lt: now },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -96,6 +96,21 @@ export class UsersController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id/prior-archive')
|
||||||
|
@Roles('owner')
|
||||||
|
updatePriorArchive(
|
||||||
|
@CurrentUser() user: { organizationId: string; role: UserRole },
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { enabled?: boolean },
|
||||||
|
) {
|
||||||
|
return this.usersService.updatePriorArchive(
|
||||||
|
user.organizationId,
|
||||||
|
user.role,
|
||||||
|
id,
|
||||||
|
body.enabled === true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':id/visibility')
|
@Patch(':id/visibility')
|
||||||
@Roles('owner', 'admin')
|
@Roles('owner', 'admin')
|
||||||
updateVisibility(
|
updateVisibility(
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type MembershipRow = {
|
|||||||
commissionPercent: { toString(): string } | number | null;
|
commissionPercent: { toString(): string } | number | null;
|
||||||
dataVisibility?: string | null;
|
dataVisibility?: string | null;
|
||||||
dataVisibilityScopes?: unknown;
|
dataVisibilityScopes?: unknown;
|
||||||
|
priorArchiveEnabled?: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
orgRole: { id: string; name: string; color: string } | null;
|
orgRole: { id: string; name: string; color: string } | null;
|
||||||
user: {
|
user: {
|
||||||
@@ -241,6 +242,32 @@ export class UsersService {
|
|||||||
return this.dtoOf(organizationId, targetId);
|
return this.dtoOf(organizationId, targetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updatePriorArchive(
|
||||||
|
organizationId: string,
|
||||||
|
actorRole: UserRole,
|
||||||
|
targetId: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<OrgUserDto> {
|
||||||
|
if (actorRole !== 'owner') {
|
||||||
|
throw new ForbiddenException('فقط مالک میتواند بایگانی اطلاعات قبل را برای کاربران روشن کند');
|
||||||
|
}
|
||||||
|
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
|
||||||
|
if (enabled) {
|
||||||
|
const org = await this.prisma.organization.findUnique({
|
||||||
|
where: { id: organizationId },
|
||||||
|
select: { enabledModules: true },
|
||||||
|
});
|
||||||
|
if (!org?.enabledModules?.includes('prior_data_archive')) {
|
||||||
|
throw new ForbiddenException('بایگانی اطلاعات قبل برای این محیط فعال نیست');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.prisma.membership.update({
|
||||||
|
where: { id: target.id },
|
||||||
|
data: { priorArchiveEnabled: enabled },
|
||||||
|
});
|
||||||
|
return this.dtoOf(organizationId, targetId);
|
||||||
|
}
|
||||||
|
|
||||||
async updateVisibility(
|
async updateVisibility(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
actorRole: UserRole,
|
actorRole: UserRole,
|
||||||
@@ -478,6 +505,7 @@ export class UsersService {
|
|||||||
row.role === 'owner' || row.role === 'admin'
|
row.role === 'owner' || row.role === 'admin'
|
||||||
? {}
|
? {}
|
||||||
: parseVisibilityScopes(row.dataVisibilityScopes),
|
: parseVisibilityScopes(row.dataVisibilityScopes),
|
||||||
|
priorArchiveEnabled: Boolean(row.priorArchiveEnabled),
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { classifyCountQuestion } from '../src/ai/ask-count';
|
||||||
|
|
||||||
|
describe('سؤالهای شمارشی دستیار', () => {
|
||||||
|
it('لید جدید هفته گذشته را ساختن حساب نمیکند', () => {
|
||||||
|
const intent = classifyCountQuestion('تعداد لید جدید در هفته گذشته؟');
|
||||||
|
assert.ok(intent);
|
||||||
|
assert.equal(intent.entity, 'lead');
|
||||||
|
assert.equal(intent.period, 'last_week');
|
||||||
|
assert.equal(intent.leadBucket, 'summary');
|
||||||
|
assert.equal(intent.hint, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('هفتهی گذشته و ماه قبل را هم میفهمد', () => {
|
||||||
|
assert.equal(classifyCountQuestion('چند مشتری جدید هفتهی گذشته داشتیم؟')?.period, 'last_week');
|
||||||
|
assert.equal(classifyCountQuestion('تعداد معاملات ماه قبل')?.period, 'last_month');
|
||||||
|
assert.equal(classifyCountQuestion('تعداد فعالیت جدید این هفته')?.period, 'week');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('مرحلهٔ جدید را با بازه قاطی نمیکند', () => {
|
||||||
|
const intent = classifyCountQuestion('چند لید در مرحله جدید هفته گذشته داریم؟');
|
||||||
|
assert.ok(intent);
|
||||||
|
assert.equal(intent.period, 'last_week');
|
||||||
|
assert.equal(intent.hint, 'جدید');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('بدون بازه، لید جدید یعنی مرحلهٔ جدید', () => {
|
||||||
|
const intent = classifyCountQuestion('تعداد لید جدید');
|
||||||
|
assert.ok(intent);
|
||||||
|
assert.equal(intent.period, undefined);
|
||||||
|
assert.equal(intent.hint, 'جدید');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('دستور ساخت را سؤال شمارشی نمیگیرد', () => {
|
||||||
|
assert.equal(classifyCountQuestion('یک لید جدید به نام علی بساز'), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
activityPriorWhere,
|
||||||
|
dealPriorWhere,
|
||||||
|
listPriorLens,
|
||||||
|
personPriorWhere,
|
||||||
|
} from '../src/common/prior-archive';
|
||||||
|
|
||||||
|
const now = new Date('2026-10-15T08:00:00.000Z');
|
||||||
|
|
||||||
|
describe('بایگانی اطلاعات قبل', () => {
|
||||||
|
it('بدون مجوز فیلتر نمیگذارد', () => {
|
||||||
|
assert.deepEqual(personPriorWhere('off'), {});
|
||||||
|
assert.deepEqual(dealPriorWhere(undefined, now), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('کار جاری شخص قبلی را کنار میگذارد و معاملهٔ بازِ موعددار را نگه میدارد', () => {
|
||||||
|
assert.deepEqual(personPriorWhere('current'), { importSource: { not: 'didar' } });
|
||||||
|
const where = dealPriorWhere('current', now);
|
||||||
|
assert.equal(where.OR?.length, 2);
|
||||||
|
assert.deepEqual(where.OR?.[1], {
|
||||||
|
importSource: 'didar',
|
||||||
|
status: 'open',
|
||||||
|
expectedCloseAt: { gt: now },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('بایگانی فقط قبلیها را میآورد و استثنای باز را برمیدارد', () => {
|
||||||
|
const where = activityPriorWhere('archive', now);
|
||||||
|
assert.equal(where.importSource, 'didar');
|
||||||
|
assert.deepEqual(where.NOT, {
|
||||||
|
importSource: 'didar',
|
||||||
|
status: 'open',
|
||||||
|
dueAt: { gt: now },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('جستجوی کار جاری شخص قبلی را پیدا میکند', () => {
|
||||||
|
assert.equal(listPriorLens('current', 'علی', false), 'off');
|
||||||
|
assert.equal(listPriorLens('current', ' ', false), 'current');
|
||||||
|
assert.equal(listPriorLens('archive', 'علی', false), 'archive');
|
||||||
|
assert.equal(listPriorLens('current', '', true), 'off');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import type {
|
import type {
|
||||||
OrgUserDto,
|
OrgUserDto,
|
||||||
TeamDto,
|
TeamDto,
|
||||||
@@ -23,6 +24,15 @@ const props = defineProps<{
|
|||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const showPriorArchive = computed(
|
||||||
|
() =>
|
||||||
|
auth.user?.role === 'owner' &&
|
||||||
|
Boolean(auth.user.enabledModules?.includes('prior_data_archive')),
|
||||||
|
);
|
||||||
|
const priorUsers = computed(() => props.users.filter((u) => u.isActive !== false));
|
||||||
|
const priorSavingId = ref('');
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
refreshUsers: [];
|
refreshUsers: [];
|
||||||
}>();
|
}>();
|
||||||
@@ -45,6 +55,25 @@ const editableUsers = computed(() =>
|
|||||||
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
|
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function setPriorArchive(userId: string, enabled: boolean) {
|
||||||
|
priorSavingId.value = userId;
|
||||||
|
error.value = '';
|
||||||
|
ok.value = '';
|
||||||
|
try {
|
||||||
|
await api.patch(`/users/${userId}/prior-archive`, { enabled });
|
||||||
|
ok.value = enabled
|
||||||
|
? 'بایگانی اطلاعات قبل برای این کاربر روشن شد.'
|
||||||
|
: 'بایگانی اطلاعات قبل برای این کاربر خاموش شد.';
|
||||||
|
emit('refreshUsers');
|
||||||
|
} catch (e) {
|
||||||
|
error.value = axios.isAxiosError(e)
|
||||||
|
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره ناموفق بود.')
|
||||||
|
: 'ذخیره ناموفق بود.';
|
||||||
|
} finally {
|
||||||
|
priorSavingId.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
@@ -296,6 +325,23 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="showPriorArchive" class="card">
|
||||||
|
<h3>بایگانی اطلاعات قبل</h3>
|
||||||
|
<p class="hint">
|
||||||
|
فقط کاربرانی که تیک میخورند سوئیچ کار جاری و بایگانی را میبینند. بقیهٔ محیط مثل قبل همهٔ
|
||||||
|
رکوردها را مطابق سطح دسترسی خودشان میبینند.
|
||||||
|
</p>
|
||||||
|
<label v-for="u in priorUsers" :key="u.id" class="vis-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="Boolean(u.priorArchiveEnabled)"
|
||||||
|
:disabled="priorSavingId === u.id"
|
||||||
|
@change="setPriorArchive(u.id, ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
<span>{{ u.fullName }}</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>سطح دسترسی به پروندهها</h3>
|
<h3>سطح دسترسی به پروندهها</h3>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ function onVoiceTranscript(text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeAction(text: string) {
|
function looksLikeAction(text: string) {
|
||||||
|
if (/[؟?]/.test(text) || /چند|تعداد|چقدر|چنتا|چیست|چیه|کدام|کدوم|چرا|چطور|چگونه/.test(text)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return /بساز|بسازید|ثبت کن|ثبت کنید|اضافه کن|ایجاد کن|یک مشتری|مشتری جدید|یک فعالیت|فعالیت جدید|یک لید|لید جدید/.test(
|
return /بساز|بسازید|ثبت کن|ثبت کنید|اضافه کن|ایجاد کن|یک مشتری|مشتری جدید|یک فعالیت|فعالیت جدید|یک لید|لید جدید/.test(
|
||||||
text,
|
text,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import GamificationToaster from '@/components/gamification/GamificationToaster.v
|
|||||||
import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs';
|
import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs';
|
||||||
import type {
|
import type {
|
||||||
ActivitySummaryDto,
|
ActivitySummaryDto,
|
||||||
|
AuthUserDto,
|
||||||
NotificationDto,
|
NotificationDto,
|
||||||
NotificationsListDto,
|
NotificationsListDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
@@ -40,6 +41,20 @@ const isMobileNav = ref(false);
|
|||||||
const globalSearch = ref('');
|
const globalSearch = ref('');
|
||||||
const activityBadge = ref(0);
|
const activityBadge = ref(0);
|
||||||
const routineBadge = ref(0);
|
const routineBadge = ref(0);
|
||||||
|
const priorSaving = ref(false);
|
||||||
|
const priorLens = computed(() => auth.user?.priorArchive?.lens ?? 'current');
|
||||||
|
|
||||||
|
async function setPriorLens(lens: 'current' | 'archive') {
|
||||||
|
if (!auth.user?.priorArchive || priorLens.value === lens || priorSaving.value) return;
|
||||||
|
priorSaving.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await api.patch<AuthUserDto>('/auth/me/prior-archive', { lens });
|
||||||
|
auth.setUser(data);
|
||||||
|
window.location.reload();
|
||||||
|
} finally {
|
||||||
|
priorSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function pathIn(prefixes: string[]) {
|
function pathIn(prefixes: string[]) {
|
||||||
return prefixes.some(
|
return prefixes.some(
|
||||||
@@ -665,6 +680,24 @@ onUnmounted(() => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="sidebar-foot">
|
<div class="sidebar-foot">
|
||||||
|
<div v-if="auth.user?.priorArchive" class="prior-switch" role="group" aria-label="بایگانی اطلاعات قبل">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="{ on: priorLens === 'current' }"
|
||||||
|
:disabled="priorSaving"
|
||||||
|
@click="setPriorLens('current')"
|
||||||
|
>
|
||||||
|
کار جاری
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="{ on: priorLens === 'archive' }"
|
||||||
|
:disabled="priorSaving"
|
||||||
|
@click="setPriorLens('archive')"
|
||||||
|
>
|
||||||
|
بایگانی اطلاعات قبل
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="foot-actions">
|
<div class="foot-actions">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
class="profile-card profile-foot"
|
class="profile-card profile-foot"
|
||||||
@@ -1312,6 +1345,35 @@ onUnmounted(() => {
|
|||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prior-switch {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prior-switch button {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #475569;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.35rem 0.3rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prior-switch button.on {
|
||||||
|
background: #f0fdfa;
|
||||||
|
border-color: #99f6e4;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prior-switch button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
.foot-meta {
|
.foot-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
@@ -1769,6 +1831,10 @@ onUnmounted(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .prior-switch {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar.collapsed .logout-link {
|
.sidebar.collapsed .logout-link {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { formatCompactMoney, formatMoney } from '@/lib/money';
|
import { formatCompactMoney, formatMoney } from '@/lib/money';
|
||||||
import {
|
import {
|
||||||
calendarDaysBetween,
|
calendarDaysBetween,
|
||||||
@@ -60,6 +61,7 @@ type TimelineRow = {
|
|||||||
tone: TimelineTone;
|
tone: TimelineTone;
|
||||||
href: string;
|
href: string;
|
||||||
at: number;
|
at: number;
|
||||||
|
prior?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AttachmentRow = {
|
type AttachmentRow = {
|
||||||
@@ -180,6 +182,27 @@ const previousSourceLabel = computed(() => {
|
|||||||
return SOURCE_LABELS[src] || src;
|
return SOURCE_LABELS[src] || src;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const priorArchiveOn = computed(() => Boolean(auth.user?.priorArchive));
|
||||||
|
|
||||||
|
function isPriorActivity(row: ActivityDto) {
|
||||||
|
if (row.importSource !== 'didar') return false;
|
||||||
|
if (row.status === 'open' && row.dueAt && new Date(row.dueAt).getTime() > Date.now()) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPriorDeal(row: DealDto) {
|
||||||
|
if (row.importSource !== 'didar') return false;
|
||||||
|
if (
|
||||||
|
row.status === 'open' &&
|
||||||
|
row.expectedCloseAt &&
|
||||||
|
new Date(row.expectedCloseAt).getTime() > Date.now()
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const lastCallAt = computed(() => {
|
const lastCallAt = computed(() => {
|
||||||
const calls = activities.value
|
const calls = activities.value
|
||||||
.filter((a) => a.type === 'call')
|
.filter((a) => a.type === 'call')
|
||||||
@@ -210,6 +233,12 @@ const nextActivity = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const openDeals = computed(() => deals.value.filter((d) => d.status === 'open'));
|
const openDeals = computed(() => deals.value.filter((d) => d.status === 'open'));
|
||||||
|
const currentDeals = computed(() =>
|
||||||
|
priorArchiveOn.value ? deals.value.filter((d) => !isPriorDeal(d)) : deals.value,
|
||||||
|
);
|
||||||
|
const priorDeals = computed(() =>
|
||||||
|
priorArchiveOn.value ? deals.value.filter((d) => isPriorDeal(d)) : [],
|
||||||
|
);
|
||||||
|
|
||||||
const lastQuote = computed(
|
const lastQuote = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -308,6 +337,7 @@ const timeline = computed<TimelineRow[]>(() => {
|
|||||||
tone: activityTone(a),
|
tone: activityTone(a),
|
||||||
href: `/activities/${a.id}`,
|
href: `/activities/${a.id}`,
|
||||||
at,
|
at,
|
||||||
|
prior: isPriorActivity(a),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const n of notes.value) {
|
for (const n of notes.value) {
|
||||||
@@ -340,7 +370,17 @@ const visibleTimeline = computed(() => {
|
|||||||
historyFilter.value === 'all'
|
historyFilter.value === 'all'
|
||||||
? timeline.value
|
? timeline.value
|
||||||
: timeline.value.filter((row) => row.tone === historyFilter.value);
|
: timeline.value.filter((row) => row.tone === historyFilter.value);
|
||||||
return rows.slice(0, 8);
|
const current = priorArchiveOn.value ? rows.filter((row) => !row.prior) : rows;
|
||||||
|
return current.slice(0, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
const priorTimeline = computed(() => {
|
||||||
|
if (!priorArchiveOn.value) return [];
|
||||||
|
const rows =
|
||||||
|
historyFilter.value === 'all'
|
||||||
|
? timeline.value
|
||||||
|
: timeline.value.filter((row) => row.tone === historyFilter.value);
|
||||||
|
return rows.filter((row) => row.prior).slice(0, 8);
|
||||||
});
|
});
|
||||||
|
|
||||||
function fileKind(name: string, mime: string): AttachmentRow['kind'] {
|
function fileKind(name: string, mime: string): AttachmentRow['kind'] {
|
||||||
@@ -442,7 +482,7 @@ async function load() {
|
|||||||
api.get<PaginatedResponse<MemoryItemDto>>('/memory', {
|
api.get<PaginatedResponse<MemoryItemDto>>('/memory', {
|
||||||
params: { personId: id.value, limit: 50 },
|
params: { personId: id.value, limit: 50 },
|
||||||
}),
|
}),
|
||||||
api.get<PipelineBoardDto>('/pipelines/board'),
|
api.get<PipelineBoardDto>('/pipelines/board', { params: { prior: 'all' } }),
|
||||||
]);
|
]);
|
||||||
person.value = personRes.data;
|
person.value = personRes.data;
|
||||||
activities.value = actRes.data.items;
|
activities.value = actRes.data.items;
|
||||||
@@ -832,7 +872,24 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p v-else class="empty">موردی با این فیلتر نیست.</p>
|
<p v-else-if="!priorTimeline.length" class="empty">موردی با این فیلتر نیست.</p>
|
||||||
|
<template v-if="priorTimeline.length">
|
||||||
|
<h3 class="prior-head">بایگانی اطلاعات قبل</h3>
|
||||||
|
<ol class="tl">
|
||||||
|
<li v-for="row in priorTimeline" :key="row.key">
|
||||||
|
<RouterLink :to="row.href">
|
||||||
|
<span class="dot" :class="row.tone">
|
||||||
|
<UiIcon :name="timelineIcon(row.tone)" :size="14" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<time>{{ row.when }}</time>
|
||||||
|
<strong>{{ row.title }}</strong>
|
||||||
|
<p v-if="row.body">{{ row.body }}</p>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</template>
|
||||||
<RouterLink class="see-all" :to="{ name: 'activities', query: { filter: 'all' } }">
|
<RouterLink class="see-all" :to="{ name: 'activities', query: { filter: 'all' } }">
|
||||||
مشاهده همه فعالیتها
|
مشاهده همه فعالیتها
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -896,7 +953,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
|||||||
/>
|
/>
|
||||||
<div v-if="!dealsEmpty">
|
<div v-if="!dealsEmpty">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="deal in deals"
|
v-for="deal in currentDeals"
|
||||||
:key="deal.id"
|
:key="deal.id"
|
||||||
class="row-card"
|
class="row-card"
|
||||||
:to="{ name: 'deal-detail', params: { id: deal.id } }"
|
:to="{ name: 'deal-detail', params: { id: deal.id } }"
|
||||||
@@ -905,6 +962,19 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
|||||||
<span>{{ deal.amount != null ? formatCompactMoney(deal.amount, deal.currency) : 'بدون مبلغ' }}</span>
|
<span>{{ deal.amount != null ? formatCompactMoney(deal.amount, deal.currency) : 'بدون مبلغ' }}</span>
|
||||||
<em>{{ dealStatusLabel[deal.status] ?? deal.status }}</em>
|
<em>{{ dealStatusLabel[deal.status] ?? deal.status }}</em>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<div v-if="priorDeals.length" class="subblock">
|
||||||
|
<h3>بایگانی اطلاعات قبل</h3>
|
||||||
|
<RouterLink
|
||||||
|
v-for="deal in priorDeals"
|
||||||
|
:key="deal.id"
|
||||||
|
class="row-card"
|
||||||
|
:to="{ name: 'deal-detail', params: { id: deal.id } }"
|
||||||
|
>
|
||||||
|
<strong>{{ deal.title }}</strong>
|
||||||
|
<span>{{ deal.amount != null ? formatCompactMoney(deal.amount, deal.currency) : 'بدون مبلغ' }}</span>
|
||||||
|
<em>{{ dealStatusLabel[deal.status] ?? deal.status }}</em>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
<div v-if="invoices.length" class="subblock">
|
<div v-if="invoices.length" class="subblock">
|
||||||
<h3>فاکتورها</h3>
|
<h3>فاکتورها</h3>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
|
|||||||
@@ -584,6 +584,19 @@ onMounted(() => {
|
|||||||
حرفهای (چندشاخه، شرط روی پاسخ فرم، تقسیم نوبتی)
|
حرفهای (چندشاخه، شرط روی پاسخ فرم، تقسیم نوبتی)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mod-group">
|
||||||
|
<div class="mod-group-head">
|
||||||
|
<span>بایگانی اطلاعات قبل</span>
|
||||||
|
</div>
|
||||||
|
<label class="mod-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="moduleDraft.includes('prior_data_archive')"
|
||||||
|
@change="toggleModule('prior_data_archive')"
|
||||||
|
/>
|
||||||
|
بایگانی اطلاعات قبل
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chat-settings">
|
<div class="chat-settings">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# بایگانی اطلاعات قبل
|
# بایگانی اطلاعات قبل
|
||||||
|
|
||||||
تاریخ: ۲۰۲۶-۱۰-۱۴
|
تاریخ: ۲۰۲۶-۱۰-۱۴
|
||||||
وضعیت: قفلشده توسط کاربر؛ پیادهسازی شروع نشده است. تا وقتی کاربر صریح بگوید «شروع کن»، کدی برای این قابلیت نوشته نمیشود.
|
وضعیت: قفلشده توسط کاربر و پیادهسازی شروع شده است.
|
||||||
|
|
||||||
## هدف
|
## هدف
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export interface AuthUserDto {
|
|||||||
enabledModules?: OrgModuleKey[];
|
enabledModules?: OrgModuleKey[];
|
||||||
/** خلاصه اشتراک مالک محیط (برای اعضا فقط جهت نمایش/گیت ورود) */
|
/** خلاصه اشتراک مالک محیط (برای اعضا فقط جهت نمایش/گیت ورود) */
|
||||||
subscription?: SubscriptionSummaryDto | null;
|
subscription?: SubscriptionSummaryDto | null;
|
||||||
|
/** اگر باشد، این کاربر سوئیچ «بایگانی اطلاعات قبل» را دارد */
|
||||||
|
priorArchive?: { lens: "current" | "archive" } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BillingPlanCode =
|
export type BillingPlanCode =
|
||||||
@@ -141,7 +143,8 @@ export type OrgModuleKey =
|
|||||||
| "leads"
|
| "leads"
|
||||||
| "conversations"
|
| "conversations"
|
||||||
| "routines"
|
| "routines"
|
||||||
| "gamification";
|
| "gamification"
|
||||||
|
| "prior_data_archive";
|
||||||
|
|
||||||
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
||||||
"customers",
|
"customers",
|
||||||
@@ -182,6 +185,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
|||||||
"conversations",
|
"conversations",
|
||||||
"routines",
|
"routines",
|
||||||
"gamification",
|
"gamification",
|
||||||
|
"prior_data_archive",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
||||||
@@ -223,6 +227,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
|||||||
conversations: "گفتگو",
|
conversations: "گفتگو",
|
||||||
routines: "کارهای روتین",
|
routines: "کارهای روتین",
|
||||||
gamification: "باشگاه امتیازات",
|
gamification: "باشگاه امتیازات",
|
||||||
|
prior_data_archive: "بایگانی اطلاعات قبل",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ORG_MODULE_GROUPS: Array<{
|
export const ORG_MODULE_GROUPS: Array<{
|
||||||
@@ -287,7 +292,10 @@ export function isOrgModuleKey(value: string): value is OrgModuleKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** ماژولهایی که باید صراحتاً توسط سوپرادمین فعال شوند */
|
/** ماژولهایی که باید صراحتاً توسط سوپرادمین فعال شوند */
|
||||||
export const OPT_IN_ORG_MODULES: OrgModuleKey[] = ["workflow_automations_advanced"];
|
export const OPT_IN_ORG_MODULES: OrgModuleKey[] = [
|
||||||
|
"workflow_automations_advanced",
|
||||||
|
"prior_data_archive",
|
||||||
|
];
|
||||||
|
|
||||||
export function defaultEnabledModules(): OrgModuleKey[] {
|
export function defaultEnabledModules(): OrgModuleKey[] {
|
||||||
return ORG_MODULE_KEYS.filter((k) => !OPT_IN_ORG_MODULES.includes(k));
|
return ORG_MODULE_KEYS.filter((k) => !OPT_IN_ORG_MODULES.includes(k));
|
||||||
@@ -346,6 +354,8 @@ export interface OrgUserDto {
|
|||||||
dataVisibility?: DataVisibility;
|
dataVisibility?: DataVisibility;
|
||||||
/** سطح دسترسی تفکیکی هر بخش (فقط بخشهایی که با پیشفرض فرق دارند) */
|
/** سطح دسترسی تفکیکی هر بخش (فقط بخشهایی که با پیشفرض فرق دارند) */
|
||||||
dataVisibilityScopes?: DataVisibilityScopes;
|
dataVisibilityScopes?: DataVisibilityScopes;
|
||||||
|
/** تیک مالک: این کاربر سوئیچ بایگانی اطلاعات قبل را ببیند */
|
||||||
|
priorArchiveEnabled?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1465,6 +1475,8 @@ export interface ActivityDto {
|
|||||||
/** زمان ارسال یادآور (اگر ارسال شده باشد) */
|
/** زمان ارسال یادآور (اگر ارسال شده باشد) */
|
||||||
reminderSentAt: string | null;
|
reminderSentAt: string | null;
|
||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
|
/** manual یا didar؛ برای جدا کردن سابقهٔ قبلی */
|
||||||
|
importSource?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
person: PersonSummaryDto | null;
|
person: PersonSummaryDto | null;
|
||||||
company: CompanySummaryDto | null;
|
company: CompanySummaryDto | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user