diff --git a/apps/api/package.json b/apps/api/package.json index 63571e5..83645f9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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": "*", diff --git a/apps/api/prisma/migrations/20261015120000_prior_data_archive/migration.sql b/apps/api/prisma/migrations/20261015120000_prior_data_archive/migration.sql new file mode 100644 index 0000000..4918c7d --- /dev/null +++ b/apps/api/prisma/migrations/20261015120000_prior_data_archive/migration.sql @@ -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' + ); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 6122173..9980e1c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index ca4e118..a76f5a6 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -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; +type ActivityActor = Pick; /** 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 { 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 ? { diff --git a/apps/api/src/ai/ai-ask.service.ts b/apps/api/src/ai/ai-ask.service.ts index 15fdbb6..b310339 100644 --- a/apps/api/src/ai/ai-ask.service.ts +++ b/apps/api/src/ai/ai-ask.service.ts @@ -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 = { diff --git a/apps/api/src/ai/ask-count.ts b/apps/api/src/ai/ask-count.ts index e09a83c..2333315 100644 --- a/apps/api/src/ai/ask-count.ts +++ b/apps/api/src/ai/ask-count.ts @@ -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, }; } diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 700313d..9faa2c2 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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') diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 17ec979..62a4e5a 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 { + 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 { 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 } : {}), }; } } diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts index dbc442a..99f11e9 100644 --- a/apps/api/src/auth/jwt.strategy.ts +++ b/apps/api/src/auth/jwt.strategy.ts @@ -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', }; } diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts index d13c562..3c8d98d 100644 --- a/apps/api/src/billing/billing.service.ts +++ b/apps/api/src/billing/billing.service.ts @@ -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[]; diff --git a/apps/api/src/common/prior-archive.ts b/apps/api/src/common/prior-archive.ts new file mode 100644 index 0000000..bd917f5 --- /dev/null +++ b/apps/api/src/common/prior-archive.ts @@ -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 | 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'; +} diff --git a/apps/api/src/common/record-scope.ts b/apps/api/src/common/record-scope.ts index a1bef18..bc0dadd 100644 --- a/apps/api/src/common/record-scope.ts +++ b/apps/api/src/common/record-scope.ts @@ -20,6 +20,8 @@ export type Actor = { visibleUserIds?: string[] | null; /** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */ scopes?: Partial>; + /** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */ + priorLens?: 'off' | 'current' | 'archive'; }; export type ScopedActor = Pick; diff --git a/apps/api/src/contacts/companies.service.ts b/apps/api/src/contacts/companies.service.ts index efdeb3f..f8dfbf0 100644 --- a/apps/api/src/contacts/companies.service.ts +++ b/apps/api/src/contacts/companies.service.ts @@ -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] } : {}), }; diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index e7a0366..37d5397 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -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] } : {}), }; diff --git a/apps/api/src/pipelines/deals-excel.service.ts b/apps/api/src/pipelines/deals-excel.service.ts index 9d941b4..9ee4407 100644 --- a/apps/api/src/pipelines/deals-excel.service.ts +++ b/apps/api/src/pipelines/deals-excel.service.ts @@ -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 { 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: { diff --git a/apps/api/src/pipelines/pipelines.controller.ts b/apps/api/src/pipelines/pipelines.controller.ts index bf395cd..aca5697 100644 --- a/apps/api/src/pipelines/pipelines.controller.ts +++ b/apps/api/src/pipelines/pipelines.controller.ts @@ -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() diff --git a/apps/api/src/pipelines/pipelines.service.ts b/apps/api/src/pipelines/pipelines.service.ts index 521d609..271bb47 100644 --- a/apps/api/src/pipelines/pipelines.service.ts +++ b/apps/api/src/pipelines/pipelines.service.ts @@ -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 { 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 } }, diff --git a/apps/api/src/reports/reports.service.ts b/apps/api/src/reports/reports.service.ts index 82d4864..8c4095c 100644 --- a/apps/api/src/reports/reports.service.ts +++ b/apps/api/src/reports/reports.service.ts @@ -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 }, }, diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index b91e294..977dfd2 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -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( diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index b2cb0c7..f69771a 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -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 { + 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(), }; } diff --git a/apps/api/test/ask-count.test.ts b/apps/api/test/ask-count.test.ts new file mode 100644 index 0000000..3e45a98 --- /dev/null +++ b/apps/api/test/ask-count.test.ts @@ -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); + }); +}); diff --git a/apps/api/test/prior-archive.test.ts b/apps/api/test/prior-archive.test.ts new file mode 100644 index 0000000..688f4c3 --- /dev/null +++ b/apps/api/test/prior-archive.test.ts @@ -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'); + }); +}); diff --git a/apps/web/src/components/TeamsSettingsPanel.vue b/apps/web/src/components/TeamsSettingsPanel.vue index b916493..398e80b 100644 --- a/apps/web/src/components/TeamsSettingsPanel.vue +++ b/apps/web/src/components/TeamsSettingsPanel.vue @@ -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(() => { +
+

بایگانی اطلاعات قبل

+

+ فقط کاربرانی که تیک می‌خورند سوئیچ کار جاری و بایگانی را می‌بینند. بقیهٔ محیط مثل قبل همهٔ + رکوردها را مطابق سطح دسترسی خودشان می‌بینند. +

+ +
+

سطح دسترسی به پرونده‌ها

diff --git a/apps/web/src/components/dashboard/HomeAiAssistant.vue b/apps/web/src/components/dashboard/HomeAiAssistant.vue index 8b6ccab..02f8e0e 100644 --- a/apps/web/src/components/dashboard/HomeAiAssistant.vue +++ b/apps/web/src/components/dashboard/HomeAiAssistant.vue @@ -71,6 +71,9 @@ function onVoiceTranscript(text: string) { } function looksLikeAction(text: string) { + if (/[؟?]/.test(text) || /چند|تعداد|چقدر|چنتا|چیست|چیه|کدام|کدوم|چرا|چطور|چگونه/.test(text)) { + return false; + } return /بساز|بسازید|ثبت کن|ثبت کنید|اضافه کن|ایجاد کن|یک مشتری|مشتری جدید|یک فعالیت|فعالیت جدید|یک لید|لید جدید/.test( text, ); diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index b1efbad..3d0f9c2 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -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('/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(() => {