fix(routines): keep the daily desk for internal team work

Customer-linked tasks stay on their record and no longer appear in today, calendar, score, or reminders.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-29 20:53:26 +03:30
co-authored by Cursor
parent 99d686fefb
commit a17c7dbabc
11 changed files with 115 additions and 90 deletions
@@ -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 }),
+26
View File
@@ -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;
}
@@ -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;
@@ -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,
+16 -42
View File
@@ -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<RoutineTaskDto> {
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<RoutineTaskDto> {
throw new BadRequestException(
'پیگیری مشتری در کارهای روتین ثبت نمی‌شود. کارهای روتین فقط برای کار داخلی تیم است.',
);
}
async update(actor: Actor, id: string, dto: UpdateRoutineTaskDto): Promise<RoutineTaskDto> {
@@ -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() {
<a-form-item label="عنوان" required>
<a-input
v-model:value="form.title"
placeholder="مثلاً: تماس با مشتری برای پیگیری پیش‌فاکتور"
placeholder="مثلاً: آماده‌سازی گزارش هفتگی تیم"
:maxlength="200"
/>
</a-form-item>
@@ -229,16 +226,20 @@ async function submit() {
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="اتصال به پرونده">
<a-space v-if="preset" :size="8">
<a-col v-if="preset" :span="24">
<a-form-item label="پرونده">
<a-space :size="8">
<a-tag :bordered="false">{{ ENTITY_TYPE_LABELS[preset.type] }}</a-tag>
<a-typography-text strong>{{ preset.label }}</a-typography-text>
</a-space>
<EntityLinkPicker v-else v-model="form.entity" />
</a-form-item>
<a-alert
type="info"
show-icon
message="این کار روی همین پرونده می‌ماند و در برنامه روزانه کارهای روتین دیده نمی‌شود."
/>
</a-col>
<a-col :span="24">
<a-col v-else :span="24">
<a-checkbox v-model:checked="form.isMandatory">
کار اجباری (در امتیاز روزانه و Streak حساب می‌شود)
</a-checkbox>
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
Alert as AAlert,
Button as AButton,
@@ -19,7 +18,14 @@ import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError, isOpenStatus } from '@/lib/routines';
import {
ROUTINE_TASK_STATUS_LABELS,
apiError,
formatDayNumeric,
formatTime,
hasExplicitTime,
isOpenStatus,
} from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
import { useAuthStore } from '@/stores/auth';
@@ -38,7 +44,6 @@ const props = withDefaults(
},
);
const router = useRouter();
const auth = useAuthStore();
const enabled = computed(() => {
@@ -51,6 +56,7 @@ const loading = ref(false);
const error = ref('');
const showClosed = ref(false);
const quickOpen = ref(false);
const detail = ref<RoutineTaskDto | null>(null);
async function load() {
if (!enabled.value || !props.entity.id) return;
@@ -73,6 +79,7 @@ watch(() => `${props.entity.type}:${props.entity.id}`, load, { immediate: true }
const actions = useRoutineTaskActions((updated) => {
const i = items.value.findIndex((t) => t.id === updated.id);
if (i >= 0) items.value[i] = updated;
if (detail.value?.id === updated.id) detail.value = updated;
});
function onAction(kind: RoutineTaskRowAction, task: RoutineTaskDto) {
@@ -88,7 +95,7 @@ const open = computed(() => items.value.filter((t) => isOpenStatus(t.status)));
const closed = computed(() => items.value.filter((t) => !isOpenStatus(t.status)));
function openTask(t: RoutineTaskDto) {
void router.push({ path: '/routines', query: { task: t.id } });
detail.value = t;
}
function onCreated(t: RoutineTaskDto) {
@@ -182,5 +189,27 @@ function onCreated(t: RoutineTaskDto) {
<QuickTaskModal :open="quickOpen" :preset="entity" @close="quickOpen = false" @created="onCreated" />
<RoutineTaskModals :actions="actions" />
<a-drawer :open="detail != null" title="جزئیات کار" @close="detail = null">
<template v-if="detail">
<a-typography-paragraph v-if="detail.description">{{ detail.description }}</a-typography-paragraph>
<a-descriptions :column="1">
<a-descriptions-item label="وضعیت">
{{ ROUTINE_TASK_STATUS_LABELS[detail.status] }}
</a-descriptions-item>
<a-descriptions-item label="مسئول">
{{ detail.assignee?.fullName || '—' }}
</a-descriptions-item>
<a-descriptions-item label="روز برنامه">
{{ formatDayNumeric(detail.scheduledFor) }}
</a-descriptions-item>
<a-descriptions-item label="مهلت">
{{ detail.dueAt && hasExplicitTime(detail) ? formatTime(detail.dueAt) : 'تا پایان روز' }}
</a-descriptions-item>
</a-descriptions>
<a-typography-paragraph v-if="detail.resultNote" :style="{ marginTop: '12px' }">
{{ detail.resultNote }}
</a-typography-paragraph>
</template>
</a-drawer>
</a-card>
</template>
@@ -97,6 +97,11 @@ const actions = useRoutineTaskActions((updated) => {
});
const isOpen = computed(() => (task.value ? isOpenStatus(task.value.status) : false));
const customerTask = computed(() => {
const t = task.value;
if (!t) return false;
return (t.source !== 'routine' && t.source !== 'manual') || t.entities.length > 0;
});
const done = computed(() => task.value?.status === 'completed');
const canManage = computed(() => !!shell?.context.value?.isManager);
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
@@ -190,10 +195,13 @@ function eventText(ev: RoutineTaskEventDto): string {
</div>
<h3 class="td-title" :class="{ done }">{{ task.title }}</h3>
<p v-if="task.description" class="td-desc">{{ task.description }}</p>
<p v-if="customerTask" class="note">
این کار مربوط به پرونده است و در برنامه روزانه کارهای روتین نیست.
</p>
</section>
<!-- اکشن اصلی -->
<section v-if="!editing" class="td-actions">
<section v-if="!editing && !customerTask" class="td-actions">
<UiButton
v-if="isOpen"
variant="success"
@@ -370,7 +378,7 @@ function eventText(ev: RoutineTaskEventDto): string {
</section>
</div>
<template v-if="task && !editing" #footer>
<template v-if="task && !editing && !customerTask" #footer>
<UiButton variant="ghost" size="sm" @click="startEdit">
<template #icon><UiIcon name="pencil" :size="14" /></template>
ویرایش
-26
View File
@@ -296,29 +296,6 @@ async function remove() {
}
}
const routinesEnabled = computed(() => {
const mods = auth.user?.enabledModules;
return !mods || mods.includes('routines');
});
const converting = ref(false);
/** پیگیری → Task در «کارهای روتین» (تکراری نمی‌سازد) */
async function convertToTask() {
if (!id.value || converting.value) return;
converting.value = true;
error.value = '';
try {
const { data } = await api.post<{ id: string }>(`/routine-tasks/from-activity/${id.value}`);
moreOpen.value = false;
void router.push({ path: '/routines', query: { task: data.id } });
} catch (e) {
const msg = (e as { response?: { data?: { message?: string | string[] } } }).response?.data?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'تبدیل به Task ممکن نشد.');
} finally {
converting.value = false;
}
}
watch(id, () => {
commentOpen.value = false;
postponeOpen.value = false;
@@ -344,9 +321,6 @@ onMounted(() => {
</button>
<div v-if="moreOpen && activity" class="menu">
<RouterLink :to="{ name: 'activity-edit', params: { id } }">ویرایش</RouterLink>
<button v-if="routinesEnabled" type="button" :disabled="converting" @click="convertToTask">
تبدیل به Task
</button>
<button type="button" class="danger" @click="remove">حذف</button>
</div>
</div>
@@ -101,7 +101,7 @@ const hideHeader = computed(() => route.name === 'routine-new' || route.name ===
<template>
<section class="crm-page routines-shell">
<template v-if="!hideHeader">
<UiPageHeader title="کارهای روتین" lead="برنامه روزانه، روتین‌های تکرارشونده و پیگیری انجام کارها">
<UiPageHeader title="کارهای روتین" lead="برنامه روزانه و کارهای داخلی تیم">
<template #actions>
<RouterLink to="/routines/settings" class="icon-link" title="تنظیمات یادآوری و اعلان‌ها">
<UiIcon name="bell" :size="17" />
@@ -51,7 +51,9 @@ const f = reactive({
const users = computed(() => shell.context.value?.users ?? []);
const teams = computed(() => shell.context.value?.teams ?? []);
const categories = computed(() => shell.context.value?.categories ?? []);
const sources = Object.entries(ROUTINE_TASK_SOURCE_LABELS) as Array<[RoutineTaskSource, string]>;
const sources = (['routine', 'manual'] as const).map(
(key) => [key, ROUTINE_TASK_SOURCE_LABELS[key]] as [RoutineTaskSource, string],
);
const statuses = Object.entries(ROUTINE_TASK_STATUS_LABELS) as Array<[RoutineTaskStatus, string]>;
const query = computed<RoutineStatsQuery>(() => {