diff --git a/apps/api/src/gamification/gamification-admin.service.ts b/apps/api/src/gamification/gamification-admin.service.ts index e7eff1e..c962e16 100644 --- a/apps/api/src/gamification/gamification-admin.service.ts +++ b/apps/api/src/gamification/gamification-admin.service.ts @@ -30,6 +30,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { type Actor, elevated } from '../common/record-scope'; import { dayKeyToDate, tehranDayKey, addDays } from '../routines/routine-dates'; +import { internalRoutineTaskWhere } from '../routines/internal-routine'; import { normalizeLevels, periodStart, previousPeriod, sanitizeConditions } from './gamification-engine'; import { GamificationService, parsePeriod, toRuleDto } from './gamification.service'; import type { @@ -751,6 +752,7 @@ export class GamificationAdminService { organizationId, status: { not: 'cancelled' }, scheduledFor: { gte: dayKeyToDate(fromKey), lte: dayKeyToDate(toKey) }, + ...internalRoutineTaskWhere, }; const [total, done] = await Promise.all([ this.prisma.routineTask.count({ where }), diff --git a/apps/api/src/routines/internal-routine.ts b/apps/api/src/routines/internal-routine.ts new file mode 100644 index 0000000..dee14ef --- /dev/null +++ b/apps/api/src/routines/internal-routine.ts @@ -0,0 +1,26 @@ +import { Prisma, type RoutineTaskSource } from '@prisma/client'; + +/** + * کارهای روتین فقط کار داخلی تیم است. + * پیگیری، لید، مشتری و معامله روی پرونده خودشان می‌مانند و وارد میز روتین نمی‌شوند. + */ +export const internalRoutineTaskWhere: Prisma.RoutineTaskWhereInput = { + source: { in: ['routine', 'manual'] }, + leadId: null, + personId: null, + companyId: null, + dealId: null, + invoiceId: null, + activityId: null, +}; + +/** فیلتر میز روتین. منبع مشتری حتی اگر صریحاً خواسته شود وارد لیست نمی‌شود. */ +export function deskRoutineTaskWhere( + source?: RoutineTaskSource | null, +): Prisma.RoutineTaskWhereInput { + if (source === 'routine' || source === 'manual') { + return { ...internalRoutineTaskWhere, source }; + } + if (source) return { AND: [internalRoutineTaskWhere, { source }] }; + return internalRoutineTaskWhere; +} diff --git a/apps/api/src/routines/routine-reminders.service.ts b/apps/api/src/routines/routine-reminders.service.ts index 8d88156..d46851c 100644 --- a/apps/api/src/routines/routine-reminders.service.ts +++ b/apps/api/src/routines/routine-reminders.service.ts @@ -3,6 +3,7 @@ import { Cron } from '@nestjs/schedule'; import { PrismaService } from '../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { dayKeyToDate, tehranDayKey, tehranHour, toFaDigits } from './routine-dates'; +import { internalRoutineTaskWhere } from './internal-routine'; const TIME_FMT = new Intl.DateTimeFormat('fa-IR', { timeZone: 'Asia/Tehran', @@ -44,6 +45,7 @@ export class RoutineRemindersService { status: { in: ['todo', 'in_progress'] }, reminderAt: { lte: now }, reminderSentAt: null, + ...internalRoutineTaskWhere, }, select: { id: true, organizationId: true, assigneeId: true, title: true, dueAt: true, reminderAt: true }, take: 300, @@ -76,7 +78,7 @@ export class RoutineRemindersService { /** هشدار Task اجباری که مهلتش گذشته («… هنوز انجام نشده») */ private async sendOverdueAlerts() { const rows = await this.prisma.routineTask.findMany({ - where: { status: 'overdue', isMandatory: true, overdueNotifiedAt: null }, + where: { status: 'overdue', isMandatory: true, overdueNotifiedAt: null, ...internalRoutineTaskWhere }, select: { id: true, organizationId: true, assigneeId: true, title: true }, take: 300, }); @@ -114,7 +116,11 @@ export class RoutineRemindersService { const groups = await this.prisma.routineTask.groupBy({ by: ['organizationId', 'assigneeId'], - where: { scheduledFor: todayDate, status: { in: ['todo', 'in_progress'] } }, + where: { + scheduledFor: todayDate, + status: { in: ['todo', 'in_progress'] }, + ...internalRoutineTaskWhere, + }, _count: { _all: true }, }); if (!groups.length) return; diff --git a/apps/api/src/routines/routine-stats.service.ts b/apps/api/src/routines/routine-stats.service.ts index c072c3d..9ee68e4 100644 --- a/apps/api/src/routines/routine-stats.service.ts +++ b/apps/api/src/routines/routine-stats.service.ts @@ -18,6 +18,7 @@ import type { } from '@mento/shared'; import type { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { deskRoutineTaskWhere, internalRoutineTaskWhere } from './internal-routine'; import type { RoutineRangeQueryDto } from './dto'; import { describeRecurrence } from './recurrence'; import { routineRule } from './routine-mapper'; @@ -118,6 +119,7 @@ export class RoutineStatsService { organizationId, assigneeId: userId, scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(day) }, + ...internalRoutineTaskWhere, }, select: { scheduledFor: true, status: true }, }); @@ -155,6 +157,7 @@ export class RoutineStatsService { isMandatory: true, status: { not: 'cancelled' }, scheduledFor: { gte: dayKeyToDate(addDays(today, -180)), lte: dayKeyToDate(today) }, + ...internalRoutineTaskWhere, }, _count: { _all: true }, }); @@ -437,8 +440,8 @@ export class RoutineStatsService { organizationId, assigneeId: { in: userIds }, scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) }, + ...deskRoutineTaskWhere(q.source), ...(q.categoryId ? { categoryId: q.categoryId } : {}), - ...(q.source ? { source: q.source } : {}), ...(q.status ? { status: q.status } : {}), }, select: statSelect, diff --git a/apps/api/src/routines/routine-tasks.service.ts b/apps/api/src/routines/routine-tasks.service.ts index 57db1cc..edbaa4f 100644 --- a/apps/api/src/routines/routine-tasks.service.ts +++ b/apps/api/src/routines/routine-tasks.service.ts @@ -47,6 +47,7 @@ import { tehranDateTime, tehranDayKey, } from './routine-dates'; +import { deskRoutineTaskWhere, internalRoutineTaskWhere } from './internal-routine'; const OPEN_STATUSES: RoutineTaskStatus[] = ['todo', 'in_progress', 'overdue']; @@ -84,6 +85,7 @@ export class RoutineTasksService { assigneeId: target, scheduledFor: dayDate, status: { not: 'cancelled' }, + ...internalRoutineTaskWhere, ...deep, }, include: routineTaskInclude, @@ -95,6 +97,7 @@ export class RoutineTasksService { assigneeId: target, status: 'overdue', scheduledFor: { lt: dayDate }, + ...internalRoutineTaskWhere, ...deep, }, include: { ...routineTaskInclude, routine: { select: { ...routineTaskInclude.routine.select, carryOverOverdue: true } } }, @@ -141,10 +144,16 @@ export class RoutineTasksService { assigneeId: actor.userId, scheduledFor: day, status: { in: ['todo', 'in_progress'] }, + ...internalRoutineTaskWhere, }, }), this.prisma.routineTask.count({ - where: { organizationId: actor.organizationId, assigneeId: actor.userId, status: 'overdue' }, + where: { + organizationId: actor.organizationId, + assigneeId: actor.userId, + status: 'overdue', + ...internalRoutineTaskWhere, + }, }), ]); return { remaining, overdue }; @@ -158,6 +167,7 @@ export class RoutineTasksService { where: { organizationId: actor.organizationId, status: 'overdue', + ...internalRoutineTaskWhere, ...filter, ...this.deepWhere(actor), }, @@ -179,9 +189,9 @@ export class RoutineTasksService { scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) }, ...filter, ...this.deepWhere(actor), + ...deskRoutineTaskWhere(q.source), ...(q.categoryId ? { categoryId: q.categoryId } : {}), ...(q.status ? { status: q.status } : {}), - ...(q.source ? { source: q.source } : {}), }; const rows = await this.prisma.routineTask.findMany({ where, @@ -311,46 +321,10 @@ export class RoutineTasksService { return toTaskDto(row); } - async createFromActivity(actor: Actor, activityId: string): Promise { - const activity = await this.prisma.activity.findFirst({ - where: { id: activityId, organizationId: actor.organizationId }, - select: { - id: true, - title: true, - description: true, - dueAt: true, - priority: true, - personId: true, - companyId: true, - dealId: true, - assigneeId: true, - }, - }); - if (!activity) throw new NotFoundException('پیگیری پیدا نشد'); - - const existing = await this.prisma.routineTask.findFirst({ - where: { organizationId: actor.organizationId, activityId, status: { not: 'cancelled' } }, - include: routineTaskInclude, - }); - if (existing) return toTaskDto(existing); - - const scope = await this.scope(actor); - const assigneeId = - activity.assigneeId && canManage(scope, activity.assigneeId) - ? activity.assigneeId - : actor.userId; - return this.create(actor, { - title: activity.title, - description: activity.description, - dueAt: activity.dueAt?.toISOString() ?? null, - priority: activity.priority, - assigneeId, - personId: activity.personId, - companyId: activity.companyId, - dealId: activity.dealId, - activityId: activity.id, - source: 'follow_up', - }); + async createFromActivity(_actor: Actor, _activityId: string): Promise { + throw new BadRequestException( + 'پیگیری مشتری در کارهای روتین ثبت نمی‌شود. کارهای روتین فقط برای کار داخلی تیم است.', + ); } async update(actor: Actor, id: string, dto: UpdateRoutineTaskDto): Promise { diff --git a/apps/web/src/components/routines/QuickTaskModal.vue b/apps/web/src/components/routines/QuickTaskModal.vue index 9db37cd..6e9a8a8 100644 --- a/apps/web/src/components/routines/QuickTaskModal.vue +++ b/apps/web/src/components/routines/QuickTaskModal.vue @@ -23,7 +23,6 @@ import { Typography, } from 'ant-design-vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; -import EntityLinkPicker, { type PickedEntity } from '@/components/routines/EntityLinkPicker.vue'; import { api } from '@/lib/api'; import { ENTITY_TYPE_LABELS, @@ -64,7 +63,6 @@ const form = reactive({ categoryId: '', assigneeId: '', isMandatory: false, - entity: null as PickedEntity | null, }); const saving = ref(false); const error = ref(''); @@ -110,7 +108,6 @@ watch( categoryId: '', assigneeId: props.defaultAssigneeId || auth.user?.id || '', isMandatory: false, - entity: null, }); error.value = ''; void ensureContext(); @@ -138,14 +135,14 @@ async function submit() { error.value = 'عنوان کار را وارد کنید.'; return Promise.reject(); } - const link = props.preset ?? form.entity; + const link = props.preset; const body: CreateRoutineTaskRequest = { title: form.title.trim(), description: form.description.trim() || null, priority: form.priority, categoryId: form.categoryId || null, assigneeId: form.assigneeId || null, - isMandatory: form.isMandatory, + isMandatory: link ? false : form.isMandatory, source: sourceFor(link?.type), scheduledFor: form.date || todayKey(), dueAt: form.time ? localInputToIso(form.date || todayKey(), form.time) : null, @@ -182,7 +179,7 @@ async function submit() { @@ -229,16 +226,20 @@ async function submit() { /> - - - + + + {{ ENTITY_TYPE_LABELS[preset.type] }} {{ preset.label }} - + - + کار اجباری (در امتیاز روزانه و Streak حساب می‌شود) diff --git a/apps/web/src/components/routines/RelatedTasksPanel.vue b/apps/web/src/components/routines/RelatedTasksPanel.vue index 64153f3..7de94a2 100644 --- a/apps/web/src/components/routines/RelatedTasksPanel.vue +++ b/apps/web/src/components/routines/RelatedTasksPanel.vue @@ -1,6 +1,5 @@