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