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:
2026-10-15 10:48:30 +03:30
co-authored by Cursor
parent 3a8a684aa4
commit 6bc5e1bec7
29 changed files with 775 additions and 77 deletions
+1 -1
View File
@@ -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'
);
+9
View File
@@ -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])
+52 -21
View File
@@ -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
? {
+91 -24
View File
@@ -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 = {
+15 -4
View File
@@ -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,
};
}
+13
View File
@@ -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')
+40
View File
@@ -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 } : {}),
};
}
}
+9
View File
@@ -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',
};
}
+47 -1
View File
@@ -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[];
+72
View File
@@ -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';
}
+2
View File
@@ -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] } : {}),
};
+2
View File
@@ -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()
+5 -2
View File
@@ -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 } },
+45 -15
View File
@@ -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 },
},
+15
View File
@@ -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(
+28
View File
@@ -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(),
};
}
+38
View File
@@ -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);
});
});
+45
View File
@@ -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,
);
+66
View File
@@ -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;
+74 -4
View File
@@ -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 @@
# بایگانی اطلاعات قبل
تاریخ: ۲۰۲۶-۱۰-۱۴
وضعیت: قفل‌شده توسط کاربر؛ پیاده‌سازی شروع نشده است. تا وقتی کاربر صریح بگوید «شروع کن»، کدی برای این قابلیت نوشته نمی‌شود.
وضعیت: قفل‌شده توسط کاربر و پیاده‌سازی شروع شده است.
## هدف
+14 -2
View File
@@ -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;