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",
|
||||
"lint": "echo \"api lint placeholder\"",
|
||||
"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": {
|
||||
"@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"])
|
||||
/// ماژولهای فعال منو برای این محیط
|
||||
enabledModules String[] @default([])
|
||||
/// اولین لحظهٔ روشن شدن «بایگانی اطلاعات قبل»؛ مرز فعالیتهای قبلی
|
||||
priorArchiveEnabledAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -348,6 +350,10 @@ model Membership {
|
||||
dataVisibility String @default("own")
|
||||
/// سطح دید تفکیکی هر بخش، مثلاً {"contacts":"org","deals":"own"}؛ بخشهای بدون مقدار = dataVisibility
|
||||
dataVisibilityScopes Json @default("{}")
|
||||
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
|
||||
priorArchiveEnabled Boolean @default(false)
|
||||
/// current | archive
|
||||
priorArchiveLens String @default("current")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -695,6 +701,8 @@ model Activity {
|
||||
status ActivityStatus @default(open)
|
||||
priority ActivityPriority @default(medium)
|
||||
title String
|
||||
/// manual | didar — سابقهٔ منتقلشده از اطلاعات قبل
|
||||
importSource String @default("manual")
|
||||
description String?
|
||||
dueAt DateTime?
|
||||
durationMinutes Int?
|
||||
@@ -741,6 +749,7 @@ model Activity {
|
||||
@@index([typeDefId])
|
||||
@@index([leadId])
|
||||
@@index([organizationId, status, dueAt])
|
||||
@@index([organizationId, importSource])
|
||||
@@index([organizationId, assigneeId])
|
||||
@@index([organizationId, createdById])
|
||||
@@index([reminderAt, reminderSentAt])
|
||||
|
||||
@@ -28,6 +28,7 @@ import { normalizeTags } from '../common/tags';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import { tehranDayKey } from '../routines/routine-dates';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { activityPriorWhere } from '../common/prior-archive';
|
||||
import { ActivityStagesService, type StageDefaults } from './activity-stages.service';
|
||||
import {
|
||||
ActivityTypesService,
|
||||
@@ -37,7 +38,7 @@ import {
|
||||
|
||||
type DtoContext = { stages?: StageDefaults; types?: SystemTypes };
|
||||
|
||||
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
|
||||
type ActivityActor = Pick<Actor, 'userId' | 'role' | 'priorLens'>;
|
||||
|
||||
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمیکند (جز عقبافتاده) */
|
||||
type StageScope = { stageId: string };
|
||||
@@ -63,6 +64,7 @@ type ActivityRow = {
|
||||
reminderNotifySms: boolean;
|
||||
reminderSentAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
importSource?: string;
|
||||
tags: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -117,31 +119,47 @@ export class ActivitiesService {
|
||||
): Promise<ActivitySummaryDto> {
|
||||
const { start, end } = dayBounds();
|
||||
const scope = activityAccessWhere(actor);
|
||||
const prior = activityPriorWhere(actor.priorLens);
|
||||
const [todayCount, overdueCount] = await Promise.all([
|
||||
this.prisma.activity.count({
|
||||
where: {
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
dueAt: { gte: start, lt: end },
|
||||
AND: [
|
||||
{
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
dueAt: { gte: start, lt: end },
|
||||
},
|
||||
prior,
|
||||
],
|
||||
},
|
||||
}),
|
||||
this.prisma.activity.count({
|
||||
where: {
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
dueAt: { lt: start },
|
||||
AND: [
|
||||
{
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
dueAt: { lt: start },
|
||||
},
|
||||
prior,
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const [today, overdue, upcoming] = await Promise.all([
|
||||
this.prisma.activity.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
dueAt: { gte: start, lt: end },
|
||||
AND: [
|
||||
{
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
dueAt: { gte: start, lt: end },
|
||||
},
|
||||
prior,
|
||||
],
|
||||
},
|
||||
include,
|
||||
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
@@ -149,10 +167,15 @@ export class ActivitiesService {
|
||||
}),
|
||||
this.prisma.activity.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
dueAt: { lt: start },
|
||||
AND: [
|
||||
{
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
dueAt: { lt: start },
|
||||
},
|
||||
prior,
|
||||
],
|
||||
},
|
||||
include,
|
||||
orderBy: [{ dueAt: 'asc' }],
|
||||
@@ -160,10 +183,15 @@ export class ActivitiesService {
|
||||
}),
|
||||
this.prisma.activity.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
|
||||
AND: [
|
||||
{
|
||||
organizationId,
|
||||
...scope,
|
||||
status: 'open',
|
||||
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
|
||||
},
|
||||
prior,
|
||||
],
|
||||
},
|
||||
include,
|
||||
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
|
||||
@@ -671,6 +699,8 @@ export class ActivitiesService {
|
||||
...this.types.searchByName(typeRows, t),
|
||||
]);
|
||||
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 = {
|
||||
organizationId,
|
||||
AND: andParts,
|
||||
@@ -868,6 +898,7 @@ export class ActivitiesService {
|
||||
reminderNotifySms: row.reminderNotifySms,
|
||||
reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
|
||||
completedAt: row.completedAt?.toISOString() ?? null,
|
||||
importSource: row.importSource ?? 'manual',
|
||||
tags: row.tags ?? [],
|
||||
person: row.person
|
||||
? {
|
||||
|
||||
@@ -32,13 +32,26 @@ import {
|
||||
type PersonImportSource,
|
||||
type UserRole,
|
||||
} 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 {
|
||||
classifyCountQuestion,
|
||||
personSourceKeys,
|
||||
type CountIntent,
|
||||
} from './ask-count';
|
||||
import {
|
||||
activityPriorWhere,
|
||||
companyPriorWhere,
|
||||
dealPriorWhere,
|
||||
personPriorWhere,
|
||||
} from '../common/prior-archive';
|
||||
|
||||
type CatalogItem = AskMentoSourceDto;
|
||||
|
||||
@@ -51,6 +64,10 @@ type PeriodCounts = {
|
||||
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) {
|
||||
const text = (value ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (!text) return '';
|
||||
@@ -116,6 +133,14 @@ function tehranPeriods(now = new Date()) {
|
||||
start: dayBoundsFromKey(weekStartKey).start,
|
||||
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 [gy, gm, gd] = dayKey.split('-').map(Number);
|
||||
return toJalali(gy!, gm!, gd!);
|
||||
@@ -127,7 +152,21 @@ function tehranPeriods(now = new Date()) {
|
||||
start: monthStart,
|
||||
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) {
|
||||
@@ -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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
@@ -240,6 +279,8 @@ Rules:
|
||||
if (key === 'today') return periods.today;
|
||||
if (key === 'yesterday') return periods.yesterday;
|
||||
if (key === 'week') return periods.week;
|
||||
if (key === 'last_week') return periods.lastWeek;
|
||||
if (key === 'last_month') return periods.lastMonth;
|
||||
return periods.month;
|
||||
}
|
||||
|
||||
@@ -261,24 +302,38 @@ Rules:
|
||||
? 'امروز'
|
||||
: intent.period === 'yesterday'
|
||||
? 'دیروز'
|
||||
: intent.period === 'week'
|
||||
? 'این هفته'
|
||||
: intent.period === 'month'
|
||||
? 'این ماه'
|
||||
: intent.period === 'week'
|
||||
? 'این هفته'
|
||||
: intent.period === 'last_week'
|
||||
? 'هفته گذشته'
|
||||
: intent.period === 'month'
|
||||
? 'این ماه'
|
||||
: intent.period === 'last_month'
|
||||
? 'ماه گذشته'
|
||||
: '';
|
||||
|
||||
if (intent.entity === 'person' || intent.entity === 'company') {
|
||||
const where = {
|
||||
const shared = {
|
||||
organizationId: orgId,
|
||||
...(intent.entity === 'person' ? ownerWhere(actor) : ownerWhere(actor)),
|
||||
...ownerWhere(actor),
|
||||
...(intent.personSource ? { importSource: intent.personSource } : {}),
|
||||
...(intent.entity === 'person' && intent.mood ? { mood: intent.mood } : {}),
|
||||
...created,
|
||||
};
|
||||
const n =
|
||||
intent.entity === 'person'
|
||||
? await this.prisma.person.count({ where })
|
||||
: await this.prisma.company.count({ where });
|
||||
? await this.prisma.person.count({
|
||||
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 source = intent.personSource
|
||||
? PERSON_IMPORT_SOURCE_LABELS[intent.personSource]
|
||||
@@ -305,15 +360,20 @@ Rules:
|
||||
if (intent.entity === 'deal') {
|
||||
const scope = dealAccessWhere(actor);
|
||||
const where = {
|
||||
organizationId: orgId,
|
||||
...scope,
|
||||
...created,
|
||||
...(intent.dealBucket === 'open' ? { status: 'open' as const } : {}),
|
||||
...(intent.dealBucket === 'won' ? { status: 'won' as const } : {}),
|
||||
...(intent.dealBucket === 'lost' ? { status: 'lost' as const } : {}),
|
||||
...(intent.dealBucket === 'stale'
|
||||
? { status: 'open' as const, OR: [{ isStale: true }, { isRotten: true }] }
|
||||
: {}),
|
||||
AND: [
|
||||
{
|
||||
organizationId: orgId,
|
||||
...scope,
|
||||
...created,
|
||||
...(intent.dealBucket === 'open' ? { status: 'open' as const } : {}),
|
||||
...(intent.dealBucket === 'won' ? { status: 'won' as const } : {}),
|
||||
...(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 name =
|
||||
@@ -342,7 +402,12 @@ Rules:
|
||||
this.prisma.product.count({ where: { organizationId: orgId, ...created } }),
|
||||
activity: () =>
|
||||
this.prisma.activity.count({
|
||||
where: { organizationId: orgId, ...assigneeWhere(actor), ...created },
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...assigneeWhere(actor),
|
||||
...created,
|
||||
...activityPriorWhere(actor.priorLens),
|
||||
},
|
||||
}),
|
||||
ticket: () =>
|
||||
this.prisma.ticket.count({
|
||||
@@ -432,8 +497,10 @@ Rules:
|
||||
}),
|
||||
this.prisma.lead.count({ where: base }),
|
||||
]);
|
||||
const when = periodNote ? ` ${periodNote}` : '';
|
||||
return `لیدهای باز${when}: ${this.fa(open)} تا. کل لیدها ${this.fa(total)} تا است؛ ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیلشده.`;
|
||||
if (periodNote) {
|
||||
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 = {
|
||||
|
||||
@@ -19,7 +19,7 @@ export type CountEntity =
|
||||
| 'routine'
|
||||
| '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 DealBucket = 'all' | 'open' | 'won' | 'lost' | 'stale';
|
||||
@@ -51,6 +51,7 @@ export function normalizeAskText(raw: string) {
|
||||
.replace(/ي/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.replace(//g, ' ')
|
||||
.replace(/[؟?!.,،؛:]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
@@ -64,7 +65,7 @@ function detectEntity(q: string): CountEntity | null {
|
||||
if (/لید|سرنخ/.test(q)) return 'lead';
|
||||
if (/پیش\s*فاکتور/.test(q)) return 'quote';
|
||||
if (/فاکتور/.test(q)) return 'invoice';
|
||||
if (/معامله/.test(q)) return 'deal';
|
||||
if (/معاملات|معامله/.test(q)) return 'deal';
|
||||
if (/چک/.test(q)) return 'cheque';
|
||||
if (/رزرو|نوبت/.test(q)) return 'reservation';
|
||||
if (/روتین/.test(q)) return 'routine';
|
||||
@@ -81,7 +82,9 @@ function detectEntity(q: string): CountEntity | null {
|
||||
function detectPeriod(q: string): CountPeriod | undefined {
|
||||
if (/دیروز/.test(q)) return 'yesterday';
|
||||
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 'last_month';
|
||||
if (/این\s*ماه|ماه\s*جاری/.test(q)) return 'month';
|
||||
return undefined;
|
||||
}
|
||||
@@ -152,6 +155,9 @@ const HINT_STOP = [
|
||||
'مرحله',
|
||||
'وضعیت',
|
||||
'هفته',
|
||||
'گذشته',
|
||||
'پیش',
|
||||
'قبل',
|
||||
'لطفا',
|
||||
'الان',
|
||||
'شده',
|
||||
@@ -219,6 +225,11 @@ export function classifyCountQuestion(raw: string): CountIntent | null {
|
||||
const entity = detectEntity(q);
|
||||
if (!entity) return null;
|
||||
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 {
|
||||
entity,
|
||||
personSource:
|
||||
@@ -226,8 +237,8 @@ export function classifyCountQuestion(raw: string): CountIntent | null {
|
||||
mood: entity === 'person' ? detectMood(q) : undefined,
|
||||
leadBucket: detectLeadBucket(q),
|
||||
dealBucket: detectDealBucket(q),
|
||||
period: detectPeriod(q),
|
||||
hint: leftoverHint(q, entity),
|
||||
period,
|
||||
hint,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
@@ -92,6 +93,18 @@ export class AuthController {
|
||||
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)
|
||||
@Get('me/avatar')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
@@ -412,6 +413,29 @@ export class AuthService {
|
||||
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> {
|
||||
const fullName = dto.fullName.trim();
|
||||
if (fullName.length < 2) {
|
||||
@@ -653,9 +677,23 @@ export class AuthService {
|
||||
),
|
||||
subscription: sub,
|
||||
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(
|
||||
kind: string,
|
||||
email: string | null | undefined,
|
||||
@@ -740,6 +778,7 @@ export class AuthService {
|
||||
enabledModules?: OrgModuleKey[];
|
||||
subscription?: SubscriptionSummaryDto | null;
|
||||
isOrgOwner?: boolean;
|
||||
priorArchive?: AuthUserDto['priorArchive'];
|
||||
}): AuthUserDto {
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -753,6 +792,7 @@ export class AuthService {
|
||||
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
||||
...(user.subscription ? { subscription: user.subscription } : {}),
|
||||
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
|
||||
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
role: true,
|
||||
dataVisibility: true,
|
||||
dataVisibilityScopes: true,
|
||||
priorArchiveEnabled: true,
|
||||
priorArchiveLens: true,
|
||||
organization: { select: { enabledModules: true } },
|
||||
},
|
||||
});
|
||||
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 {
|
||||
userId: user.id,
|
||||
organizationId: membership.organizationId,
|
||||
@@ -98,6 +106,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
dataVisibility,
|
||||
visibleUserIds,
|
||||
scopes,
|
||||
priorLens: priorGranted ? priorLens : 'off',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
ApplyBillingPlanRequest,
|
||||
BillingAccountDto,
|
||||
@@ -251,13 +252,58 @@ export class BillingService {
|
||||
modules.filter(isOrgModuleKey),
|
||||
),
|
||||
] 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({
|
||||
where: { id: organizationId },
|
||||
data: { enabledModules: cleaned },
|
||||
data: {
|
||||
enabledModules: cleaned,
|
||||
...(enablePrior && !prev?.priorArchiveEnabledAt
|
||||
? { priorArchiveEnabledAt: cutoff }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (enablePrior) await this.backfillPriorActivities(organizationId, cutoff);
|
||||
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[] {
|
||||
if (!raw?.length) return defaultEnabledModules();
|
||||
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;
|
||||
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
|
||||
scopes?: Partial<Record<DataScope, ScopeVisibility>>;
|
||||
/** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */
|
||||
priorLens?: 'off' | 'current' | 'archive';
|
||||
};
|
||||
|
||||
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { normalizePersonImportSource } from './person-source';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { companyPriorWhere, listPriorLens } from '../common/prior-archive';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
|
||||
function parseDateOrNull(raw?: string | null): Date | null {
|
||||
@@ -97,6 +98,7 @@ export class CompaniesService {
|
||||
const where: Prisma.CompanyWhereInput = {
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
...companyPriorWhere(listPriorLens(actor.priorLens, q, false)),
|
||||
...(search ? { AND: [search] } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import { selectMonthBirthdays } from './birthday-month';
|
||||
import { renderSmsText } from '../common/sms-text';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { listPriorLens, personPriorWhere } from '../common/prior-archive';
|
||||
import {
|
||||
normalizePersonImportSource,
|
||||
personImportSourceLabel,
|
||||
@@ -137,6 +138,7 @@ export class PeopleService implements OnModuleInit {
|
||||
...ownerWhere(actor),
|
||||
...(opts?.mood ? { mood: opts.mood } : {}),
|
||||
...(sourceFilter ? { importSource: sourceFilter } : {}),
|
||||
...personPriorWhere(listPriorLens(actor.priorLens, q, Boolean(sourceFilter))),
|
||||
...(search ? { AND: [search] } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } 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 { normalizePhone } from '../contacts/normalize';
|
||||
import {
|
||||
@@ -110,7 +111,11 @@ export class DealsExcelService {
|
||||
async export(actor: Actor): Promise<Buffer> {
|
||||
const [rows, defs] = await Promise.all([
|
||||
this.prisma.deal.findMany({
|
||||
where: { organizationId: actor.organizationId, ...dealAccessWhere(actor) },
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...dealAccessWhere(actor),
|
||||
...dealPriorWhere(actor.priorLens),
|
||||
},
|
||||
include: {
|
||||
person: {
|
||||
select: {
|
||||
|
||||
@@ -37,8 +37,9 @@ export class PipelinesController {
|
||||
board(
|
||||
@CurrentUser() user: Actor,
|
||||
@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()
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { asCustomFields } from '../common/custom-fields';
|
||||
import { type Actor, dealAccessWhere } from '../common/record-scope';
|
||||
import { dealPriorWhere } from '../common/prior-archive';
|
||||
import { decimalToBase, loadOrgRates } from '../common/currency';
|
||||
import { assertStageMap, dealStatusForStage } from './pipeline-merge.util';
|
||||
|
||||
@@ -294,8 +295,9 @@ export class PipelinesService {
|
||||
pipelineId?: string,
|
||||
actor?: Pick<
|
||||
Actor,
|
||||
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'
|
||||
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'priorLens'
|
||||
>,
|
||||
includePrior = false,
|
||||
): Promise<PipelineBoardDto> {
|
||||
const pipeline = pipelineId
|
||||
? await this.prisma.pipeline.findFirst({
|
||||
@@ -313,7 +315,7 @@ export class PipelinesService {
|
||||
|
||||
if (!resolved) {
|
||||
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({
|
||||
@@ -321,6 +323,7 @@ export class PipelinesService {
|
||||
organizationId,
|
||||
pipelineId: resolved.id,
|
||||
...(actor ? dealAccessWhere(actor) : {}),
|
||||
...(includePrior ? {} : dealPriorWhere(actor?.priorLens)),
|
||||
},
|
||||
include: {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
|
||||
@@ -26,6 +26,11 @@ import {
|
||||
ticketAccessWhere,
|
||||
} from '../common/record-scope';
|
||||
import { decimalToBase, loadOrgRates, toBaseAmount } from '../common/currency';
|
||||
import {
|
||||
activityPriorWhere,
|
||||
dealPriorWhere,
|
||||
personPriorWhere,
|
||||
} from '../common/prior-archive';
|
||||
import {
|
||||
addJalaliMonths,
|
||||
dateToJalali,
|
||||
@@ -57,6 +62,9 @@ export class ReportsService {
|
||||
const prevStart = new Date(prevEnd.getTime() - durationMs);
|
||||
|
||||
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 sixAgo = addJalaliMonths(jy, jm, -5);
|
||||
const sixMonthStart = startOfJalaliMonth(sixAgo.jy, sixAgo.jm);
|
||||
@@ -76,15 +84,20 @@ export class ReportsService {
|
||||
await Promise.all([
|
||||
this.prisma.deal.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...dealScope,
|
||||
OR: [
|
||||
{ status: 'open' },
|
||||
AND: [
|
||||
{
|
||||
status: { in: ['won', 'lost'] },
|
||||
updatedAt: { gte: dealEarliest },
|
||||
organizationId: orgId,
|
||||
...dealScope,
|
||||
OR: [
|
||||
{ status: 'open' },
|
||||
{
|
||||
status: { in: ['won', 'lost'] },
|
||||
updatedAt: { gte: dealEarliest },
|
||||
},
|
||||
{ createdAt: { gte: dealEarliest } },
|
||||
],
|
||||
},
|
||||
{ createdAt: { gte: dealEarliest } },
|
||||
dealsPrior,
|
||||
],
|
||||
},
|
||||
select: {
|
||||
@@ -118,6 +131,7 @@ export class ReportsService {
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...activityScope,
|
||||
...activitiesPrior,
|
||||
createdAt: { gte: prevStart, lte: end },
|
||||
},
|
||||
select: {
|
||||
@@ -146,6 +160,7 @@ export class ReportsService {
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...personScope,
|
||||
...peoplePrior,
|
||||
},
|
||||
select: { mood: true, createdAt: true },
|
||||
}),
|
||||
@@ -1536,6 +1551,9 @@ export class ReportsService {
|
||||
const yesterdayEnd = endOfDay(yesterday);
|
||||
const weekAgo = new Date(now);
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
const peoplePrior = personPriorWhere(actor.priorLens);
|
||||
const dealsPrior = dealPriorWhere(actor.priorLens, now);
|
||||
const activitiesPrior = activityPriorWhere(actor.priorLens, now);
|
||||
|
||||
const [
|
||||
wonDeals,
|
||||
@@ -1554,6 +1572,7 @@ export class ReportsService {
|
||||
organizationId: orgId,
|
||||
status: 'won',
|
||||
...dealScope,
|
||||
...dealsPrior,
|
||||
updatedAt: { gte: addMonthsStart(now, -5) },
|
||||
},
|
||||
select: {
|
||||
@@ -1568,17 +1587,22 @@ export class ReportsService {
|
||||
},
|
||||
}),
|
||||
this.prisma.deal.count({
|
||||
where: { organizationId: orgId, status: 'open', ...dealScope },
|
||||
where: { organizationId: orgId, status: 'open', ...dealScope, ...dealsPrior },
|
||||
}),
|
||||
this.prisma.deal.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
status: 'open',
|
||||
...dealScope,
|
||||
OR: [
|
||||
{ isStale: true },
|
||||
{ isRotten: true },
|
||||
{ updatedAt: { lt: daysAgo(5) } },
|
||||
AND: [
|
||||
{
|
||||
organizationId: orgId,
|
||||
status: 'open',
|
||||
...dealScope,
|
||||
OR: [
|
||||
{ isStale: true },
|
||||
{ isRotten: true },
|
||||
{ updatedAt: { lt: daysAgo(5) } },
|
||||
],
|
||||
},
|
||||
dealsPrior,
|
||||
],
|
||||
},
|
||||
select: {
|
||||
@@ -1594,6 +1618,7 @@ export class ReportsService {
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...personScope,
|
||||
...peoplePrior,
|
||||
createdAt: { gte: curMonthStart, lte: curMonthEnd },
|
||||
},
|
||||
}),
|
||||
@@ -1601,6 +1626,7 @@ export class ReportsService {
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...personScope,
|
||||
...peoplePrior,
|
||||
createdAt: { gte: prevMonthStart, lte: prevMonthEnd },
|
||||
},
|
||||
}),
|
||||
@@ -1608,6 +1634,7 @@ export class ReportsService {
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...activityScope,
|
||||
...activitiesPrior,
|
||||
status: 'open',
|
||||
dueAt: { gte: todayStart, lte: todayEnd },
|
||||
},
|
||||
@@ -1616,6 +1643,7 @@ export class ReportsService {
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...activityScope,
|
||||
...activitiesPrior,
|
||||
dueAt: { gte: yesterdayStart, lte: yesterdayEnd },
|
||||
},
|
||||
}),
|
||||
@@ -1624,6 +1652,7 @@ export class ReportsService {
|
||||
organizationId: orgId,
|
||||
status: 'open',
|
||||
...dealScope,
|
||||
...dealsPrior,
|
||||
updatedAt: { lte: weekAgo },
|
||||
},
|
||||
}),
|
||||
@@ -1639,6 +1668,7 @@ export class ReportsService {
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...activityScope,
|
||||
...activitiesPrior,
|
||||
status: 'open',
|
||||
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')
|
||||
@Roles('owner', 'admin')
|
||||
updateVisibility(
|
||||
|
||||
@@ -44,6 +44,7 @@ type MembershipRow = {
|
||||
commissionPercent: { toString(): string } | number | null;
|
||||
dataVisibility?: string | null;
|
||||
dataVisibilityScopes?: unknown;
|
||||
priorArchiveEnabled?: boolean;
|
||||
createdAt: Date;
|
||||
orgRole: { id: string; name: string; color: string } | null;
|
||||
user: {
|
||||
@@ -241,6 +242,32 @@ export class UsersService {
|
||||
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(
|
||||
organizationId: string,
|
||||
actorRole: UserRole,
|
||||
@@ -478,6 +505,7 @@ export class UsersService {
|
||||
row.role === 'owner' || row.role === 'admin'
|
||||
? {}
|
||||
: parseVisibilityScopes(row.dataVisibilityScopes),
|
||||
priorArchiveEnabled: Boolean(row.priorArchiveEnabled),
|
||||
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 axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type {
|
||||
OrgUserDto,
|
||||
TeamDto,
|
||||
@@ -23,6 +24,15 @@ const props = defineProps<{
|
||||
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<{
|
||||
refreshUsers: [];
|
||||
}>();
|
||||
@@ -45,6 +55,25 @@ const editableUsers = computed(() =>
|
||||
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() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -296,6 +325,23 @@ onMounted(() => {
|
||||
</div>
|
||||
</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">
|
||||
<h3>سطح دسترسی به پروندهها</h3>
|
||||
<p class="hint">
|
||||
|
||||
@@ -71,6 +71,9 @@ function onVoiceTranscript(text: string) {
|
||||
}
|
||||
|
||||
function looksLikeAction(text: string) {
|
||||
if (/[؟?]/.test(text) || /چند|تعداد|چقدر|چنتا|چیست|چیه|کدام|کدوم|چرا|چطور|چگونه/.test(text)) {
|
||||
return false;
|
||||
}
|
||||
return /بساز|بسازید|ثبت کن|ثبت کنید|اضافه کن|ایجاد کن|یک مشتری|مشتری جدید|یک فعالیت|فعالیت جدید|یک لید|لید جدید/.test(
|
||||
text,
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import GamificationToaster from '@/components/gamification/GamificationToaster.v
|
||||
import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs';
|
||||
import type {
|
||||
ActivitySummaryDto,
|
||||
AuthUserDto,
|
||||
NotificationDto,
|
||||
NotificationsListDto,
|
||||
} from '@mento/shared';
|
||||
@@ -40,6 +41,20 @@ const isMobileNav = ref(false);
|
||||
const globalSearch = ref('');
|
||||
const activityBadge = 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[]) {
|
||||
return prefixes.some(
|
||||
@@ -665,6 +680,24 @@ onUnmounted(() => {
|
||||
</p>
|
||||
|
||||
<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">
|
||||
<RouterLink
|
||||
class="profile-card profile-foot"
|
||||
@@ -1312,6 +1345,35 @@ onUnmounted(() => {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
@@ -1769,6 +1831,10 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .prior-switch {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .logout-link {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { formatCompactMoney, formatMoney } from '@/lib/money';
|
||||
import {
|
||||
calendarDaysBetween,
|
||||
@@ -60,6 +61,7 @@ type TimelineRow = {
|
||||
tone: TimelineTone;
|
||||
href: string;
|
||||
at: number;
|
||||
prior?: boolean;
|
||||
};
|
||||
|
||||
type AttachmentRow = {
|
||||
@@ -180,6 +182,27 @@ const previousSourceLabel = computed(() => {
|
||||
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 calls = activities.value
|
||||
.filter((a) => a.type === 'call')
|
||||
@@ -210,6 +233,12 @@ const nextActivity = computed(() => {
|
||||
});
|
||||
|
||||
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(
|
||||
() =>
|
||||
@@ -308,6 +337,7 @@ const timeline = computed<TimelineRow[]>(() => {
|
||||
tone: activityTone(a),
|
||||
href: `/activities/${a.id}`,
|
||||
at,
|
||||
prior: isPriorActivity(a),
|
||||
});
|
||||
}
|
||||
for (const n of notes.value) {
|
||||
@@ -340,7 +370,17 @@ const visibleTimeline = computed(() => {
|
||||
historyFilter.value === 'all'
|
||||
? timeline.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'] {
|
||||
@@ -442,7 +482,7 @@ async function load() {
|
||||
api.get<PaginatedResponse<MemoryItemDto>>('/memory', {
|
||||
params: { personId: id.value, limit: 50 },
|
||||
}),
|
||||
api.get<PipelineBoardDto>('/pipelines/board'),
|
||||
api.get<PipelineBoardDto>('/pipelines/board', { params: { prior: 'all' } }),
|
||||
]);
|
||||
person.value = personRes.data;
|
||||
activities.value = actRes.data.items;
|
||||
@@ -832,7 +872,24 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
</RouterLink>
|
||||
</li>
|
||||
</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>
|
||||
@@ -896,7 +953,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
/>
|
||||
<div v-if="!dealsEmpty">
|
||||
<RouterLink
|
||||
v-for="deal in deals"
|
||||
v-for="deal in currentDeals"
|
||||
:key="deal.id"
|
||||
class="row-card"
|
||||
: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>
|
||||
<em>{{ dealStatusLabel[deal.status] ?? deal.status }}</em>
|
||||
</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">
|
||||
<h3>فاکتورها</h3>
|
||||
<RouterLink
|
||||
|
||||
@@ -584,6 +584,19 @@ onMounted(() => {
|
||||
حرفهای (چندشاخه، شرط روی پاسخ فرم، تقسیم نوبتی)
|
||||
</label>
|
||||
</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 class="chat-settings">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# بایگانی اطلاعات قبل
|
||||
|
||||
تاریخ: ۲۰۲۶-۱۰-۱۴
|
||||
وضعیت: قفلشده توسط کاربر؛ پیادهسازی شروع نشده است. تا وقتی کاربر صریح بگوید «شروع کن»، کدی برای این قابلیت نوشته نمیشود.
|
||||
وضعیت: قفلشده توسط کاربر و پیادهسازی شروع شده است.
|
||||
|
||||
## هدف
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface AuthUserDto {
|
||||
enabledModules?: OrgModuleKey[];
|
||||
/** خلاصه اشتراک مالک محیط (برای اعضا فقط جهت نمایش/گیت ورود) */
|
||||
subscription?: SubscriptionSummaryDto | null;
|
||||
/** اگر باشد، این کاربر سوئیچ «بایگانی اطلاعات قبل» را دارد */
|
||||
priorArchive?: { lens: "current" | "archive" } | null;
|
||||
}
|
||||
|
||||
export type BillingPlanCode =
|
||||
@@ -141,7 +143,8 @@ export type OrgModuleKey =
|
||||
| "leads"
|
||||
| "conversations"
|
||||
| "routines"
|
||||
| "gamification";
|
||||
| "gamification"
|
||||
| "prior_data_archive";
|
||||
|
||||
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
||||
"customers",
|
||||
@@ -182,6 +185,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
||||
"conversations",
|
||||
"routines",
|
||||
"gamification",
|
||||
"prior_data_archive",
|
||||
];
|
||||
|
||||
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
||||
@@ -223,6 +227,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
||||
conversations: "گفتگو",
|
||||
routines: "کارهای روتین",
|
||||
gamification: "باشگاه امتیازات",
|
||||
prior_data_archive: "بایگانی اطلاعات قبل",
|
||||
};
|
||||
|
||||
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[] {
|
||||
return ORG_MODULE_KEYS.filter((k) => !OPT_IN_ORG_MODULES.includes(k));
|
||||
@@ -346,6 +354,8 @@ export interface OrgUserDto {
|
||||
dataVisibility?: DataVisibility;
|
||||
/** سطح دسترسی تفکیکی هر بخش (فقط بخشهایی که با پیشفرض فرق دارند) */
|
||||
dataVisibilityScopes?: DataVisibilityScopes;
|
||||
/** تیک مالک: این کاربر سوئیچ بایگانی اطلاعات قبل را ببیند */
|
||||
priorArchiveEnabled?: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -1465,6 +1475,8 @@ export interface ActivityDto {
|
||||
/** زمان ارسال یادآور (اگر ارسال شده باشد) */
|
||||
reminderSentAt: string | null;
|
||||
completedAt: string | null;
|
||||
/** manual یا didar؛ برای جدا کردن سابقهٔ قبلی */
|
||||
importSource?: string;
|
||||
tags: string[];
|
||||
person: PersonSummaryDto | null;
|
||||
company: CompanySummaryDto | null;
|
||||
|
||||
Reference in New Issue
Block a user