From b3784818fca18b08f8f962a9b429debc27de7502 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 6 Oct 2026 19:54:38 +0330 Subject: [PATCH] feat: routines module; super admin subscription badge, 7-day trial and lifetime plan Co-authored-by: Cursor --- .../20261006190000_routines/migration.sql | 247 +++++++ apps/api/prisma/schema.prisma | 208 +++++- apps/api/src/app.module.ts | 2 + apps/api/src/billing/billing.service.ts | 8 +- apps/api/src/platform/dto.ts | 4 +- apps/api/src/platform/platform.service.ts | 20 +- apps/api/src/routines/dto.ts | 311 ++++++++ apps/api/src/routines/recurrence.ts | 101 +++ apps/api/src/routines/routine-dates.ts | 100 +++ .../src/routines/routine-generator.service.ts | 167 +++++ apps/api/src/routines/routine-mapper.ts | 195 +++++ .../src/routines/routine-reminders.service.ts | 151 ++++ apps/api/src/routines/routine-scope.ts | 79 ++ .../api/src/routines/routine-stats.service.ts | 565 +++++++++++++++ .../src/routines/routine-tasks.controller.ts | 129 ++++ .../api/src/routines/routine-tasks.service.ts | 609 ++++++++++++++++ apps/api/src/routines/routines.controller.ts | 143 ++++ apps/api/src/routines/routines.module.ts | 23 + apps/api/src/routines/routines.service.ts | 498 +++++++++++++ .../routines/ActivityHistoryList.vue | 271 +++++++ .../components/routines/DailyScoreCard.vue | 244 +++++++ .../components/routines/EntityLinkPicker.vue | 224 ++++++ apps/web/src/components/routines/KpiCard.vue | 151 ++++ .../src/components/routines/MonthlyReport.vue | 269 +++++++ .../components/routines/QuickTaskModal.vue | 253 +++++++ .../components/routines/RecurrencePicker.vue | 301 ++++++++ .../components/routines/RelatedTasksPanel.vue | 200 ++++++ .../components/routines/RoutineMonthGrid.vue | 265 +++++++ .../components/routines/RoutineTaskDrawer.vue | 663 +++++++++++++++++ .../components/routines/RoutineTaskModals.vue | 137 ++++ .../components/routines/RoutineTaskRow.vue | 440 ++++++++++++ .../routines/TeamPerformanceTable.vue | 191 +++++ .../src/components/routines/WeeklyReport.vue | 301 ++++++++ apps/web/src/components/ui/UiDrawer.vue | 181 +++++ apps/web/src/components/ui/index.ts | 1 + apps/web/src/layouts/AppLayout.vue | 42 +- apps/web/src/lib/routineCharts.ts | 248 +++++++ apps/web/src/lib/routines.ts | 256 +++++++ apps/web/src/lib/useRoutineTaskActions.ts | 131 ++++ apps/web/src/router/index.ts | 56 ++ apps/web/src/views/ActivityDetailView.vue | 26 + apps/web/src/views/CompanyFormView.vue | 2 + apps/web/src/views/DealFormView.vue | 8 + apps/web/src/views/LeadDetailView.vue | 3 + apps/web/src/views/PersonProfileView.vue | 9 +- apps/web/src/views/adm/AdmBillingPanel.vue | 8 +- .../src/views/adm/AdmOrganizationsView.vue | 64 ++ .../src/views/routines/RoutineFormView.vue | 530 ++++++++++++++ .../views/routines/RoutinesCalendarView.vue | 553 ++++++++++++++ .../src/views/routines/RoutinesListView.vue | 255 +++++++ .../src/views/routines/RoutinesManageView.vue | 673 ++++++++++++++++++ .../routines/RoutinesMyPerformanceView.vue | 272 +++++++ .../views/routines/RoutinesOverdueView.vue | 319 +++++++++ .../views/routines/RoutinesSettingsView.vue | 219 ++++++ .../src/views/routines/RoutinesShellView.vue | 231 ++++++ .../src/views/routines/RoutinesTeamView.vue | 460 ++++++++++++ .../src/views/routines/RoutinesTodayView.vue | 529 ++++++++++++++ packages/shared/src/index.ts | 490 ++++++++++++- 58 files changed, 13018 insertions(+), 18 deletions(-) create mode 100644 apps/api/prisma/migrations/20261006190000_routines/migration.sql create mode 100644 apps/api/src/routines/dto.ts create mode 100644 apps/api/src/routines/recurrence.ts create mode 100644 apps/api/src/routines/routine-dates.ts create mode 100644 apps/api/src/routines/routine-generator.service.ts create mode 100644 apps/api/src/routines/routine-mapper.ts create mode 100644 apps/api/src/routines/routine-reminders.service.ts create mode 100644 apps/api/src/routines/routine-scope.ts create mode 100644 apps/api/src/routines/routine-stats.service.ts create mode 100644 apps/api/src/routines/routine-tasks.controller.ts create mode 100644 apps/api/src/routines/routine-tasks.service.ts create mode 100644 apps/api/src/routines/routines.controller.ts create mode 100644 apps/api/src/routines/routines.module.ts create mode 100644 apps/api/src/routines/routines.service.ts create mode 100644 apps/web/src/components/routines/ActivityHistoryList.vue create mode 100644 apps/web/src/components/routines/DailyScoreCard.vue create mode 100644 apps/web/src/components/routines/EntityLinkPicker.vue create mode 100644 apps/web/src/components/routines/KpiCard.vue create mode 100644 apps/web/src/components/routines/MonthlyReport.vue create mode 100644 apps/web/src/components/routines/QuickTaskModal.vue create mode 100644 apps/web/src/components/routines/RecurrencePicker.vue create mode 100644 apps/web/src/components/routines/RelatedTasksPanel.vue create mode 100644 apps/web/src/components/routines/RoutineMonthGrid.vue create mode 100644 apps/web/src/components/routines/RoutineTaskDrawer.vue create mode 100644 apps/web/src/components/routines/RoutineTaskModals.vue create mode 100644 apps/web/src/components/routines/RoutineTaskRow.vue create mode 100644 apps/web/src/components/routines/TeamPerformanceTable.vue create mode 100644 apps/web/src/components/routines/WeeklyReport.vue create mode 100644 apps/web/src/components/ui/UiDrawer.vue create mode 100644 apps/web/src/lib/routineCharts.ts create mode 100644 apps/web/src/lib/routines.ts create mode 100644 apps/web/src/lib/useRoutineTaskActions.ts create mode 100644 apps/web/src/views/routines/RoutineFormView.vue create mode 100644 apps/web/src/views/routines/RoutinesCalendarView.vue create mode 100644 apps/web/src/views/routines/RoutinesListView.vue create mode 100644 apps/web/src/views/routines/RoutinesManageView.vue create mode 100644 apps/web/src/views/routines/RoutinesMyPerformanceView.vue create mode 100644 apps/web/src/views/routines/RoutinesOverdueView.vue create mode 100644 apps/web/src/views/routines/RoutinesSettingsView.vue create mode 100644 apps/web/src/views/routines/RoutinesShellView.vue create mode 100644 apps/web/src/views/routines/RoutinesTeamView.vue create mode 100644 apps/web/src/views/routines/RoutinesTodayView.vue diff --git a/apps/api/prisma/migrations/20261006190000_routines/migration.sql b/apps/api/prisma/migrations/20261006190000_routines/migration.sql new file mode 100644 index 0000000..2d36160 --- /dev/null +++ b/apps/api/prisma/migrations/20261006190000_routines/migration.sql @@ -0,0 +1,247 @@ +-- CreateEnum +CREATE TYPE "RoutineTaskStatus" AS ENUM ('todo', 'in_progress', 'completed', 'overdue', 'skipped', 'cancelled'); + +-- CreateEnum +CREATE TYPE "RoutineRecurrence" AS ENUM ('daily', 'weekdays', 'weekly', 'biweekly', 'monthly', 'every_n_days', 'specific_dates'); + +-- CreateEnum +CREATE TYPE "RoutineTaskSource" AS ENUM ('routine', 'manual', 'follow_up', 'lead', 'customer', 'deal'); + +-- CreateTable +CREATE TABLE "RoutineCategory" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "color" TEXT NOT NULL DEFAULT '#14b8a6', + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RoutineCategory_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Routine" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "assigneeId" TEXT, + "teamId" TEXT, + "categoryId" TEXT, + "priority" "ActivityPriority" NOT NULL DEFAULT 'medium', + "timeOfDay" TEXT NOT NULL DEFAULT '09:00', + "durationMinutes" INTEGER, + "startDate" DATE NOT NULL, + "endDate" DATE, + "recurrence" "RoutineRecurrence" NOT NULL DEFAULT 'daily', + "weekdays" INTEGER[] DEFAULT ARRAY[]::INTEGER[], + "intervalDays" INTEGER, + "monthDay" INTEGER, + "specificDates" DATE[], + "isMandatory" BOOLEAN NOT NULL DEFAULT true, + "allowSkip" BOOLEAN NOT NULL DEFAULT false, + "skipReasonRequired" BOOLEAN NOT NULL DEFAULT true, + "reportRequired" BOOLEAN NOT NULL DEFAULT false, + "allowAfterDeadline" BOOLEAN NOT NULL DEFAULT true, + "carryOverOverdue" BOOLEAN NOT NULL DEFAULT false, + "entityLink" TEXT NOT NULL DEFAULT 'none', + "reminderMinutesBefore" INTEGER, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdById" TEXT, + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Routine_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RoutineTask" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "routineId" TEXT, + "assigneeId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "categoryId" TEXT, + "priority" "ActivityPriority" NOT NULL DEFAULT 'medium', + "status" "RoutineTaskStatus" NOT NULL DEFAULT 'todo', + "scheduledFor" DATE NOT NULL, + "dueAt" TIMESTAMP(3), + "startedAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "resultNote" TEXT, + "skipReason" TEXT, + "source" "RoutineTaskSource" NOT NULL DEFAULT 'manual', + "isMandatory" BOOLEAN NOT NULL DEFAULT false, + "leadId" TEXT, + "personId" TEXT, + "companyId" TEXT, + "dealId" TEXT, + "invoiceId" TEXT, + "activityId" TEXT, + "reminderAt" TIMESTAMP(3), + "reminderSentAt" TIMESTAMP(3), + "overdueNotifiedAt" TIMESTAMP(3), + "createdById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RoutineTask_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RoutineTaskEvent" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "actorId" TEXT, + "type" TEXT NOT NULL, + "fromStatus" "RoutineTaskStatus", + "toStatus" "RoutineTaskStatus", + "note" TEXT, + "meta" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RoutineTaskEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RoutineUserSettings" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "remindBeforeMinutes" INTEGER DEFAULT 30, + "endOfDaySummary" BOOLEAN NOT NULL DEFAULT true, + "overdueAlert" BOOLEAN NOT NULL DEFAULT true, + "summaryHour" INTEGER NOT NULL DEFAULT 16, + "lastSummaryOn" DATE, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RoutineUserSettings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "RoutineCategory_organizationId_sortOrder_idx" ON "RoutineCategory"("organizationId", "sortOrder"); + +-- CreateIndex +CREATE UNIQUE INDEX "RoutineCategory_organizationId_name_key" ON "RoutineCategory"("organizationId", "name"); + +-- CreateIndex +CREATE INDEX "Routine_organizationId_isActive_deletedAt_idx" ON "Routine"("organizationId", "isActive", "deletedAt"); + +-- CreateIndex +CREATE INDEX "Routine_assigneeId_idx" ON "Routine"("assigneeId"); + +-- CreateIndex +CREATE INDEX "Routine_teamId_idx" ON "Routine"("teamId"); + +-- CreateIndex +CREATE INDEX "RoutineTask_organizationId_assigneeId_scheduledFor_idx" ON "RoutineTask"("organizationId", "assigneeId", "scheduledFor"); + +-- CreateIndex +CREATE INDEX "RoutineTask_organizationId_status_dueAt_idx" ON "RoutineTask"("organizationId", "status", "dueAt"); + +-- CreateIndex +CREATE INDEX "RoutineTask_reminderAt_reminderSentAt_idx" ON "RoutineTask"("reminderAt", "reminderSentAt"); + +-- CreateIndex +CREATE INDEX "RoutineTask_leadId_idx" ON "RoutineTask"("leadId"); + +-- CreateIndex +CREATE INDEX "RoutineTask_personId_idx" ON "RoutineTask"("personId"); + +-- CreateIndex +CREATE INDEX "RoutineTask_companyId_idx" ON "RoutineTask"("companyId"); + +-- CreateIndex +CREATE INDEX "RoutineTask_dealId_idx" ON "RoutineTask"("dealId"); + +-- CreateIndex +CREATE INDEX "RoutineTask_activityId_idx" ON "RoutineTask"("activityId"); + +-- CreateIndex +CREATE UNIQUE INDEX "RoutineTask_routineId_assigneeId_scheduledFor_key" ON "RoutineTask"("routineId", "assigneeId", "scheduledFor"); + +-- CreateIndex +CREATE INDEX "RoutineTaskEvent_taskId_createdAt_idx" ON "RoutineTaskEvent"("taskId", "createdAt"); + +-- CreateIndex +CREATE INDEX "RoutineTaskEvent_organizationId_createdAt_idx" ON "RoutineTaskEvent"("organizationId", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "RoutineUserSettings_organizationId_userId_key" ON "RoutineUserSettings"("organizationId", "userId"); + +-- AddForeignKey +ALTER TABLE "RoutineCategory" ADD CONSTRAINT "RoutineCategory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Routine" ADD CONSTRAINT "Routine_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Routine" ADD CONSTRAINT "Routine_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Routine" ADD CONSTRAINT "Routine_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Routine" ADD CONSTRAINT "Routine_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "RoutineCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Routine" ADD CONSTRAINT "Routine_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_routineId_fkey" FOREIGN KEY ("routineId") REFERENCES "Routine"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "RoutineCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_dealId_fkey" FOREIGN KEY ("dealId") REFERENCES "Deal"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_activityId_fkey" FOREIGN KEY ("activityId") REFERENCES "Activity"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoutineTask" ADD CONSTRAINT "RoutineTask_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoutineTaskEvent" ADD CONSTRAINT "RoutineTaskEvent_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoutineTaskEvent" ADD CONSTRAINT "RoutineTaskEvent_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "RoutineTask"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoutineTaskEvent" ADD CONSTRAINT "RoutineTaskEvent_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoutineUserSettings" ADD CONSTRAINT "RoutineUserSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoutineUserSettings" ADD CONSTRAINT "RoutineUserSettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +-- Enable the new routines module for organizations with an explicit module list +UPDATE "Organization" +SET "enabledModules" = array_append("enabledModules", 'routines') +WHERE cardinality("enabledModules") > 0 AND NOT ('routines' = ANY("enabledModules")); \ No newline at end of file diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 37cf83c..5d83fc0 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -218,6 +218,11 @@ model Organization { conversationMessageReactions ConversationMessageReaction[] trackingLinks TrackingLink[] trackingEvents TrackingEvent[] + routineCategories RoutineCategory[] + routines Routine[] + routineTasks RoutineTask[] + routineTaskEvents RoutineTaskEvent[] + routineUserSettings RoutineUserSettings[] } model User { @@ -271,6 +276,12 @@ model User { reservationAssignments ReservationAppointment[] @relation("ReservationAssignee") assignedLeads Lead[] @relation("LeadAssignee") createdLeads Lead[] @relation("LeadCreator") + assignedRoutines Routine[] @relation("RoutineAssignee") + createdRoutines Routine[] @relation("RoutineCreator") + routineTasks RoutineTask[] @relation("RoutineTaskAssignee") + createdRoutineTasks RoutineTask[] @relation("RoutineTaskCreator") + routineTaskEvents RoutineTaskEvent[] + routineSettings RoutineUserSettings[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -338,6 +349,7 @@ model Team { name String description String @default("") members TeamMember[] + routines Routine[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -379,7 +391,7 @@ model BillingAccount { id String @id @default(cuid()) ownerUserId String @unique owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade) - /// trial | monthly | quarterly | semiannual | yearly | custom + /// trial | monthly | quarterly | semiannual | yearly | lifetime | custom planCode String @default("trial") /// active | expired | suspended status String @default("active") @@ -435,6 +447,7 @@ model Company { owner User? @relation("CompanyOwner", fields: [ownerId], references: [id], onDelete: SetNull) tickets Ticket[] projectCards ProjectCard[] + routineTasks RoutineTask[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -503,6 +516,7 @@ model Person { cheques Cheque[] trackingLinks TrackingLink[] trackingEvents TrackingEvent[] + routineTasks RoutineTask[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -601,6 +615,7 @@ model Deal { invoices Invoice[] tickets Ticket[] projectCards ProjectCard[] + routineTasks RoutineTask[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -646,6 +661,7 @@ model Activity { createdById String? createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull) notes Note[] + routineTasks RoutineTask[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1023,6 +1039,7 @@ model Invoice { commissionFixedAmount Decimal? @db.Decimal(18, 2) commissionEntry CommissionEntry? cheques Cheque[] + routineTasks RoutineTask[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -2176,6 +2193,7 @@ model Lead { events LeadEvent[] trackingLinks TrackingLink[] trackingEvents TrackingEvent[] + routineTasks RoutineTask[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -2361,3 +2379,191 @@ model ConversationMessageReaction { @@unique([organizationId, externalRoomId, messageId, userId, emoji]) @@index([organizationId, externalRoomId]) } + +enum RoutineTaskStatus { + todo + in_progress + completed + overdue + skipped + cancelled +} + +enum RoutineRecurrence { + daily + weekdays + weekly + biweekly + monthly + every_n_days + specific_dates +} + +enum RoutineTaskSource { + routine + manual + follow_up + lead + customer + deal +} + +/// دسته‌بندی کارهای روتین (فروش، پیگیری، لید، …) +model RoutineCategory { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String + color String @default("#14b8a6") + sortOrder Int @default(0) + routines Routine[] + tasks RoutineTask[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, name]) + @@index([organizationId, sortOrder]) +} + +/// الگوی کار تکرارشونده؛ هر روز Task مستقل (RoutineTask) تولید می‌کند +model Routine { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + title String + description String? + assigneeId String? + assignee User? @relation("RoutineAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) + /// اگر ست باشد برای هر عضو تیم یک Task جدا ساخته می‌شود + teamId String? + team Team? @relation(fields: [teamId], references: [id], onDelete: SetNull) + categoryId String? + category RoutineCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + priority ActivityPriority @default(medium) + /// HH:mm به وقت تهران + timeOfDay String @default("09:00") + durationMinutes Int? + startDate DateTime @db.Date + endDate DateTime? @db.Date + recurrence RoutineRecurrence @default(daily) + /// ۰ = شنبه … ۶ = جمعه + weekdays Int[] @default([]) + intervalDays Int? + monthDay Int? + specificDates DateTime[] @db.Date + isMandatory Boolean @default(true) + allowSkip Boolean @default(false) + skipReasonRequired Boolean @default(true) + reportRequired Boolean @default(false) + allowAfterDeadline Boolean @default(true) + carryOverOverdue Boolean @default(false) + /// none | optional | lead | customer + entityLink String @default("none") + reminderMinutesBefore Int? + isActive Boolean @default(true) + createdById String? + createdBy User? @relation("RoutineCreator", fields: [createdById], references: [id], onDelete: SetNull) + deletedAt DateTime? + tasks RoutineTask[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId, isActive, deletedAt]) + @@index([assigneeId]) + @@index([teamId]) +} + +/// نمونه روزانه یک کار (از روتین، دستی، پیگیری، لید یا مشتری) +model RoutineTask { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + routineId String? + routine Routine? @relation(fields: [routineId], references: [id], onDelete: SetNull) + assigneeId String + assignee User @relation("RoutineTaskAssignee", fields: [assigneeId], references: [id], onDelete: Cascade) + title String + description String? + categoryId String? + category RoutineCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + priority ActivityPriority @default(medium) + status RoutineTaskStatus @default(todo) + /// روز برنامه به وقت تهران + scheduledFor DateTime @db.Date + dueAt DateTime? + startedAt DateTime? + completedAt DateTime? + resultNote String? + skipReason String? + source RoutineTaskSource @default(manual) + isMandatory Boolean @default(false) + leadId String? + lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull) + personId String? + person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) + companyId String? + company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) + dealId String? + deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) + invoiceId String? + invoice Invoice? @relation(fields: [invoiceId], references: [id], onDelete: SetNull) + activityId String? + activity Activity? @relation(fields: [activityId], references: [id], onDelete: SetNull) + reminderAt DateTime? + reminderSentAt DateTime? + overdueNotifiedAt DateTime? + createdById String? + createdBy User? @relation("RoutineTaskCreator", fields: [createdById], references: [id], onDelete: SetNull) + events RoutineTaskEvent[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([routineId, assigneeId, scheduledFor]) + @@index([organizationId, assigneeId, scheduledFor]) + @@index([organizationId, status, dueAt]) + @@index([reminderAt, reminderSentAt]) + @@index([leadId]) + @@index([personId]) + @@index([companyId]) + @@index([dealId]) + @@index([activityId]) +} + +/// تاریخچه تغییرات هر Task +model RoutineTaskEvent { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + taskId String + task RoutineTask @relation(fields: [taskId], references: [id], onDelete: Cascade) + actorId String? + actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull) + /// created | status_changed | rescheduled | note | reassigned | updated + type String + fromStatus RoutineTaskStatus? + toStatus RoutineTaskStatus? + note String? + meta Json @default("{}") + createdAt DateTime @default(now()) + + @@index([taskId, createdAt]) + @@index([organizationId, createdAt]) +} + +/// تنظیمات اعلان کارهای روتین هر کاربر +model RoutineUserSettings { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + remindBeforeMinutes Int? @default(30) + endOfDaySummary Boolean @default(true) + overdueAlert Boolean @default(true) + summaryHour Int @default(16) + lastSummaryOn DateTime? @db.Date + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, userId]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a29010d..81e395b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -46,6 +46,7 @@ import { VoipModule } from './integrations/voip/voip.module'; import { ReservationsModule } from './reservations/reservations.module'; import { LeadsModule } from './leads/leads.module'; import { ConversationsModule } from './conversations/conversations.module'; +import { RoutinesModule } from './routines/routines.module'; @Module({ imports: [ @@ -99,6 +100,7 @@ import { ConversationsModule } from './conversations/conversations.module'; VoipModule, ReservationsModule, ConversationsModule, + RoutinesModule, ], }) export class AppModule {} diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts index bf61483..d13c562 100644 --- a/apps/api/src/billing/billing.service.ts +++ b/apps/api/src/billing/billing.service.ts @@ -15,7 +15,9 @@ import type { import { BILLING_PLAN_DAYS, BILLING_PLAN_DEFAULT_MAX_ORGS, + BILLING_TRIAL_DAYS, defaultEnabledModules, + isPermanentBillingPlan, isOrgModuleKey, } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; @@ -25,7 +27,7 @@ function daysRemaining(expiresAt: Date, now = new Date()): number { return Math.max(0, Math.ceil(ms / (24 * 60 * 60 * 1000))); } -function toSummary(row: { +export function toSummary(row: { planCode: string; status: string; startsAt: Date; @@ -41,7 +43,7 @@ function toSummary(row: { expiresAt: row.expiresAt.toISOString(), maxOrganizations: row.maxOrganizations, daysRemaining: remaining, - warn: remaining > 0 && remaining <= 3, + warn: !isPermanentBillingPlan(row.planCode) && remaining > 0 && remaining <= 3, }; } @@ -81,7 +83,7 @@ export class BillingService { const existing = await this.getByOwnerUserId(ownerUserId); if (existing) return existing; const now = new Date(); - const expiresAt = new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000); + const expiresAt = new Date(now.getTime() + BILLING_TRIAL_DAYS * 24 * 60 * 60 * 1000); return this.prisma.billingAccount.create({ data: { ownerUserId, diff --git a/apps/api/src/platform/dto.ts b/apps/api/src/platform/dto.ts index 8de5445..8a4fc35 100644 --- a/apps/api/src/platform/dto.ts +++ b/apps/api/src/platform/dto.ts @@ -124,8 +124,8 @@ export class ResetPlatformUserPasswordDto { } export class ApplyBillingPlanDto { - @IsIn(['trial', 'monthly', 'quarterly', 'semiannual', 'yearly', 'custom']) - planCode!: 'trial' | 'monthly' | 'quarterly' | 'semiannual' | 'yearly' | 'custom'; + @IsIn(['trial', 'monthly', 'quarterly', 'semiannual', 'yearly', 'lifetime', 'custom']) + planCode!: 'trial' | 'monthly' | 'quarterly' | 'semiannual' | 'yearly' | 'lifetime' | 'custom'; @IsOptional() @IsInt() diff --git a/apps/api/src/platform/platform.service.ts b/apps/api/src/platform/platform.service.ts index 05870b1..f1f5e78 100644 --- a/apps/api/src/platform/platform.service.ts +++ b/apps/api/src/platform/platform.service.ts @@ -18,7 +18,7 @@ import { normalizeMobile } from '../auth/auth.service'; import { MembershipsService } from '../memberships/memberships.service'; import { OrgRolesService } from '../org-roles/org-roles.service'; import { PipelinesService } from '../pipelines/pipelines.service'; -import { BillingService } from '../billing/billing.service'; +import { BillingService, toSummary } from '../billing/billing.service'; import { PrismaService } from '../prisma/prisma.service'; import { buildSearchWhere, ci } from '../common/search'; import type { @@ -103,9 +103,21 @@ export class PlatformService { deals: true, }, }, + memberships: { + where: { role: 'owner', isActive: true }, + orderBy: { createdAt: 'asc' }, + take: 1, + select: { user: { select: { billingAccount: true } } }, + }, }, }); - return rows.map((row) => this.orgToDto(row)); + return rows.map((row) => { + const account = row.memberships[0]?.user.billingAccount; + return { + ...this.orgToDto(row), + subscription: account ? toSummary(account) : null, + }; + }); } async getOrganization(id: string): Promise { @@ -144,8 +156,12 @@ export class PlatformService { }, }); if (!row) throw new NotFoundException('محیط پیدا نشد'); + const ownerAccount = row.memberships.find( + (m) => m.role === 'owner' && m.isActive, + )?.user.billingAccount; return { ...this.orgToDto(row), + subscription: ownerAccount ? toSummary(ownerAccount) : null, members: row.memberships.map((m) => { const isBillingOwner = m.role === 'owner'; return { diff --git a/apps/api/src/routines/dto.ts b/apps/api/src/routines/dto.ts new file mode 100644 index 0000000..0f65ac9 --- /dev/null +++ b/apps/api/src/routines/dto.ts @@ -0,0 +1,311 @@ +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsDateString, + IsIn, + IsInt, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, + MinLength, + ValidateIf, +} from 'class-validator'; + +const PRIORITIES = ['very_low', 'low', 'medium', 'high', 'very_high'] as const; +const RECURRENCES = [ + 'daily', + 'weekdays', + 'weekly', + 'biweekly', + 'monthly', + 'every_n_days', + 'specific_dates', +] as const; +const SOURCES = ['routine', 'manual', 'follow_up', 'lead', 'customer', 'deal'] as const; +const STATUSES = ['todo', 'in_progress', 'completed', 'overdue', 'skipped', 'cancelled'] as const; +const ENTITY_LINKS = ['none', 'optional', 'lead', 'customer'] as const; +const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/; +const TIME = /^([01]\d|2[0-3]):[0-5]\d$/; + +const nullable = () => ValidateIf((_, v) => v !== '' && v != null); + +export class SaveRoutineCategoryDto { + @IsString() + @MinLength(1) + @MaxLength(60) + name!: string; + + @IsOptional() + @IsString() + @MaxLength(20) + color?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + sortOrder?: number; +} + +export class SaveRoutineDto { + @IsString() + @MinLength(2) + @MaxLength(200) + title!: string; + + @IsOptional() + @nullable() + @IsString() + @MaxLength(2000) + description?: string | null; + + @IsOptional() + @nullable() + @IsString() + assigneeId?: string | null; + + @IsOptional() + @nullable() + @IsString() + teamId?: string | null; + + @IsOptional() + @nullable() + @IsString() + categoryId?: string | null; + + @IsOptional() + @IsIn(PRIORITIES) + priority?: (typeof PRIORITIES)[number]; + + @Matches(TIME) + timeOfDay!: string; + + @IsOptional() + @nullable() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(24 * 60) + durationMinutes?: number | null; + + @Matches(DAY_KEY) + startDate!: string; + + @IsOptional() + @nullable() + @Matches(DAY_KEY) + endDate?: string | null; + + @IsIn(RECURRENCES) + recurrence!: (typeof RECURRENCES)[number]; + + @IsOptional() + @IsArray() + @Type(() => Number) + @IsInt({ each: true }) + @Min(0, { each: true }) + @Max(6, { each: true }) + weekdays?: number[]; + + @IsOptional() + @nullable() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(365) + intervalDays?: number | null; + + @IsOptional() + @nullable() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(31) + monthDay?: number | null; + + @IsOptional() + @IsArray() + @ArrayMaxSize(366) + @Matches(DAY_KEY, { each: true }) + specificDates?: string[]; + + @IsOptional() @IsBoolean() isMandatory?: boolean; + @IsOptional() @IsBoolean() allowSkip?: boolean; + @IsOptional() @IsBoolean() skipReasonRequired?: boolean; + @IsOptional() @IsBoolean() reportRequired?: boolean; + @IsOptional() @IsBoolean() allowAfterDeadline?: boolean; + @IsOptional() @IsBoolean() carryOverOverdue?: boolean; + + @IsOptional() + @IsIn(ENTITY_LINKS) + entityLink?: (typeof ENTITY_LINKS)[number]; + + @IsOptional() + @nullable() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(24 * 60) + reminderMinutesBefore?: number | null; + + @IsOptional() @IsBoolean() isActive?: boolean; +} + +export class ToggleRoutineDto { + @IsBoolean() + isActive!: boolean; +} + +export class ChangeRoutineAssigneeDto { + @IsOptional() + @nullable() + @IsString() + assigneeId?: string | null; + + @IsOptional() + @nullable() + @IsString() + teamId?: string | null; +} + +export class CreateRoutineTaskDto { + @IsString() + @MinLength(2) + @MaxLength(200) + title!: string; + + @IsOptional() + @nullable() + @IsString() + @MaxLength(2000) + description?: string | null; + + @IsOptional() + @nullable() + @IsDateString() + dueAt?: string | null; + + @IsOptional() + @nullable() + @Matches(DAY_KEY) + scheduledFor?: string | null; + + @IsOptional() + @IsIn(PRIORITIES) + priority?: (typeof PRIORITIES)[number]; + + @IsOptional() @nullable() @IsString() categoryId?: string | null; + @IsOptional() @nullable() @IsString() assigneeId?: string | null; + @IsOptional() @IsBoolean() isMandatory?: boolean; + + @IsOptional() + @IsIn(SOURCES) + source?: (typeof SOURCES)[number]; + + @IsOptional() @nullable() @IsString() leadId?: string | null; + @IsOptional() @nullable() @IsString() personId?: string | null; + @IsOptional() @nullable() @IsString() companyId?: string | null; + @IsOptional() @nullable() @IsString() dealId?: string | null; + @IsOptional() @nullable() @IsString() invoiceId?: string | null; + @IsOptional() @nullable() @IsString() activityId?: string | null; +} + +export class UpdateRoutineTaskDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(200) + title?: string; + + @IsOptional() + @nullable() + @IsString() + @MaxLength(2000) + description?: string | null; + + @IsOptional() @nullable() @IsDateString() dueAt?: string | null; + @IsOptional() @nullable() @Matches(DAY_KEY) scheduledFor?: string | null; + @IsOptional() @IsIn(PRIORITIES) priority?: (typeof PRIORITIES)[number]; + @IsOptional() @nullable() @IsString() categoryId?: string | null; + @IsOptional() @nullable() @IsString() assigneeId?: string | null; + @IsOptional() @IsBoolean() isMandatory?: boolean; + @IsOptional() @nullable() @IsString() leadId?: string | null; + @IsOptional() @nullable() @IsString() personId?: string | null; + @IsOptional() @nullable() @IsString() companyId?: string | null; + @IsOptional() @nullable() @IsString() dealId?: string | null; + @IsOptional() @nullable() @IsString() invoiceId?: string | null; +} + +export class CompleteRoutineTaskDto { + @IsOptional() + @nullable() + @IsString() + @MaxLength(2000) + note?: string | null; +} + +export class SkipRoutineTaskDto { + @IsOptional() + @nullable() + @IsString() + @MaxLength(1000) + reason?: string | null; +} + +export class RescheduleRoutineTaskDto { + @IsDateString() + dueAt!: string; +} + +export class RoutineTaskQueryDto { + @IsOptional() @IsString() userId?: string; + @IsOptional() @Matches(DAY_KEY) date?: string; +} + +export class RoutineRangeQueryDto { + @IsOptional() @Matches(DAY_KEY) from?: string; + @IsOptional() @Matches(DAY_KEY) to?: string; + @IsOptional() @IsString() userId?: string; + @IsOptional() @IsString() teamId?: string; + @IsOptional() @IsString() categoryId?: string; + @IsOptional() @IsIn(SOURCES) source?: (typeof SOURCES)[number]; + @IsOptional() @IsIn(STATUSES) status?: (typeof STATUSES)[number]; + @IsOptional() @IsIn(['me', 'team']) view?: 'me' | 'team'; +} + +export class RoutineEntityQueryDto { + @IsOptional() @IsString() leadId?: string; + @IsOptional() @IsString() personId?: string; + @IsOptional() @IsString() companyId?: string; + @IsOptional() @IsString() dealId?: string; +} + +export class ListRoutinesQueryDto { + @IsOptional() @IsIn(['mine', 'all']) scope?: 'mine' | 'all'; + @IsOptional() @IsString() q?: string; +} + +export class RoutineUserSettingsUpdateDto { + @IsOptional() + @nullable() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(24 * 60) + remindBeforeMinutes?: number | null; + + @IsOptional() @IsBoolean() endOfDaySummary?: boolean; + @IsOptional() @IsBoolean() overdueAlert?: boolean; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + summaryHour?: number; +} diff --git a/apps/api/src/routines/recurrence.ts b/apps/api/src/routines/recurrence.ts new file mode 100644 index 0000000..6b257d5 --- /dev/null +++ b/apps/api/src/routines/recurrence.ts @@ -0,0 +1,101 @@ +import type { RoutineRecurrence } from '@mento/shared'; +import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared'; +import { + addDays, + dayDiff, + jalaliDayOfMonth, + persianWeekday, + toFaDigits, + weekStartKey, +} from './routine-dates'; + +export type RecurrenceRule = { + recurrence: RoutineRecurrence; + startDate: string; + endDate: string | null; + weekdays: number[]; + intervalDays: number | null; + monthDay: number | null; + specificDates: string[]; +}; + +function weeklyDays(rule: RecurrenceRule): number[] { + return rule.weekdays.length ? rule.weekdays : [persianWeekday(rule.startDate)]; +} + +export function occursOn(rule: RecurrenceRule, day: string): boolean { + if (day < rule.startDate) return false; + if (rule.endDate && day > rule.endDate) return false; + + switch (rule.recurrence) { + case 'daily': + return true; + case 'weekdays': + case 'weekly': + return weeklyDays(rule).includes(persianWeekday(day)); + case 'biweekly': { + if (!weeklyDays(rule).includes(persianWeekday(day))) return false; + const weeks = Math.floor( + dayDiff(weekStartKey(rule.startDate), weekStartKey(day)) / 7, + ); + return weeks % 2 === 0; + } + case 'monthly': { + const target = rule.monthDay ?? jalaliDayOfMonth(rule.startDate); + const current = jalaliDayOfMonth(day); + if (current === target) return true; + // ماه‌هایی که روز هدف را ندارند (مثلاً ۳۱ در مهر): آخرین روز ماه + const isLastDay = jalaliDayOfMonth(addDays(day, 1)) === 1; + return isLastDay && target > current; + } + case 'every_n_days': { + const n = Math.max(1, rule.intervalDays ?? 1); + return dayDiff(rule.startDate, day) % n === 0; + } + case 'specific_dates': + return rule.specificDates.includes(day); + default: + return false; + } +} + +function joinFa(items: string[]): string { + if (items.length <= 1) return items.join(''); + return `${items.slice(0, -1).join('، ')} و ${items[items.length - 1]}`; +} + +function describeWeekdays(days: number[]): string { + const sorted = [...new Set(days)].sort((a, b) => a - b); + if (sorted.length === 7) return 'هر روز'; + const contiguous = + sorted.length >= 3 && + sorted.every((d, i) => i === 0 || d === sorted[i - 1]! + 1); + if (contiguous) { + return `${ROUTINE_WEEKDAY_LABELS[sorted[0]!]} تا ${ROUTINE_WEEKDAY_LABELS[sorted[sorted.length - 1]!]}`; + } + return `هر ${joinFa(sorted.map((d) => ROUTINE_WEEKDAY_LABELS[d]!))}`; +} + +export function describeRecurrence(rule: RecurrenceRule): string { + switch (rule.recurrence) { + case 'daily': + return 'هر روز'; + case 'weekdays': + case 'weekly': + return describeWeekdays(weeklyDays(rule)); + case 'biweekly': + return `هر دو هفته، ${joinFa(weeklyDays(rule).map((d) => ROUTINE_WEEKDAY_LABELS[d]!))}`; + case 'monthly': { + const day = rule.monthDay ?? jalaliDayOfMonth(rule.startDate); + return day === 1 ? 'اول هر ماه' : `روز ${toFaDigits(day)} هر ماه`; + } + case 'every_n_days': { + const n = Math.max(1, rule.intervalDays ?? 1); + return n === 1 ? 'هر روز' : `هر ${toFaDigits(n)} روز یک‌بار`; + } + case 'specific_dates': + return `${toFaDigits(rule.specificDates.length)} تاریخ مشخص`; + default: + return ''; + } +} diff --git a/apps/api/src/routines/routine-dates.ts b/apps/api/src/routines/routine-dates.ts new file mode 100644 index 0000000..7397738 --- /dev/null +++ b/apps/api/src/routines/routine-dates.ts @@ -0,0 +1,100 @@ +/** کلید روز (YYYY-MM-DD) و زمان‌ها همگی به وقت تهران (بدون ساعت تابستانی، +03:30). */ +export const TEHRAN_OFFSET = '+03:30'; + +const DAY_FMT = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Tehran', + year: 'numeric', + month: '2-digit', + day: '2-digit', +}); + +const HOUR_FMT = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Tehran', + hour: '2-digit', + hourCycle: 'h23', +}); + +const JALALI_DAY_FMT = new Intl.DateTimeFormat('en-US-u-ca-persian', { + timeZone: 'UTC', + day: 'numeric', +}); + +const DAY_KEY_RE = /^\d{4}-\d{2}-\d{2}$/; +const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; + +export function isDayKey(value: unknown): value is string { + return typeof value === 'string' && DAY_KEY_RE.test(value); +} + +export function isTimeOfDay(value: unknown): value is string { + return typeof value === 'string' && TIME_RE.test(value); +} + +export function tehranDayKey(d: Date = new Date()): string { + return DAY_FMT.format(d); +} + +export function tehranHour(d: Date = new Date()): number { + return Number(HOUR_FMT.format(d)) % 24; +} + +/** مقدار مناسب برای ستون‌های @db.Date */ +export function dayKeyToDate(key: string): Date { + return new Date(`${key}T00:00:00.000Z`); +} + +/** خواندن ستون‌های @db.Date */ +export function dateToDayKey(d: Date): string { + return d.toISOString().slice(0, 10); +} + +export function addDays(key: string, n: number): string { + const d = dayKeyToDate(key); + d.setUTCDate(d.getUTCDate() + n); + return dateToDayKey(d); +} + +/** b - a بر حسب روز */ +export function dayDiff(a: string, b: string): number { + return Math.round( + (dayKeyToDate(b).getTime() - dayKeyToDate(a).getTime()) / 86_400_000, + ); +} + +/** ۰ = شنبه … ۶ = جمعه */ +export function persianWeekday(key: string): number { + return (dayKeyToDate(key).getUTCDay() + 1) % 7; +} + +/** شنبهٔ همان هفته */ +export function weekStartKey(key: string): string { + return addDays(key, -persianWeekday(key)); +} + +export function jalaliDayOfMonth(key: string): number { + return Number(JALALI_DAY_FMT.format(dayKeyToDate(key))); +} + +export function tehranDateTime(key: string, hhmm: string): Date { + return new Date(`${key}T${hhmm}:00${TEHRAN_OFFSET}`); +} + +export function tehranDayStart(key: string): Date { + return tehranDateTime(key, '00:00'); +} + +export function eachDay(from: string, to: string): string[] { + const out: string[] = []; + let cur = from; + let guard = 0; + while (cur <= to && guard < 800) { + out.push(cur); + cur = addDays(cur, 1); + guard += 1; + } + return out; +} + +export function toFaDigits(value: number | string): string { + return String(value).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]!); +} diff --git a/apps/api/src/routines/routine-generator.service.ts b/apps/api/src/routines/routine-generator.service.ts new file mode 100644 index 0000000..433359a --- /dev/null +++ b/apps/api/src/routines/routine-generator.service.ts @@ -0,0 +1,167 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import type { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { occursOn } from './recurrence'; +import { routineRule } from './routine-mapper'; +import { dayKeyToDate, tehranDateTime, tehranDayKey } from './routine-dates'; + +export const DEFAULT_REMIND_MINUTES = 30; + +@Injectable() +export class RoutineGeneratorService implements OnApplicationBootstrap { + private readonly logger = new Logger(RoutineGeneratorService.name); + private overdueRunning = false; + + constructor(private readonly prisma: PrismaService) {} + + onApplicationBootstrap() { + // جبران cron از دست رفته (ری‌استارت سرور حوالی نیمه‌شب) + setTimeout(() => { + void this.generateForDay(tehranDayKey()).catch((err) => + this.logger.warn(`bootstrap generate failed: ${err instanceof Error ? err.message : err}`), + ); + void this.markOverdue(); + }, 8000); + } + + @Cron('5 0 * * *', { timeZone: 'Asia/Tehran' }) + async generateToday(): Promise { + try { + const count = await this.generateForDay(tehranDayKey()); + if (count) this.logger.log(`generated ${count} routine tasks`); + } catch (err) { + this.logger.warn(`daily generate failed: ${err instanceof Error ? err.message : err}`); + } + } + + /** ساخت Taskهای یک روز برای روتین‌های فعال (idempotent). */ + async generateForDay( + day: string, + filter: { organizationId?: string; routineId?: string } = {}, + ): Promise { + const date = dayKeyToDate(day); + const routines = await this.prisma.routine.findMany({ + where: { + isActive: true, + deletedAt: null, + startDate: { lte: date }, + OR: [{ endDate: null }, { endDate: { gte: date } }], + ...(filter.organizationId ? { organizationId: filter.organizationId } : {}), + ...(filter.routineId ? { id: filter.routineId } : {}), + }, + include: { team: { select: { members: { select: { userId: true } } } } }, + }); + if (!routines.length) return 0; + + const candidates = new Set(); + const planned: Array<{ routine: (typeof routines)[number]; userIds: string[] }> = []; + for (const routine of routines) { + if (!occursOn(routineRule(routine), day)) continue; + const userIds = routine.assigneeId + ? [routine.assigneeId] + : [...new Set(routine.team?.members.map((m) => m.userId) ?? [])]; + if (!userIds.length) continue; + userIds.forEach((id) => candidates.add(`${routine.organizationId}:${id}`)); + planned.push({ routine, userIds }); + } + if (!planned.length) return 0; + + const memberships = await this.prisma.membership.findMany({ + where: { + isActive: true, + userId: { in: [...candidates].map((c) => c.split(':')[1]!) }, + organizationId: { in: [...new Set(planned.map((p) => p.routine.organizationId))] }, + user: { isActive: true }, + }, + select: { userId: true, organizationId: true }, + }); + const active = new Set(memberships.map((m) => `${m.organizationId}:${m.userId}`)); + const offsets = await this.reminderOffsets(memberships); + + const rows: Prisma.RoutineTaskCreateManyInput[] = []; + for (const { routine, userIds } of planned) { + const dueAt = tehranDateTime(day, routine.timeOfDay); + for (const assigneeId of userIds) { + const key = `${routine.organizationId}:${assigneeId}`; + if (!active.has(key)) continue; + const minutes = routine.reminderMinutesBefore ?? offsets.get(key) ?? null; + const reminderAt = minutes != null ? new Date(dueAt.getTime() - minutes * 60_000) : null; + rows.push({ + organizationId: routine.organizationId, + routineId: routine.id, + assigneeId, + title: routine.title, + description: routine.description, + categoryId: routine.categoryId, + priority: routine.priority, + scheduledFor: date, + dueAt, + reminderAt, + source: 'routine', + isMandatory: routine.isMandatory, + createdById: routine.createdById, + }); + } + } + if (!rows.length) return 0; + const result = await this.prisma.routineTask.createMany({ data: rows, skipDuplicates: true }); + return result.count; + } + + /** دقیقه‌های یادآور پیش‌فرض هر کاربر (تنظیمات شخصی؛ بدون تنظیم = ۳۰) */ + async reminderOffsets( + pairs: Array<{ organizationId: string; userId: string }>, + ): Promise> { + const map = new Map(); + for (const p of pairs) map.set(`${p.organizationId}:${p.userId}`, DEFAULT_REMIND_MINUTES); + if (!pairs.length) return map; + const settings = await this.prisma.routineUserSettings.findMany({ + where: { userId: { in: [...new Set(pairs.map((p) => p.userId))] } }, + select: { organizationId: true, userId: true, remindBeforeMinutes: true }, + }); + for (const s of settings) { + const key = `${s.organizationId}:${s.userId}`; + if (map.has(key)) map.set(key, s.remindBeforeMinutes); + } + return map; + } + + @Cron('*/5 * * * *') + async markOverdue(): Promise { + if (this.overdueRunning) return; + this.overdueRunning = true; + try { + const now = new Date(); + for (let i = 0; i < 20; i += 1) { + const due = await this.prisma.routineTask.findMany({ + where: { status: { in: ['todo', 'in_progress'] }, dueAt: { lt: now } }, + select: { id: true, organizationId: true, status: true }, + take: 500, + }); + if (!due.length) break; + await this.prisma.$transaction([ + this.prisma.routineTask.updateMany({ + where: { id: { in: due.map((t) => t.id) }, status: { in: ['todo', 'in_progress'] } }, + data: { status: 'overdue' }, + }), + this.prisma.routineTaskEvent.createMany({ + data: due.map((t) => ({ + organizationId: t.organizationId, + taskId: t.id, + type: 'status_changed', + fromStatus: t.status, + toStatus: 'overdue' as const, + note: 'مهلت انجام گذشت', + })), + }), + ]); + if (due.length < 500) break; + } + } catch (err) { + this.logger.warn(`overdue run failed: ${err instanceof Error ? err.message : err}`); + } finally { + this.overdueRunning = false; + } + } +} diff --git a/apps/api/src/routines/routine-mapper.ts b/apps/api/src/routines/routine-mapper.ts new file mode 100644 index 0000000..c3be77a --- /dev/null +++ b/apps/api/src/routines/routine-mapper.ts @@ -0,0 +1,195 @@ +import type { + RoutineCategoryDto, + RoutineDto, + RoutineEntityLink, + RoutineEntityRefDto, + RoutineTaskDto, + RoutineTaskEventDto, +} from '@mento/shared'; +import type { Prisma } from '@prisma/client'; +import { describeRecurrence } from './recurrence'; +import { dateToDayKey } from './routine-dates'; + +const userSelect = { select: { id: true, fullName: true } } as const; + +export const routineTaskInclude = { + category: true, + assignee: userSelect, + createdBy: userSelect, + routine: { + select: { + id: true, + allowSkip: true, + skipReasonRequired: true, + reportRequired: true, + allowAfterDeadline: true, + entityLink: true, + }, + }, + lead: { select: { id: true, title: true, contactName: true } }, + person: { select: { id: true, firstName: true, lastName: true } }, + company: { select: { id: true, name: true } }, + deal: { select: { id: true, title: true } }, + invoice: { select: { id: true, number: true, title: true } }, + activity: { select: { id: true, title: true } }, +} satisfies Prisma.RoutineTaskInclude; + +export type RoutineTaskRow = Prisma.RoutineTaskGetPayload<{ + include: typeof routineTaskInclude; +}>; + +export const routineInclude = { + category: true, + assignee: userSelect, + team: { select: { id: true, name: true } }, +} satisfies Prisma.RoutineInclude; + +export type RoutineRow = Prisma.RoutineGetPayload<{ include: typeof routineInclude }>; + +export function toCategoryDto(row: { + id: string; + name: string; + color: string; + sortOrder: number; +}): RoutineCategoryDto { + return { id: row.id, name: row.name, color: row.color, sortOrder: row.sortOrder }; +} + +function normalizeEntityLink(value: string | null | undefined): RoutineEntityLink { + return value === 'optional' || value === 'lead' || value === 'customer' ? value : 'none'; +} + +export function toTaskDto(row: RoutineTaskRow): RoutineTaskDto { + const entities: RoutineEntityRefDto[] = []; + if (row.lead) { + entities.push({ type: 'lead', id: row.lead.id, label: row.lead.title || row.lead.contactName || 'لید' }); + } + if (row.person) { + entities.push({ + type: 'person', + id: row.person.id, + label: `${row.person.firstName} ${row.person.lastName}`.trim(), + }); + } + if (row.company) entities.push({ type: 'company', id: row.company.id, label: row.company.name }); + if (row.deal) entities.push({ type: 'deal', id: row.deal.id, label: row.deal.title }); + if (row.invoice) { + entities.push({ + type: 'invoice', + id: row.invoice.id, + label: row.invoice.title || `فاکتور ${row.invoice.number}`, + }); + } + if (row.activity) entities.push({ type: 'activity', id: row.activity.id, label: row.activity.title }); + + const r = row.routine; + return { + id: row.id, + routineId: row.routineId, + title: row.title, + description: row.description, + status: row.status, + priority: row.priority, + category: row.category ? toCategoryDto(row.category) : null, + scheduledFor: dateToDayKey(row.scheduledFor), + dueAt: row.dueAt?.toISOString() ?? null, + startedAt: row.startedAt?.toISOString() ?? null, + completedAt: row.completedAt?.toISOString() ?? null, + resultNote: row.resultNote, + skipReason: row.skipReason, + source: row.source, + isMandatory: row.isMandatory, + rules: { + allowSkip: r ? r.allowSkip : true, + skipReasonRequired: r ? r.skipReasonRequired : false, + reportRequired: r ? r.reportRequired : false, + allowAfterDeadline: r ? r.allowAfterDeadline : true, + entityLink: normalizeEntityLink(r?.entityLink ?? 'optional'), + }, + assignee: row.assignee, + createdBy: row.createdBy, + entities, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +export function toEventDto(row: { + id: string; + type: string; + fromStatus: RoutineTaskDto['status'] | null; + toStatus: RoutineTaskDto['status'] | null; + note: string | null; + createdAt: Date; + actor: { id: string; fullName: string } | null; +}): RoutineTaskEventDto { + return { + id: row.id, + type: row.type as RoutineTaskEventDto['type'], + fromStatus: row.fromStatus, + toStatus: row.toStatus, + note: row.note, + actor: row.actor, + createdAt: row.createdAt.toISOString(), + }; +} + +export function routineRule(row: { + recurrence: RoutineDto['recurrence']; + startDate: Date; + endDate: Date | null; + weekdays: number[]; + intervalDays: number | null; + monthDay: number | null; + specificDates: Date[]; +}) { + return { + recurrence: row.recurrence, + startDate: dateToDayKey(row.startDate), + endDate: row.endDate ? dateToDayKey(row.endDate) : null, + weekdays: row.weekdays, + intervalDays: row.intervalDays, + monthDay: row.monthDay, + specificDates: row.specificDates.map(dateToDayKey), + }; +} + +export function toRoutineDto( + row: RoutineRow, + execution: { total: number; done: number } = { total: 0, done: 0 }, +): RoutineDto { + const rule = routineRule(row); + return { + id: row.id, + title: row.title, + description: row.description, + assignee: row.assignee, + team: row.team, + category: row.category ? toCategoryDto(row.category) : null, + priority: row.priority, + timeOfDay: row.timeOfDay, + durationMinutes: row.durationMinutes, + startDate: rule.startDate, + endDate: rule.endDate, + recurrence: row.recurrence, + weekdays: row.weekdays, + intervalDays: row.intervalDays, + monthDay: row.monthDay, + specificDates: rule.specificDates, + isMandatory: row.isMandatory, + allowSkip: row.allowSkip, + skipReasonRequired: row.skipReasonRequired, + reportRequired: row.reportRequired, + allowAfterDeadline: row.allowAfterDeadline, + carryOverOverdue: row.carryOverOverdue, + entityLink: normalizeEntityLink(row.entityLink), + reminderMinutesBefore: row.reminderMinutesBefore, + isActive: row.isActive, + recurrenceLabel: describeRecurrence(rule), + executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null, + executionTotal: execution.total, + executionDone: execution.done, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} diff --git a/apps/api/src/routines/routine-reminders.service.ts b/apps/api/src/routines/routine-reminders.service.ts new file mode 100644 index 0000000..8d88156 --- /dev/null +++ b/apps/api/src/routines/routine-reminders.service.ts @@ -0,0 +1,151 @@ +import { Injectable, Logger } from '@nestjs/common'; +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'; + +const TIME_FMT = new Intl.DateTimeFormat('fa-IR', { + timeZone: 'Asia/Tehran', + hour: '2-digit', + minute: '2-digit', + hour12: false, +}); + +@Injectable() +export class RoutineRemindersService { + private readonly logger = new Logger(RoutineRemindersService.name); + private running = false; + + constructor( + private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, + ) {} + + @Cron('* * * * *') + async run(): Promise { + if (this.running) return; + this.running = true; + try { + await this.sendDueReminders(); + await this.sendOverdueAlerts(); + await this.sendDaySummaries(); + } catch (err) { + this.logger.warn(`routine reminders failed: ${err instanceof Error ? err.message : err}`); + } finally { + this.running = false; + } + } + + /** یادآور قبل از مهلت («یک Task شما ۳۰ دقیقه دیگر Deadline دارد») */ + private async sendDueReminders() { + const now = new Date(); + const due = await this.prisma.routineTask.findMany({ + where: { + status: { in: ['todo', 'in_progress'] }, + reminderAt: { lte: now }, + reminderSentAt: null, + }, + select: { id: true, organizationId: true, assigneeId: true, title: true, dueAt: true, reminderAt: true }, + take: 300, + }); + for (const task of due) { + const claimed = await this.prisma.routineTask.updateMany({ + where: { id: task.id, reminderSentAt: null }, + data: { reminderSentAt: now }, + }); + if (!claimed.count) continue; + // یادآورهای خیلی قدیمی (قطعی سرور) ارسال نمی‌شوند + if (task.reminderAt && now.getTime() - task.reminderAt.getTime() > 2 * 60 * 60_000) continue; + const minutes = task.dueAt ? Math.max(0, Math.round((task.dueAt.getTime() - now.getTime()) / 60_000)) : null; + await this.notifications.create({ + organizationId: task.organizationId, + userId: task.assigneeId, + type: 'routine_task_reminder', + title: + minutes != null && minutes > 0 + ? `یک کار شما ${toFaDigits(minutes)} دقیقه دیگر مهلت دارد` + : 'یادآوری کار روتین', + body: task.dueAt ? `${task.title} — ساعت ${TIME_FMT.format(task.dueAt)}` : task.title, + link: `/routines?task=${task.id}`, + entityType: 'routine_task', + entityId: task.id, + }); + } + } + + /** هشدار Task اجباری که مهلتش گذشته («… هنوز انجام نشده») */ + private async sendOverdueAlerts() { + const rows = await this.prisma.routineTask.findMany({ + where: { status: 'overdue', isMandatory: true, overdueNotifiedAt: null }, + select: { id: true, organizationId: true, assigneeId: true, title: true }, + take: 300, + }); + if (!rows.length) return; + const settings = await this.prisma.routineUserSettings.findMany({ + where: { userId: { in: [...new Set(rows.map((r) => r.assigneeId))] }, overdueAlert: false }, + select: { organizationId: true, userId: true }, + }); + const muted = new Set(settings.map((s) => `${s.organizationId}:${s.userId}`)); + const now = new Date(); + for (const task of rows) { + const claimed = await this.prisma.routineTask.updateMany({ + where: { id: task.id, overdueNotifiedAt: null }, + data: { overdueNotifiedAt: now }, + }); + if (!claimed.count || muted.has(`${task.organizationId}:${task.assigneeId}`)) continue; + await this.notifications.create({ + organizationId: task.organizationId, + userId: task.assigneeId, + type: 'routine_task_overdue', + title: `کار «${task.title}» هنوز انجام نشده`, + body: 'مهلت این کار اجباری گذشته است.', + link: `/routines/overdue?task=${task.id}`, + entityType: 'routine_task', + entityId: task.id, + }); + } + } + + /** خلاصه پایان روز («۳ کار تا پایان امروز باقی مانده است») */ + private async sendDaySummaries() { + const hour = tehranHour(); + const today = tehranDayKey(); + const todayDate = dayKeyToDate(today); + + const groups = await this.prisma.routineTask.groupBy({ + by: ['organizationId', 'assigneeId'], + where: { scheduledFor: todayDate, status: { in: ['todo', 'in_progress'] } }, + _count: { _all: true }, + }); + if (!groups.length) return; + + const settings = await this.prisma.routineUserSettings.findMany({ + where: { userId: { in: [...new Set(groups.map((g) => g.assigneeId))] } }, + }); + const byKey = new Map(settings.map((s) => [`${s.organizationId}:${s.userId}`, s])); + + for (const g of groups) { + const key = `${g.organizationId}:${g.assigneeId}`; + const s = byKey.get(key); + if (s && !s.endOfDaySummary) continue; + if (hour < (s?.summaryHour ?? 16)) continue; + if (s?.lastSummaryOn && s.lastSummaryOn.getTime() >= todayDate.getTime()) continue; + + await this.prisma.routineUserSettings.upsert({ + where: { organizationId_userId: { organizationId: g.organizationId, userId: g.assigneeId } }, + create: { organizationId: g.organizationId, userId: g.assigneeId, lastSummaryOn: todayDate }, + update: { lastSummaryOn: todayDate }, + }); + await this.notifications.create({ + organizationId: g.organizationId, + userId: g.assigneeId, + type: 'routine_day_summary', + title: `${toFaDigits(g._count._all)} کار تا پایان امروز باقی مانده است`, + body: 'برای تکمیل روز، کارهای باقی‌مانده را انجام دهید.', + link: '/routines', + entityType: 'routine_day', + entityId: `${g.assigneeId}:${today}`, + }); + } + } +} diff --git a/apps/api/src/routines/routine-scope.ts b/apps/api/src/routines/routine-scope.ts new file mode 100644 index 0000000..e77059f --- /dev/null +++ b/apps/api/src/routines/routine-scope.ts @@ -0,0 +1,79 @@ +import { ForbiddenException } from '@nestjs/common'; +import { type Actor, elevated, scopedOwnerIds } from '../common/record-scope'; +import type { PrismaService } from '../prisma/prisma.service'; + +export type RoutineScope = { + userId: string; + organizationId: string; + /** مالک/مدیر سازمان یا مدیر حداقل یک تیم */ + isManager: boolean; + /** کاربرانی که می‌تواند برایشان روتین/Task تعریف کند؛ null = همه */ + manageableUserIds: string[] | null; + /** کاربرانی که Taskهایشان را می‌بیند؛ null = همه */ + viewableUserIds: string[] | null; +}; + +export async function resolveRoutineScope( + prisma: PrismaService, + actor: Actor, +): Promise { + const base = { + userId: actor.userId, + organizationId: actor.organizationId, + }; + if (elevated(actor.role)) { + return { ...base, isManager: true, manageableUserIds: null, viewableUserIds: null }; + } + + const managed = await prisma.teamMember.findMany({ + where: { userId: actor.userId, role: 'manager', team: { organizationId: actor.organizationId } }, + select: { teamId: true }, + }); + let manageable: string[] = [actor.userId]; + if (managed.length) { + const members = await prisma.teamMember.findMany({ + where: { teamId: { in: managed.map((m) => m.teamId) } }, + select: { userId: true }, + }); + manageable = [...new Set([actor.userId, ...members.map((m) => m.userId)])]; + } + + const visible = scopedOwnerIds(actor, 'routines'); + const viewable = visible === null ? null : [...new Set([...visible, ...manageable])]; + + return { + ...base, + isManager: managed.length > 0, + manageableUserIds: manageable, + viewableUserIds: viewable, + }; +} + +export function canView(scope: RoutineScope, userId: string): boolean { + return scope.viewableUserIds === null || scope.viewableUserIds.includes(userId); +} + +export function canManage(scope: RoutineScope, userId: string): boolean { + return scope.manageableUserIds === null || scope.manageableUserIds.includes(userId); +} + +export function assertManager(scope: RoutineScope): void { + if (!scope.isManager) { + throw new ForbiddenException('فقط مدیران به این بخش دسترسی دارند'); + } +} + +/** فیلتر Prisma روی assigneeId بر اساس دید کاربر و فیلتر کاربر درخواستی */ +export function assigneeFilter( + scope: RoutineScope, + requestedUserId?: string | null, +): { assigneeId?: string | { in: string[] } } { + if (requestedUserId) { + if (!canView(scope, requestedUserId)) { + throw new ForbiddenException('دسترسی به کارهای این کاربر ندارید'); + } + return { assigneeId: requestedUserId }; + } + if (scope.viewableUserIds === null) return {}; + return { assigneeId: { in: scope.viewableUserIds } }; +} diff --git a/apps/api/src/routines/routine-stats.service.ts b/apps/api/src/routines/routine-stats.service.ts new file mode 100644 index 0000000..c072c3d --- /dev/null +++ b/apps/api/src/routines/routine-stats.service.ts @@ -0,0 +1,565 @@ +import { ForbiddenException, Injectable } from '@nestjs/common'; +import type { + RoutineCalendarDayDto, + RoutineCategoryStatDto, + RoutineDayState, + RoutineExecutionRowDto, + RoutineHistoryDayDto, + RoutineMemberStatDto, + RoutineMonthlyReportDto, + RoutinePerformanceDto, + RoutinePeriodTotalsDto, + RoutineScoreDto, + RoutineStatusBreakdownDto, + RoutineTaskStatus, + RoutineTeamPerformanceDto, + RoutineTrendPointDto, + RoutineWeeklyReportDto, +} from '@mento/shared'; +import type { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import type { RoutineRangeQueryDto } from './dto'; +import { describeRecurrence } from './recurrence'; +import { routineRule } from './routine-mapper'; +import { type RoutineScope, assertManager, canView } from './routine-scope'; +import { + addDays, + dateToDayKey, + dayDiff, + dayKeyToDate, + eachDay, + jalaliDayOfMonth, + tehranDayKey, + weekStartKey, +} from './routine-dates'; + +type StatTask = { + assigneeId: string; + scheduledFor: Date; + status: RoutineTaskStatus; + isMandatory: boolean; + categoryId: string | null; + routineId: string | null; + source: string; + dueAt: Date | null; + completedAt: Date | null; + createdAt: Date; +}; + +type Counts = { + total: number; + completed: number; + overdue: number; + skipped: number; + cancelled: number; + pending: number; +}; + +const statSelect = { + assigneeId: true, + scheduledFor: true, + status: true, + isMandatory: true, + categoryId: true, + routineId: true, + source: true, + dueAt: true, + completedAt: true, + createdAt: true, +} satisfies Prisma.RoutineTaskSelect; + +function emptyCounts(): Counts { + return { total: 0, completed: 0, overdue: 0, skipped: 0, cancelled: 0, pending: 0 }; +} + +function addTo(c: Counts, status: RoutineTaskStatus, n = 1) { + c.total += n; + if (status === 'completed') c.completed += n; + else if (status === 'overdue') c.overdue += n; + else if (status === 'skipped') c.skipped += n; + else if (status === 'cancelled') c.cancelled += n; + else c.pending += n; +} + +/** Taskهای لغو و رد شده در درصد حساب نمی‌شوند */ +export function effectiveTotal(c: Counts): number { + return c.total - c.cancelled - c.skipped; +} + +export function percentOf(c: Counts): number | null { + const eff = effectiveTotal(c); + return eff > 0 ? Math.round((c.completed / eff) * 100) : null; +} + +export function dayState(c: Counts, day: string, today: string): RoutineDayState { + const eff = effectiveTotal(c); + if (eff <= 0 || day > today) return 'empty'; + if (c.completed >= eff) return 'complete'; + if (c.completed === 0 && day < today) return 'missed'; + return 'partial'; +} + +export function jalaliMonthStart(day: string): string { + return addDays(day, -(jalaliDayOfMonth(day) - 1)); +} + +@Injectable() +export class RoutineStatsService { + constructor(private readonly prisma: PrismaService) {} + + /* ───────── امتیاز روزانه و Streak ───────── */ + + async score(organizationId: string, userId: string, day = tehranDayKey()): Promise { + const monthStart = jalaliMonthStart(day); + const weekStart = weekStartKey(day); + const from = [monthStart, weekStart, addDays(day, -1)].sort()[0]!; + const tasks = await this.prisma.routineTask.findMany({ + where: { + organizationId, + assigneeId: userId, + scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(day) }, + }, + select: { scheduledFor: true, status: true }, + }); + const range = (a: string, b: string) => { + const c = emptyCounts(); + for (const t of tasks) { + const k = dateToDayKey(t.scheduledFor); + if (k >= a && k <= b) addTo(c, t.status); + } + return percentOf(c); + }; + const yesterday = addDays(day, -1); + return { + today: range(day, day), + yesterday: range(yesterday, yesterday), + week: range(weekStart, day), + month: range(monthStart, day), + streak: (await this.streaks(organizationId, [userId], day)).get(userId) ?? 0, + }; + } + + /** روزهای متوالی که همه Taskهای اجباری انجام (یا مجاز رد) شده‌اند؛ روز بدون Task اجباری خنثی است */ + async streaks( + organizationId: string, + userIds: string[], + today = tehranDayKey(), + ): Promise> { + const out = new Map(); + if (!userIds.length) return out; + const groups = await this.prisma.routineTask.groupBy({ + by: ['assigneeId', 'scheduledFor', 'status'], + where: { + organizationId, + assigneeId: { in: userIds }, + isMandatory: true, + status: { not: 'cancelled' }, + scheduledFor: { gte: dayKeyToDate(addDays(today, -180)), lte: dayKeyToDate(today) }, + }, + _count: { _all: true }, + }); + const byUser = new Map>(); + for (const g of groups) { + const days = byUser.get(g.assigneeId) ?? new Map(); + const key = dateToDayKey(g.scheduledFor); + const cur = days.get(key) ?? { total: 0, ok: 0 }; + cur.total += g._count._all; + if (g.status === 'completed' || g.status === 'skipped') cur.ok += g._count._all; + days.set(key, cur); + byUser.set(g.assigneeId, days); + } + for (const userId of userIds) { + const days = byUser.get(userId); + let streak = 0; + if (days) { + const keys = [...days.keys()].sort().reverse(); + for (const key of keys) { + const d = days.get(key)!; + const complete = d.ok >= d.total; + if (key === today) { + if (complete) streak += 1; + continue; + } + if (!complete) break; + streak += 1; + } + } + out.set(userId, streak); + } + return out; + } + + /* ───────── خلاصه روزها (تقویم/تاریخچه) ───────── */ + + summarizeDays(tasks: Array<{ scheduledFor: Date; status: RoutineTaskStatus }>, from: string, to: string) { + const today = tehranDayKey(); + const map = new Map(); + for (const t of tasks) { + const k = dateToDayKey(t.scheduledFor); + const c = map.get(k) ?? emptyCounts(); + addTo(c, t.status); + map.set(k, c); + } + return eachDay(from, to).map((date) => { + const c = map.get(date) ?? emptyCounts(); + return { date, counts: c, state: dayState(c, date, today), percent: percentOf(c) }; + }); + } + + toCalendarDays( + tasks: Array<{ scheduledFor: Date; status: RoutineTaskStatus }>, + from: string, + to: string, + ): RoutineCalendarDayDto[] { + return this.summarizeDays(tasks, from, to).map(({ date, counts, state, percent }) => ({ + date, + total: effectiveTotal(counts), + completed: counts.completed, + overdue: counts.overdue, + percent, + state, + })); + } + + /* ───────── گزارش‌ها ───────── */ + + async performance(scope: RoutineScope, q: RoutineRangeQueryDto): Promise { + const userId = q.userId || scope.userId; + if (!canView(scope, userId)) throw new ForbiddenException('دسترسی ندارید'); + const { from, to } = this.range(q, 29); + const tasks = await this.fetch(scope.organizationId, from, to, { ...q, userId }, [userId]); + const agg = await this.aggregate(scope.organizationId, tasks, from, to); + const streak = (await this.streaks(scope.organizationId, [userId])).get(userId) ?? 0; + return { + from, + to, + total: agg.effTotal, + completed: agg.counts.completed, + notDone: agg.status.notDone, + overdue: agg.counts.overdue, + percent: percentOf(agg.counts), + avgCompletionMinutes: this.avgCompletion(tasks), + completeDays: agg.days.complete, + partialDays: agg.days.partial, + missedDays: agg.days.missed, + streak, + status: agg.status, + trend: agg.trend, + byCategory: agg.byCategory, + }; + } + + async team(scope: RoutineScope, q: RoutineRangeQueryDto): Promise { + assertManager(scope); + const { from, to } = this.range(q, 29); + const users = await this.teamUsers(scope, q); + const tasks = await this.fetch(scope.organizationId, from, to, q, users.map((u) => u.id)); + const agg = await this.aggregate(scope.organizationId, tasks, from, to); + return { + from, + to, + total: agg.effTotal, + completed: agg.counts.completed, + overdue: agg.counts.overdue, + percent: percentOf(agg.counts), + status: agg.status, + trend: agg.trend, + byCategory: agg.byCategory, + members: await this.memberStats(scope.organizationId, users, tasks), + }; + } + + async weekly(scope: RoutineScope, q: RoutineRangeQueryDto): Promise { + const from = weekStartKey(q.from || tehranDayKey()); + const to = addDays(from, 6); + const prevFrom = addDays(from, -7); + const userIds = await this.viewUsers(scope, q); + const tasks = await this.fetch(scope.organizationId, prevFrom, to, q, userIds); + const current = tasks.filter((t) => dateToDayKey(t.scheduledFor) >= from); + const previous = tasks.filter((t) => dateToDayKey(t.scheduledFor) < from); + return { + from, + to, + days: this.toCalendarDays(current, from, to), + totals: this.periodTotals(current, from, to), + previous: this.periodTotals(previous, prevFrom, addDays(from, -1)), + }; + } + + async monthly(scope: RoutineScope, q: RoutineRangeQueryDto): Promise { + const from = q.from || jalaliMonthStart(tehranDayKey()); + const to = q.to || addDays(jalaliMonthStart(addDays(from, 32)), -1); + const length = dayDiff(from, to) + 1; + const prevTo = addDays(from, -1); + const prevFrom = addDays(from, -length); + const userIds = await this.viewUsers(scope, q); + const tasks = await this.fetch(scope.organizationId, prevFrom, to, q, userIds); + const current = tasks.filter((t) => dateToDayKey(t.scheduledFor) >= from); + const previous = tasks.filter((t) => dateToDayKey(t.scheduledFor) < from); + const status = this.statusBreakdown(current); + let byUser: RoutineMemberStatDto[] = []; + if (q.view === 'team') { + const users = await this.teamUsers(scope, q); + byUser = await this.memberStats(scope.organizationId, users, current); + } + return { + from, + to, + totals: this.periodTotals(current, from, to), + previous: this.periodTotals(previous, prevFrom, prevTo), + status, + days: this.toCalendarDays(current, from, to), + byUser, + }; + } + + async routinesExecution(scope: RoutineScope, q: RoutineRangeQueryDto): Promise { + assertManager(scope); + const { from, to } = this.range(q, 29); + const users = await this.teamUsers(scope, q); + const where: Prisma.RoutineTaskWhereInput = { + organizationId: scope.organizationId, + routineId: { not: null }, + scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) }, + assigneeId: { in: users.map((u) => u.id) }, + status: { notIn: ['cancelled'] }, + ...(q.categoryId ? { categoryId: q.categoryId } : {}), + }; + const groups = await this.prisma.routineTask.groupBy({ + by: ['routineId', 'status'], + where, + _count: { _all: true }, + }); + const map = new Map(); + for (const g of groups) { + if (!g.routineId) continue; + const c = map.get(g.routineId) ?? emptyCounts(); + addTo(c, g.status, g._count._all); + map.set(g.routineId, c); + } + if (!map.size) return []; + const routines = await this.prisma.routine.findMany({ + where: { id: { in: [...map.keys()] } }, + include: { assignee: { select: { fullName: true } }, team: { select: { name: true } } }, + }); + return routines + .map((r) => { + const c = map.get(r.id)!; + const resolved = c.completed + c.overdue + c.skipped; + return { + routineId: r.id, + title: r.deletedAt ? `${r.title} (حذف‌شده)` : r.title, + assigneeLabel: r.assignee?.fullName ?? r.team?.name ?? '—', + recurrenceLabel: describeRecurrence(routineRule(r)), + occurrences: c.total, + completed: c.completed, + notDone: c.overdue + c.pending, + skipped: c.skipped, + percent: resolved ? Math.round((c.completed / resolved) * 100) : null, + }; + }) + .sort((a, b) => (a.percent ?? 101) - (b.percent ?? 101)); + } + + async history(scope: RoutineScope, q: RoutineRangeQueryDto): Promise { + const userId = q.userId || scope.userId; + if (!canView(scope, userId)) throw new ForbiddenException('دسترسی ندارید'); + const { from, to } = this.range(q, 59); + const tasks = await this.fetch(scope.organizationId, from, to, { ...q, userId }, [userId]); + return this.summarizeDays(tasks, from, to) + .filter((d) => d.counts.total > 0) + .reverse() + .map(({ date, counts, state, percent }) => ({ + date, + total: counts.total, + completed: counts.completed, + overdue: counts.overdue, + skipped: counts.skipped, + cancelled: counts.cancelled, + percent, + state, + })); + } + + /* ───────── کمکی ───────── */ + + private range(q: { from?: string; to?: string }, defaultDays: number) { + const to = q.to || tehranDayKey(); + let from = q.from || addDays(to, -defaultDays); + if (from > to) from = to; + if (dayDiff(from, to) > 400) from = addDays(to, -400); + return { from, to }; + } + + private async viewUsers(scope: RoutineScope, q: RoutineRangeQueryDto) { + if (q.view === 'team') return (await this.teamUsers(scope, q)).map((u) => u.id); + const userId = q.userId || scope.userId; + if (!canView(scope, userId)) throw new ForbiddenException('دسترسی ندارید'); + return [userId]; + } + + async teamUsers(scope: RoutineScope, q: { userId?: string; teamId?: string }) { + assertManager(scope); + let ids = scope.manageableUserIds; + if (q.teamId) { + const members = await this.prisma.teamMember.findMany({ + where: { teamId: q.teamId, team: { organizationId: scope.organizationId } }, + select: { userId: true }, + }); + const teamIds = members.map((m) => m.userId); + ids = ids ? ids.filter((id) => teamIds.includes(id)) : teamIds; + } + if (q.userId) { + ids = ids ? ids.filter((id) => id === q.userId) : [q.userId]; + } + const rows = await this.prisma.membership.findMany({ + where: { + organizationId: scope.organizationId, + isActive: true, + user: { isActive: true }, + ...(ids ? { userId: { in: ids } } : {}), + }, + select: { user: { select: { id: true, fullName: true } } }, + orderBy: { user: { fullName: 'asc' } }, + }); + return rows.map((r) => r.user); + } + + private fetch( + organizationId: string, + from: string, + to: string, + q: RoutineRangeQueryDto, + userIds: string[], + ): Promise { + return this.prisma.routineTask.findMany({ + where: { + organizationId, + assigneeId: { in: userIds }, + scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) }, + ...(q.categoryId ? { categoryId: q.categoryId } : {}), + ...(q.source ? { source: q.source } : {}), + ...(q.status ? { status: q.status } : {}), + }, + select: statSelect, + }); + } + + private statusBreakdown(tasks: StatTask[]): RoutineStatusBreakdownDto & { notDone: number } { + const c = emptyCounts(); + for (const t of tasks) addTo(c, t.status); + return { + completed: c.completed, + notDone: c.pending + c.overdue, + overdue: c.overdue, + skipped: c.skipped, + cancelled: c.cancelled, + }; + } + + private periodTotals(tasks: StatTask[], from: string, to: string): RoutinePeriodTotalsDto { + const c = emptyCounts(); + for (const t of tasks) addTo(c, t.status); + const days = this.summarizeDays(tasks, from, to); + const percents = days.map((d) => d.percent).filter((p): p is number => p != null); + return { + total: effectiveTotal(c), + completed: c.completed, + notDone: c.pending + c.overdue, + overdue: c.overdue, + percent: percentOf(c), + completeDays: days.filter((d) => d.state === 'complete').length, + partialDays: days.filter((d) => d.state === 'partial').length, + missedDays: days.filter((d) => d.state === 'missed').length, + avgPercent: percents.length + ? Math.round(percents.reduce((s, p) => s + p, 0) / percents.length) + : null, + }; + } + + private async aggregate(organizationId: string, tasks: StatTask[], from: string, to: string) { + const counts = emptyCounts(); + for (const t of tasks) addTo(counts, t.status); + const days = this.summarizeDays(tasks, from, to); + const trend: RoutineTrendPointDto[] = days.map((d) => ({ + date: d.date, + total: effectiveTotal(d.counts), + completed: d.counts.completed, + percent: d.percent, + })); + + const catCounts = new Map(); + for (const t of tasks) { + const c = catCounts.get(t.categoryId) ?? emptyCounts(); + addTo(c, t.status); + catCounts.set(t.categoryId, c); + } + const catIds = [...catCounts.keys()].filter((id): id is string => !!id); + const cats = catIds.length + ? await this.prisma.routineCategory.findMany({ + where: { organizationId, id: { in: catIds } }, + }) + : []; + const byCategory: RoutineCategoryStatDto[] = [...catCounts.entries()] + .map(([id, c]) => { + const cat = cats.find((x) => x.id === id); + return { + categoryId: id, + name: cat?.name ?? 'بدون دسته', + color: cat?.color ?? '#94a3b8', + total: effectiveTotal(c), + completed: c.completed, + percent: percentOf(c), + }; + }) + .sort((a, b) => b.total - a.total); + + return { + counts, + effTotal: effectiveTotal(counts), + status: this.statusBreakdown(tasks), + trend, + byCategory, + days: { + complete: days.filter((d) => d.state === 'complete').length, + partial: days.filter((d) => d.state === 'partial').length, + missed: days.filter((d) => d.state === 'missed').length, + }, + }; + } + + private async memberStats( + organizationId: string, + users: Array<{ id: string; fullName: string }>, + tasks: StatTask[], + ): Promise { + const streaks = await this.streaks(organizationId, users.map((u) => u.id)); + return users.map((user) => { + const own = tasks.filter((t) => t.assigneeId === user.id); + const c = emptyCounts(); + for (const t of own) addTo(c, t.status); + return { + user, + total: effectiveTotal(c), + completed: c.completed, + overdue: c.overdue, + skipped: c.skipped, + percent: percentOf(c), + streak: streaks.get(user.id) ?? 0, + avgCompletionMinutes: this.avgCompletion(own), + }; + }); + } + + /** میانگین فاصله انجام از زمان برنامه (روتین: ساعت برنامه، بقیه: زمان ثبت) */ + private avgCompletion(tasks: StatTask[]): number | null { + const values: number[] = []; + for (const t of tasks) { + if (t.status !== 'completed' || !t.completedAt) continue; + const ref = t.source === 'routine' && t.dueAt ? t.dueAt : t.createdAt; + values.push(Math.max(0, (t.completedAt.getTime() - ref.getTime()) / 60_000)); + } + if (!values.length) return null; + return Math.round(values.reduce((s, v) => s + v, 0) / values.length); + } +} diff --git a/apps/api/src/routines/routine-tasks.controller.ts b/apps/api/src/routines/routine-tasks.controller.ts new file mode 100644 index 0000000..5de7dc7 --- /dev/null +++ b/apps/api/src/routines/routine-tasks.controller.ts @@ -0,0 +1,129 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { OrgModuleGuard } from '../billing/org-module.guard'; +import { RequireOrgModule } from '../billing/org-module.decorator'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { Actor } from '../common/record-scope'; +import { RoutineTasksService } from './routine-tasks.service'; +import { + CompleteRoutineTaskDto, + CreateRoutineTaskDto, + RescheduleRoutineTaskDto, + RoutineEntityQueryDto, + RoutineRangeQueryDto, + RoutineTaskQueryDto, + RoutineUserSettingsUpdateDto, + SkipRoutineTaskDto, + UpdateRoutineTaskDto, +} from './dto'; + +@Controller('routine-tasks') +@UseGuards(JwtAuthGuard, OrgModuleGuard) +@RequireOrgModule('routines') +export class RoutineTasksController { + constructor(private readonly tasks: RoutineTasksService) {} + + @Get('today') + today(@CurrentUser() user: Actor, @Query() q: RoutineTaskQueryDto) { + return this.tasks.today(user, q.userId, q.date); + } + + @Get('badge') + badge(@CurrentUser() user: Actor) { + return this.tasks.badge(user); + } + + @Get('overdue') + overdue(@CurrentUser() user: Actor, @Query() q: RoutineTaskQueryDto) { + return this.tasks.overdue(user, q.userId); + } + + @Get('calendar') + calendar(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) { + return this.tasks.calendar(user, q); + } + + @Get('by-entity') + byEntity(@CurrentUser() user: Actor, @Query() q: RoutineEntityQueryDto) { + return this.tasks.byEntity(user, q); + } + + @Get('settings') + settings(@CurrentUser() user: Actor) { + return this.tasks.getSettings(user); + } + + @Put('settings') + updateSettings(@CurrentUser() user: Actor, @Body() dto: RoutineUserSettingsUpdateDto) { + return this.tasks.updateSettings(user, dto); + } + + @Post() + create(@CurrentUser() user: Actor, @Body() dto: CreateRoutineTaskDto) { + return this.tasks.create(user, dto); + } + + @Post('from-activity/:activityId') + fromActivity(@CurrentUser() user: Actor, @Param('activityId') activityId: string) { + return this.tasks.createFromActivity(user, activityId); + } + + @Get(':id') + detail(@CurrentUser() user: Actor, @Param('id') id: string) { + return this.tasks.detail(user, id); + } + + @Patch(':id') + update(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: UpdateRoutineTaskDto) { + return this.tasks.update(user, id, dto); + } + + @Post(':id/complete') + complete(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: CompleteRoutineTaskDto) { + return this.tasks.complete(user, id, dto.note); + } + + @Post(':id/uncomplete') + uncomplete(@CurrentUser() user: Actor, @Param('id') id: string) { + return this.tasks.uncomplete(user, id); + } + + @Post(':id/start') + start(@CurrentUser() user: Actor, @Param('id') id: string) { + return this.tasks.start(user, id); + } + + @Post(':id/skip') + skip(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: SkipRoutineTaskDto) { + return this.tasks.skip(user, id, dto.reason); + } + + @Post(':id/reschedule') + reschedule( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() dto: RescheduleRoutineTaskDto, + ) { + return this.tasks.reschedule(user, id, dto.dueAt); + } + + @Post(':id/move-today') + moveToday(@CurrentUser() user: Actor, @Param('id') id: string) { + return this.tasks.moveToToday(user, id); + } + + @Post(':id/cancel') + cancel(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: SkipRoutineTaskDto) { + return this.tasks.cancel(user, id, dto.reason); + } +} diff --git a/apps/api/src/routines/routine-tasks.service.ts b/apps/api/src/routines/routine-tasks.service.ts new file mode 100644 index 0000000..8e22c6f --- /dev/null +++ b/apps/api/src/routines/routine-tasks.service.ts @@ -0,0 +1,609 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { + RoutineCalendarDto, + RoutineTaskDetailDto, + RoutineTaskDto, + RoutineTaskSource, + RoutineTaskStatus, + RoutineTodayDto, + RoutineUserSettingsDto, +} from '@mento/shared'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import type { Actor } from '../common/record-scope'; +import type { + CreateRoutineTaskDto, + RoutineEntityQueryDto, + RoutineRangeQueryDto, + RoutineUserSettingsUpdateDto, + UpdateRoutineTaskDto, +} from './dto'; +import { + type RoutineScope, + assigneeFilter, + canManage, + canView, + resolveRoutineScope, +} from './routine-scope'; +import { + type RoutineTaskRow, + routineTaskInclude, + toEventDto, + toTaskDto, +} from './routine-mapper'; +import { DEFAULT_REMIND_MINUTES } from './routine-generator.service'; +import { RoutineStatsService, effectiveTotal } from './routine-stats.service'; +import { + addDays, + dayKeyToDate, + isDayKey, + tehranDateTime, + tehranDayKey, +} from './routine-dates'; + +const OPEN_STATUSES: RoutineTaskStatus[] = ['todo', 'in_progress', 'overdue']; + +@Injectable() +export class RoutineTasksService { + constructor( + private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, + private readonly stats: RoutineStatsService, + ) {} + + scope(actor: Actor): Promise { + return resolveRoutineScope(this.prisma, actor); + } + + /* ───────── خواندن ───────── */ + + async today(actor: Actor, userId?: string, date?: string): Promise { + const scope = await this.scope(actor); + const target = userId || actor.userId; + if (!canView(scope, target)) throw new ForbiddenException('دسترسی ندارید'); + const day = date && isDayKey(date) ? date : tehranDayKey(); + const dayDate = dayKeyToDate(day); + + const [todayRows, overdueRows, score] = await Promise.all([ + this.prisma.routineTask.findMany({ + where: { + organizationId: actor.organizationId, + assigneeId: target, + scheduledFor: dayDate, + status: { not: 'cancelled' }, + }, + include: routineTaskInclude, + orderBy: [{ dueAt: 'asc' }, { createdAt: 'asc' }], + }), + this.prisma.routineTask.findMany({ + where: { + organizationId: actor.organizationId, + assigneeId: target, + status: 'overdue', + scheduledFor: { lt: dayDate }, + }, + include: { ...routineTaskInclude, routine: { select: { ...routineTaskInclude.routine.select, carryOverOverdue: true } } }, + orderBy: [{ dueAt: 'asc' }], + take: 100, + }), + this.stats.score(actor.organizationId, target, day), + ]); + + const carried = overdueRows.filter((r) => r.routine?.carryOverOverdue); + const overdue = overdueRows.filter((r) => !r.routine?.carryOverOverdue); + + const counts = { total: 0, completed: 0, overdue: 0, skipped: 0, cancelled: 0, pending: 0 }; + for (const r of todayRows) { + counts.total += 1; + if (r.status === 'completed') counts.completed += 1; + else if (r.status === 'skipped') counts.skipped += 1; + else if (r.status === 'overdue') counts.overdue += 1; + else counts.pending += 1; + } + const eff = effectiveTotal(counts); + return { + date: day, + tasks: [...todayRows, ...carried].map((r) => toTaskDto(r as RoutineTaskRow)), + overdue: overdue.map((r) => toTaskDto(r as RoutineTaskRow)), + stats: { + total: eff, + completed: counts.completed, + remaining: counts.pending, + overdue: counts.overdue + overdueRows.length, + percent: eff > 0 ? Math.round((counts.completed / eff) * 100) : null, + }, + score, + }; + } + + /** شمارش کارهای باقی‌مانده امروز برای Badge منو */ + async badge(actor: Actor): Promise<{ remaining: number; overdue: number }> { + const day = dayKeyToDate(tehranDayKey()); + const [remaining, overdue] = await Promise.all([ + this.prisma.routineTask.count({ + where: { + organizationId: actor.organizationId, + assigneeId: actor.userId, + scheduledFor: day, + status: { in: ['todo', 'in_progress'] }, + }, + }), + this.prisma.routineTask.count({ + where: { organizationId: actor.organizationId, assigneeId: actor.userId, status: 'overdue' }, + }), + ]); + return { remaining, overdue }; + } + + async overdue(actor: Actor, userId?: string): Promise { + const scope = await this.scope(actor); + const filter = + userId === 'all' ? assigneeFilter(scope) : assigneeFilter(scope, userId || actor.userId); + const rows = await this.prisma.routineTask.findMany({ + where: { organizationId: actor.organizationId, status: 'overdue', ...filter }, + include: routineTaskInclude, + orderBy: [{ dueAt: 'asc' }], + take: 300, + }); + return rows.map(toTaskDto); + } + + async calendar(actor: Actor, q: RoutineRangeQueryDto): Promise { + const scope = await this.scope(actor); + const to = q.to || tehranDayKey(); + let from = q.from || addDays(to, -30); + if (from > to) from = to; + const filter = assigneeFilter(scope, q.userId || actor.userId); + const where: Prisma.RoutineTaskWhereInput = { + organizationId: actor.organizationId, + scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) }, + ...filter, + ...(q.categoryId ? { categoryId: q.categoryId } : {}), + ...(q.status ? { status: q.status } : {}), + ...(q.source ? { source: q.source } : {}), + }; + const rows = await this.prisma.routineTask.findMany({ + where, + include: routineTaskInclude, + orderBy: [{ scheduledFor: 'asc' }, { dueAt: 'asc' }], + take: 3000, + }); + return { + days: this.stats.toCalendarDays(rows, from, to), + tasks: rows.map(toTaskDto), + }; + } + + async byEntity(actor: Actor, q: RoutineEntityQueryDto): Promise { + const or: Prisma.RoutineTaskWhereInput[] = []; + if (q.leadId) or.push({ leadId: q.leadId }); + if (q.personId) or.push({ personId: q.personId }); + if (q.companyId) or.push({ companyId: q.companyId }); + if (q.dealId) or.push({ dealId: q.dealId }); + if (!or.length) return []; + const scope = await this.scope(actor); + const rows = await this.prisma.routineTask.findMany({ + where: { + organizationId: actor.organizationId, + OR: or, + status: { not: 'cancelled' }, + ...assigneeFilter(scope), + }, + include: routineTaskInclude, + orderBy: [{ scheduledFor: 'desc' }, { dueAt: 'desc' }], + take: 100, + }); + return rows.map(toTaskDto); + } + + async detail(actor: Actor, id: string): Promise { + const scope = await this.scope(actor); + const row = await this.findTask(actor.organizationId, id); + if (!canView(scope, row.assigneeId)) throw new ForbiddenException('دسترسی ندارید'); + const events = await this.prisma.routineTaskEvent.findMany({ + where: { taskId: id }, + include: { actor: { select: { id: true, fullName: true } } }, + orderBy: { createdAt: 'asc' }, + }); + return { ...toTaskDto(row), events: events.map(toEventDto) }; + } + + /* ───────── ایجاد / ویرایش ───────── */ + + async create(actor: Actor, dto: CreateRoutineTaskDto): Promise { + const scope = await this.scope(actor); + const assigneeId = dto.assigneeId || actor.userId; + if (assigneeId !== actor.userId && !canManage(scope, assigneeId)) { + throw new ForbiddenException('اجازه ثبت کار برای این کاربر را ندارید'); + } + await this.assertMember(actor.organizationId, assigneeId); + await this.assertRefs(actor.organizationId, dto); + + const { scheduledFor, dueAt } = this.resolveWhen(dto.dueAt, dto.scheduledFor); + const source: RoutineTaskSource = + dto.source ?? + (dto.activityId + ? 'follow_up' + : dto.leadId + ? 'lead' + : dto.personId || dto.companyId + ? 'customer' + : dto.dealId + ? 'deal' + : 'manual'); + const reminderAt = dto.dueAt ? await this.defaultReminder(actor.organizationId, assigneeId, dueAt) : null; + + const row = await this.prisma.routineTask.create({ + data: { + organizationId: actor.organizationId, + assigneeId, + title: dto.title.trim(), + description: dto.description?.trim() || null, + priority: dto.priority ?? 'medium', + categoryId: dto.categoryId || null, + isMandatory: dto.isMandatory ?? false, + source, + scheduledFor: dayKeyToDate(scheduledFor), + dueAt, + reminderAt, + status: dueAt.getTime() < Date.now() ? 'overdue' : 'todo', + leadId: dto.leadId || null, + personId: dto.personId || null, + companyId: dto.companyId || null, + dealId: dto.dealId || null, + invoiceId: dto.invoiceId || null, + activityId: dto.activityId || null, + createdById: actor.userId, + events: { + create: { + organizationId: actor.organizationId, + actorId: actor.userId, + type: 'created', + toStatus: 'todo', + }, + }, + }, + include: routineTaskInclude, + }); + + if (assigneeId !== actor.userId) { + await this.notifications.create({ + organizationId: actor.organizationId, + userId: assigneeId, + type: 'routine_task_assigned', + title: 'کار جدید برای شما ثبت شد', + body: row.title, + link: `/routines?task=${row.id}`, + entityType: 'routine_task', + entityId: row.id, + }); + } + 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 update(actor: Actor, id: string, dto: UpdateRoutineTaskDto): Promise { + const { scope, row } = await this.loadForEdit(actor, id); + await this.assertRefs(actor.organizationId, dto); + const data: Prisma.RoutineTaskUncheckedUpdateInput = {}; + const events: Array<{ type: string; note?: string }> = []; + + if (dto.title !== undefined) data.title = dto.title.trim(); + if (dto.description !== undefined) data.description = dto.description?.trim() || null; + if (dto.priority) data.priority = dto.priority; + if (dto.categoryId !== undefined) data.categoryId = dto.categoryId || null; + if (dto.isMandatory !== undefined) data.isMandatory = dto.isMandatory; + for (const key of ['leadId', 'personId', 'companyId', 'dealId', 'invoiceId'] as const) { + if (dto[key] !== undefined) data[key] = dto[key] || null; + } + if (dto.assigneeId && dto.assigneeId !== row.assigneeId) { + if (!canManage(scope, dto.assigneeId)) { + throw new ForbiddenException('اجازه ارجاع به این کاربر را ندارید'); + } + await this.assertMember(actor.organizationId, dto.assigneeId); + data.assigneeId = dto.assigneeId; + events.push({ type: 'reassigned' }); + } + if (dto.dueAt !== undefined || dto.scheduledFor !== undefined) { + const when = this.resolveWhen(dto.dueAt, dto.scheduledFor); + data.scheduledFor = dayKeyToDate(when.scheduledFor); + data.dueAt = when.dueAt; + data.reminderAt = await this.defaultReminder( + actor.organizationId, + (data.assigneeId as string) ?? row.assigneeId, + when.dueAt, + ); + data.reminderSentAt = null; + if (row.status === 'overdue' && when.dueAt.getTime() > Date.now()) data.status = 'todo'; + events.push({ type: 'rescheduled' }); + } + if (!events.length) events.push({ type: 'updated' }); + + const updated = await this.saveWithEvents(actor, row, data, events); + return toTaskDto(updated); + } + + /* ───────── عملیات وضعیت ───────── */ + + async complete(actor: Actor, id: string, note?: string | null) { + const { row } = await this.loadForEdit(actor, id); + if (row.status === 'completed') { + if (!note?.trim()) return toTaskDto(row); + const updated = await this.saveWithEvents(actor, row, { resultNote: note.trim() }, [ + { type: 'note', note: note.trim() }, + ]); + return toTaskDto(updated); + } + const rules = toTaskDto(row).rules; + if (row.status === 'cancelled') throw new BadRequestException('کار لغو شده قابل انجام نیست'); + if (row.status === 'overdue' && !rules.allowAfterDeadline) { + throw new BadRequestException('انجام این کار بعد از مهلت مجاز نیست'); + } + if (rules.reportRequired && !note?.trim()) { + throw new BadRequestException('ثبت گزارش/نتیجه برای این کار الزامی است'); + } + const updated = await this.saveWithEvents( + actor, + row, + { status: 'completed', completedAt: new Date(), resultNote: note?.trim() || row.resultNote }, + [{ type: 'status_changed', note: note?.trim() || undefined }], + ); + return toTaskDto(updated); + } + + async uncomplete(actor: Actor, id: string) { + const { row } = await this.loadForEdit(actor, id); + if (!['completed', 'skipped', 'cancelled'].includes(row.status)) return toTaskDto(row); + const status: RoutineTaskStatus = + row.dueAt && row.dueAt.getTime() < Date.now() ? 'overdue' : 'todo'; + const updated = await this.saveWithEvents( + actor, + row, + { status, completedAt: null, skipReason: null }, + [{ type: 'status_changed' }], + ); + return toTaskDto(updated); + } + + async start(actor: Actor, id: string) { + const { row } = await this.loadForEdit(actor, id); + if (row.status !== 'todo') return toTaskDto(row); + const updated = await this.saveWithEvents( + actor, + row, + { status: 'in_progress', startedAt: new Date() }, + [{ type: 'status_changed' }], + ); + return toTaskDto(updated); + } + + async skip(actor: Actor, id: string, reason?: string | null) { + const { row } = await this.loadForEdit(actor, id); + const rules = toTaskDto(row).rules; + if (!OPEN_STATUSES.includes(row.status)) throw new BadRequestException('این کار باز نیست'); + if (!rules.allowSkip) throw new BadRequestException('رد کردن این کار مجاز نیست'); + if (rules.skipReasonRequired && !reason?.trim()) { + throw new BadRequestException('ثبت دلیل رد کردن الزامی است'); + } + const updated = await this.saveWithEvents( + actor, + row, + { status: 'skipped', skipReason: reason?.trim() || null }, + [{ type: 'status_changed', note: reason?.trim() || undefined }], + ); + return toTaskDto(updated); + } + + async cancel(actor: Actor, id: string, reason?: string | null) { + const { row } = await this.loadForEdit(actor, id); + if (row.status === 'cancelled') return toTaskDto(row); + const updated = await this.saveWithEvents(actor, row, { status: 'cancelled' }, [ + { type: 'status_changed', note: reason?.trim() || undefined }, + ]); + return toTaskDto(updated); + } + + async reschedule(actor: Actor, id: string, dueAtIso: string) { + return this.update(actor, id, { dueAt: dueAtIso }); + } + + async moveToToday(actor: Actor, id: string) { + const { row } = await this.loadForEdit(actor, id); + const today = tehranDayKey(); + const time = row.dueAt + ? new Intl.DateTimeFormat('en-GB', { + timeZone: 'Asia/Tehran', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).format(row.dueAt) + : '23:59'; + let dueAt = tehranDateTime(today, time); + if (dueAt.getTime() < Date.now()) dueAt = tehranDateTime(today, '23:59'); + return this.update(actor, id, { dueAt: dueAt.toISOString() }); + } + + /* ───────── تنظیمات اعلان ───────── */ + + async getSettings(actor: Actor): Promise { + const row = await this.prisma.routineUserSettings.findUnique({ + where: { organizationId_userId: { organizationId: actor.organizationId, userId: actor.userId } }, + }); + return { + remindBeforeMinutes: row ? row.remindBeforeMinutes : DEFAULT_REMIND_MINUTES, + endOfDaySummary: row?.endOfDaySummary ?? true, + overdueAlert: row?.overdueAlert ?? true, + summaryHour: row?.summaryHour ?? 16, + }; + } + + async updateSettings(actor: Actor, dto: RoutineUserSettingsUpdateDto) { + const data = { + ...(dto.remindBeforeMinutes !== undefined ? { remindBeforeMinutes: dto.remindBeforeMinutes } : {}), + ...(dto.endOfDaySummary !== undefined ? { endOfDaySummary: dto.endOfDaySummary } : {}), + ...(dto.overdueAlert !== undefined ? { overdueAlert: dto.overdueAlert } : {}), + ...(dto.summaryHour !== undefined ? { summaryHour: dto.summaryHour } : {}), + }; + await this.prisma.routineUserSettings.upsert({ + where: { organizationId_userId: { organizationId: actor.organizationId, userId: actor.userId } }, + create: { organizationId: actor.organizationId, userId: actor.userId, ...data }, + update: data, + }); + return this.getSettings(actor); + } + + /* ───────── کمکی ───────── */ + + private async findTask(organizationId: string, id: string): Promise { + const row = await this.prisma.routineTask.findFirst({ + where: { id, organizationId }, + include: routineTaskInclude, + }); + if (!row) throw new NotFoundException('کار پیدا نشد'); + return row; + } + + private async loadForEdit(actor: Actor, id: string) { + const scope = await this.scope(actor); + const row = await this.findTask(actor.organizationId, id); + if (row.assigneeId !== actor.userId && !canManage(scope, row.assigneeId)) { + throw new ForbiddenException('اجازه تغییر این کار را ندارید'); + } + return { scope, row }; + } + + private async saveWithEvents( + actor: Actor, + row: RoutineTaskRow, + data: Prisma.RoutineTaskUncheckedUpdateInput, + events: Array<{ type: string; note?: string }>, + ): Promise { + const nextStatus = (data.status as RoutineTaskStatus | undefined) ?? row.status; + try { + const [updated] = await this.prisma.$transaction([ + this.prisma.routineTask.update({ + where: { id: row.id }, + data, + include: routineTaskInclude, + }), + this.prisma.routineTaskEvent.createMany({ + data: events.map((e) => ({ + organizationId: actor.organizationId, + taskId: row.id, + actorId: actor.userId, + type: e.type, + fromStatus: nextStatus !== row.status ? row.status : null, + toStatus: nextStatus !== row.status ? nextStatus : null, + note: e.note ?? null, + })), + }), + ]); + return updated; + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw new BadRequestException('این روتین برای آن روز کار دیگری دارد'); + } + throw err; + } + } + + private resolveWhen(dueAtIso?: string | null, scheduledFor?: string | null) { + if (dueAtIso) { + const dueAt = new Date(dueAtIso); + if (Number.isNaN(dueAt.getTime())) throw new BadRequestException('زمان نامعتبر است'); + return { dueAt, scheduledFor: tehranDayKey(dueAt) }; + } + const day = scheduledFor && isDayKey(scheduledFor) ? scheduledFor : tehranDayKey(); + return { dueAt: tehranDateTime(day, '23:59'), scheduledFor: day }; + } + + private async defaultReminder(organizationId: string, userId: string, dueAt: Date) { + const settings = await this.prisma.routineUserSettings.findUnique({ + where: { organizationId_userId: { organizationId, userId } }, + select: { remindBeforeMinutes: true }, + }); + const minutes = settings ? settings.remindBeforeMinutes : DEFAULT_REMIND_MINUTES; + return minutes != null ? new Date(dueAt.getTime() - minutes * 60_000) : null; + } + + private async assertMember(organizationId: string, userId: string) { + const m = await this.prisma.membership.findFirst({ + where: { organizationId, userId, isActive: true }, + select: { id: true }, + }); + if (!m) throw new BadRequestException('مسئول انتخاب‌شده عضو سازمان نیست'); + } + + private async assertRefs( + organizationId: string, + dto: { + categoryId?: string | null; + leadId?: string | null; + personId?: string | null; + companyId?: string | null; + dealId?: string | null; + invoiceId?: string | null; + activityId?: string | null; + }, + ) { + const checks: Array<[string | null | undefined, () => Promise, string]> = [ + [dto.categoryId, () => this.prisma.routineCategory.count({ where: { id: dto.categoryId!, organizationId } }), 'دسته‌بندی'], + [dto.leadId, () => this.prisma.lead.count({ where: { id: dto.leadId!, organizationId } }), 'لید'], + [dto.personId, () => this.prisma.person.count({ where: { id: dto.personId!, organizationId } }), 'شخص'], + [dto.companyId, () => this.prisma.company.count({ where: { id: dto.companyId!, organizationId } }), 'شرکت'], + [dto.dealId, () => this.prisma.deal.count({ where: { id: dto.dealId!, organizationId } }), 'معامله'], + [dto.invoiceId, () => this.prisma.invoice.count({ where: { id: dto.invoiceId!, organizationId } }), 'فاکتور'], + [dto.activityId, () => this.prisma.activity.count({ where: { id: dto.activityId!, organizationId } }), 'پیگیری'], + ]; + for (const [value, count, label] of checks) { + if (value && !(await count())) throw new BadRequestException(`${label} انتخاب‌شده معتبر نیست`); + } + } +} diff --git a/apps/api/src/routines/routines.controller.ts b/apps/api/src/routines/routines.controller.ts new file mode 100644 index 0000000..a64ad45 --- /dev/null +++ b/apps/api/src/routines/routines.controller.ts @@ -0,0 +1,143 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { OrgModuleGuard } from '../billing/org-module.guard'; +import { RequireOrgModule } from '../billing/org-module.decorator'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { Actor } from '../common/record-scope'; +import { RoutinesService } from './routines.service'; +import { RoutineStatsService } from './routine-stats.service'; +import { + ChangeRoutineAssigneeDto, + ListRoutinesQueryDto, + RoutineRangeQueryDto, + SaveRoutineCategoryDto, + SaveRoutineDto, + ToggleRoutineDto, +} from './dto'; + +@Controller('routines') +@UseGuards(JwtAuthGuard, OrgModuleGuard) +@RequireOrgModule('routines') +export class RoutinesController { + constructor( + private readonly routines: RoutinesService, + private readonly stats: RoutineStatsService, + ) {} + + @Get('context') + context(@CurrentUser() user: Actor) { + return this.routines.context(user); + } + + @Get('categories') + categories(@CurrentUser() user: Actor) { + return this.routines.categories(user.organizationId); + } + + @Post('categories') + createCategory(@CurrentUser() user: Actor, @Body() dto: SaveRoutineCategoryDto) { + return this.routines.createCategory(user, dto); + } + + @Patch('categories/:id') + updateCategory( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() dto: SaveRoutineCategoryDto, + ) { + return this.routines.updateCategory(user, id, dto); + } + + @Delete('categories/:id') + deleteCategory(@CurrentUser() user: Actor, @Param('id') id: string) { + return this.routines.deleteCategory(user, id); + } + + /* ───────── آمار و گزارش ───────── */ + + @Get('stats/me') + async statsMe(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) { + return this.stats.performance(await this.routines.scope(user), q); + } + + @Get('stats/team') + async statsTeam(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) { + return this.stats.team(await this.routines.scope(user), q); + } + + @Get('stats/weekly') + async statsWeekly(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) { + return this.stats.weekly(await this.routines.scope(user), q); + } + + @Get('stats/monthly') + async statsMonthly(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) { + return this.stats.monthly(await this.routines.scope(user), q); + } + + @Get('stats/routines') + async statsRoutines(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) { + return this.stats.routinesExecution(await this.routines.scope(user), q); + } + + @Get('stats/history') + async statsHistory(@CurrentUser() user: Actor, @Query() q: RoutineRangeQueryDto) { + return this.stats.history(await this.routines.scope(user), q); + } + + /* ───────── روتین‌ها ───────── */ + + @Get() + list(@CurrentUser() user: Actor, @Query() q: ListRoutinesQueryDto) { + return this.routines.list(user, q); + } + + @Post() + create(@CurrentUser() user: Actor, @Body() dto: SaveRoutineDto) { + return this.routines.create(user, dto); + } + + @Get(':id') + get(@CurrentUser() user: Actor, @Param('id') id: string) { + return this.routines.get(user, id); + } + + @Patch(':id') + update(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: SaveRoutineDto) { + return this.routines.update(user, id, dto); + } + + @Patch(':id/toggle') + toggle(@CurrentUser() user: Actor, @Param('id') id: string, @Body() dto: ToggleRoutineDto) { + return this.routines.toggle(user, id, dto.isActive); + } + + @Patch(':id/assignee') + assignee( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() dto: ChangeRoutineAssigneeDto, + ) { + return this.routines.changeAssignee(user, id, dto); + } + + @Post(':id/duplicate') + duplicate(@CurrentUser() user: Actor, @Param('id') id: string) { + return this.routines.duplicate(user, id); + } + + @Delete(':id') + remove(@CurrentUser() user: Actor, @Param('id') id: string) { + return this.routines.remove(user, id); + } +} diff --git a/apps/api/src/routines/routines.module.ts b/apps/api/src/routines/routines.module.ts new file mode 100644 index 0000000..6d65819 --- /dev/null +++ b/apps/api/src/routines/routines.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { RoutinesController } from './routines.controller'; +import { RoutineTasksController } from './routine-tasks.controller'; +import { RoutinesService } from './routines.service'; +import { RoutineTasksService } from './routine-tasks.service'; +import { RoutineStatsService } from './routine-stats.service'; +import { RoutineGeneratorService } from './routine-generator.service'; +import { RoutineRemindersService } from './routine-reminders.service'; + +@Module({ + imports: [NotificationsModule], + controllers: [RoutinesController, RoutineTasksController], + providers: [ + RoutinesService, + RoutineTasksService, + RoutineStatsService, + RoutineGeneratorService, + RoutineRemindersService, + ], + exports: [RoutineTasksService], +}) +export class RoutinesModule {} diff --git a/apps/api/src/routines/routines.service.ts b/apps/api/src/routines/routines.service.ts new file mode 100644 index 0000000..98b6cea --- /dev/null +++ b/apps/api/src/routines/routines.service.ts @@ -0,0 +1,498 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { + RoutineCategoryDto, + RoutineContextDto, + RoutineDto, +} from '@mento/shared'; +import type { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import type { Actor } from '../common/record-scope'; +import { elevated } from '../common/record-scope'; +import { + ChangeRoutineAssigneeDto, + ListRoutinesQueryDto, + SaveRoutineCategoryDto, + SaveRoutineDto, +} from './dto'; +import { RoutineGeneratorService } from './routine-generator.service'; +import { routineInclude, toCategoryDto, toRoutineDto } from './routine-mapper'; +import { + type RoutineScope, + assertManager, + canManage, + resolveRoutineScope, +} from './routine-scope'; +import { addDays, dayKeyToDate, tehranDateTime, tehranDayKey } from './routine-dates'; + +const DEFAULT_CATEGORIES: Array<{ name: string; color: string }> = [ + { name: 'فروش', color: '#14b8a6' }, + { name: 'پیگیری', color: '#3b82f6' }, + { name: 'لید', color: '#8b5cf6' }, + { name: 'مشتری', color: '#f59e0b' }, + { name: 'گزارش‌دهی', color: '#f43f5e' }, +]; + +@Injectable() +export class RoutinesService { + constructor( + private readonly prisma: PrismaService, + private readonly generator: RoutineGeneratorService, + ) {} + + scope(actor: Actor): Promise { + return resolveRoutineScope(this.prisma, actor); + } + + /* ───────── دسته‌بندی‌ها ───────── */ + + async categories(organizationId: string): Promise { + let rows = await this.prisma.routineCategory.findMany({ + where: { organizationId }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + }); + if (!rows.length) { + await this.prisma.routineCategory.createMany({ + data: DEFAULT_CATEGORIES.map((c, i) => ({ organizationId, ...c, sortOrder: i })), + skipDuplicates: true, + }); + rows = await this.prisma.routineCategory.findMany({ + where: { organizationId }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + }); + } + return rows.map(toCategoryDto); + } + + async createCategory(actor: Actor, dto: SaveRoutineCategoryDto) { + assertManager(await this.scope(actor)); + try { + const row = await this.prisma.routineCategory.create({ + data: { + organizationId: actor.organizationId, + name: dto.name.trim(), + color: dto.color || '#14b8a6', + sortOrder: dto.sortOrder ?? 99, + }, + }); + return toCategoryDto(row); + } catch { + throw new BadRequestException('دسته‌بندی با این نام وجود دارد'); + } + } + + async updateCategory(actor: Actor, id: string, dto: SaveRoutineCategoryDto) { + assertManager(await this.scope(actor)); + await this.findCategory(actor.organizationId, id); + const row = await this.prisma.routineCategory.update({ + where: { id }, + data: { + name: dto.name.trim(), + ...(dto.color ? { color: dto.color } : {}), + ...(dto.sortOrder != null ? { sortOrder: dto.sortOrder } : {}), + }, + }); + return toCategoryDto(row); + } + + async deleteCategory(actor: Actor, id: string) { + assertManager(await this.scope(actor)); + await this.findCategory(actor.organizationId, id); + await this.prisma.routineCategory.delete({ where: { id } }); + return { ok: true }; + } + + private async findCategory(organizationId: string, id: string) { + const row = await this.prisma.routineCategory.findFirst({ where: { id, organizationId } }); + if (!row) throw new NotFoundException('دسته‌بندی پیدا نشد'); + return row; + } + + /* ───────── زمینه (کاربران، تیم‌ها) ───────── */ + + async context(actor: Actor): Promise { + const scope = await this.scope(actor); + const ids = scope.isManager ? scope.manageableUserIds : scope.viewableUserIds; + const [members, teams, categories] = await Promise.all([ + this.prisma.membership.findMany({ + where: { + organizationId: actor.organizationId, + isActive: true, + user: { isActive: true }, + ...(ids ? { userId: { in: ids } } : {}), + }, + select: { user: { select: { id: true, fullName: true } } }, + orderBy: { user: { fullName: 'asc' } }, + }), + scope.isManager + ? this.prisma.team.findMany({ + where: { + organizationId: actor.organizationId, + ...(elevated(actor.role) + ? {} + : { members: { some: { userId: actor.userId, role: 'manager' } } }), + }, + select: { id: true, name: true }, + orderBy: { name: 'asc' }, + }) + : Promise.resolve([]), + this.categories(actor.organizationId), + ]); + return { + isManager: scope.isManager, + users: members.map((m) => m.user), + teams, + categories, + }; + } + + /* ───────── روتین‌ها ───────── */ + + async list(actor: Actor, query: ListRoutinesQueryDto): Promise { + const scope = await this.scope(actor); + const where: Prisma.RoutineWhereInput = { + organizationId: actor.organizationId, + deletedAt: null, + }; + if (query.scope === 'all') { + assertManager(scope); + if (scope.manageableUserIds) { + where.OR = [ + { assigneeId: { in: scope.manageableUserIds } }, + { team: { members: { some: { userId: actor.userId, role: 'manager' } } } }, + ]; + } + } else { + where.OR = [ + { assigneeId: actor.userId }, + { assigneeId: null, team: { members: { some: { userId: actor.userId } } } }, + ]; + where.isActive = true; + } + if (query.q?.trim()) { + where.title = { contains: query.q.trim(), mode: 'insensitive' }; + } + const rows = await this.prisma.routine.findMany({ + where, + include: routineInclude, + orderBy: [{ isActive: 'desc' }, { timeOfDay: 'asc' }, { createdAt: 'desc' }], + }); + const execution = await this.executionMap( + rows.map((r) => r.id), + query.scope === 'all' ? undefined : actor.userId, + ); + return rows.map((r) => toRoutineDto(r, execution.get(r.id))); + } + + async get(actor: Actor, id: string): Promise { + const scope = await this.scope(actor); + const row = await this.findRoutine(actor.organizationId, id); + const isOwnRoutine = row.assigneeId === actor.userId; + if (!isOwnRoutine) assertManager(scope); + const execution = await this.executionMap([row.id]); + return toRoutineDto(row, execution.get(row.id)); + } + + async create(actor: Actor, dto: SaveRoutineDto): Promise { + const scope = await this.scope(actor); + assertManager(scope); + const data = await this.buildData(actor, scope, dto); + const row = await this.prisma.routine.create({ + data: { + ...data, + organizationId: actor.organizationId, + createdById: actor.userId, + } as Prisma.RoutineUncheckedCreateInput, + include: routineInclude, + }); + await this.generator.generateForDay(tehranDayKey(), { routineId: row.id }); + return toRoutineDto(row); + } + + async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise { + const scope = await this.scope(actor); + assertManager(scope); + const existing = await this.findRoutine(actor.organizationId, id); + this.assertCanManageRoutine(scope, existing); + const data = await this.buildData(actor, scope, dto); + const row = await this.prisma.routine.update({ + where: { id }, + data: data as Prisma.RoutineUncheckedUpdateInput, + include: routineInclude, + }); + await this.syncTodayTasks(row.id); + const execution = await this.executionMap([row.id]); + return toRoutineDto(row, execution.get(row.id)); + } + + async toggle(actor: Actor, id: string, isActive: boolean): Promise { + const scope = await this.scope(actor); + assertManager(scope); + const existing = await this.findRoutine(actor.organizationId, id); + this.assertCanManageRoutine(scope, existing); + const row = await this.prisma.routine.update({ + where: { id }, + data: { isActive }, + include: routineInclude, + }); + await this.syncTodayTasks(row.id); + const execution = await this.executionMap([row.id]); + return toRoutineDto(row, execution.get(row.id)); + } + + async changeAssignee( + actor: Actor, + id: string, + dto: ChangeRoutineAssigneeDto, + ): Promise { + const scope = await this.scope(actor); + assertManager(scope); + const existing = await this.findRoutine(actor.organizationId, id); + this.assertCanManageRoutine(scope, existing); + const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId); + await this.cancelTodayPending(id, 'تغییر مسئول روتین'); + const row = await this.prisma.routine.update({ + where: { id }, + data: target, + include: routineInclude, + }); + await this.generator.generateForDay(tehranDayKey(), { routineId: id }); + const execution = await this.executionMap([row.id]); + return toRoutineDto(row, execution.get(row.id)); + } + + async duplicate(actor: Actor, id: string): Promise { + const scope = await this.scope(actor); + assertManager(scope); + const src = await this.findRoutine(actor.organizationId, id); + this.assertCanManageRoutine(scope, src); + const { + id: _id, + createdAt: _c, + updatedAt: _u, + category: _cat, + assignee: _a, + team: _t, + deletedAt: _d, + ...rest + } = src; + const row = await this.prisma.routine.create({ + data: { + ...rest, + title: `${src.title} (کپی)`.slice(0, 200), + isActive: false, + createdById: actor.userId, + }, + include: routineInclude, + }); + return toRoutineDto(row); + } + + async remove(actor: Actor, id: string) { + const scope = await this.scope(actor); + assertManager(scope); + const existing = await this.findRoutine(actor.organizationId, id); + this.assertCanManageRoutine(scope, existing); + await this.prisma.routine.update({ + where: { id }, + data: { deletedAt: new Date(), isActive: false }, + }); + await this.cancelTodayPending(id, 'حذف روتین'); + return { ok: true }; + } + + /* ───────── کمکی ───────── */ + + /** درصد اجرای روتین در ۳۰ روز اخیر (Taskهای در انتظار امروز حساب نمی‌شوند) */ + async executionMap( + routineIds: string[], + assigneeId?: string, + ): Promise> { + const map = new Map(); + if (!routineIds.length) return map; + const today = tehranDayKey(); + const groups = await this.prisma.routineTask.groupBy({ + by: ['routineId', 'status'], + where: { + routineId: { in: routineIds }, + scheduledFor: { gte: dayKeyToDate(addDays(today, -29)), lte: dayKeyToDate(today) }, + status: { in: ['completed', 'overdue', 'skipped'] }, + ...(assigneeId ? { assigneeId } : {}), + }, + _count: { _all: true }, + }); + for (const g of groups) { + if (!g.routineId) continue; + const cur = map.get(g.routineId) ?? { total: 0, done: 0 }; + cur.total += g._count._all; + if (g.status === 'completed') cur.done += g._count._all; + map.set(g.routineId, cur); + } + return map; + } + + private async findRoutine(organizationId: string, id: string) { + const row = await this.prisma.routine.findFirst({ + where: { id, organizationId, deletedAt: null }, + include: routineInclude, + }); + if (!row) throw new NotFoundException('روتین پیدا نشد'); + return row; + } + + private assertCanManageRoutine( + scope: RoutineScope, + row: { assigneeId: string | null; teamId: string | null; createdById: string | null }, + ) { + if (scope.manageableUserIds === null) return; + if (row.assigneeId && canManage(scope, row.assigneeId)) return; + if (row.createdById === scope.userId) return; + throw new ForbiddenException('اجازه مدیریت این روتین را ندارید'); + } + + private async resolveTarget( + actor: Actor, + scope: RoutineScope, + assigneeId?: string | null, + teamId?: string | null, + ): Promise<{ assigneeId: string | null; teamId: string | null }> { + if (assigneeId) { + if (!canManage(scope, assigneeId)) { + throw new ForbiddenException('اجازه تعریف روتین برای این کاربر را ندارید'); + } + const member = await this.prisma.membership.findFirst({ + where: { organizationId: actor.organizationId, userId: assigneeId, isActive: true }, + select: { id: true }, + }); + if (!member) throw new BadRequestException('مسئول انتخاب‌شده عضو سازمان نیست'); + return { assigneeId, teamId: null }; + } + if (teamId) { + const team = await this.prisma.team.findFirst({ + where: { + id: teamId, + organizationId: actor.organizationId, + ...(elevated(actor.role) + ? {} + : { members: { some: { userId: actor.userId, role: 'manager' } } }), + }, + select: { id: true }, + }); + if (!team) throw new BadRequestException('تیم انتخاب‌شده معتبر نیست'); + return { assigneeId: null, teamId }; + } + throw new BadRequestException('مسئول یا تیم روتین را انتخاب کنید'); + } + + private async buildData(actor: Actor, scope: RoutineScope, dto: SaveRoutineDto) { + const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId); + if (dto.categoryId) await this.findCategory(actor.organizationId, dto.categoryId); + if (dto.endDate && dto.endDate < dto.startDate) { + throw new BadRequestException('تاریخ پایان نباید قبل از تاریخ شروع باشد'); + } + const weekdays = [...new Set(dto.weekdays ?? [])].sort((a, b) => a - b); + const specificDates = [...new Set(dto.specificDates ?? [])].sort(); + if (['weekdays', 'biweekly'].includes(dto.recurrence) && !weekdays.length) { + throw new BadRequestException('حداقل یک روز هفته را انتخاب کنید'); + } + if (dto.recurrence === 'every_n_days' && !dto.intervalDays) { + throw new BadRequestException('فاصله تکرار (روز) را وارد کنید'); + } + if (dto.recurrence === 'specific_dates' && !specificDates.length) { + throw new BadRequestException('حداقل یک تاریخ انتخاب کنید'); + } + return { + title: dto.title.trim(), + description: dto.description?.trim() || null, + ...target, + categoryId: dto.categoryId || null, + priority: dto.priority ?? 'medium', + timeOfDay: dto.timeOfDay, + durationMinutes: dto.durationMinutes ?? null, + startDate: dayKeyToDate(dto.startDate), + endDate: dto.endDate ? dayKeyToDate(dto.endDate) : null, + recurrence: dto.recurrence, + weekdays, + intervalDays: dto.recurrence === 'every_n_days' ? dto.intervalDays ?? 1 : null, + monthDay: dto.recurrence === 'monthly' ? dto.monthDay ?? null : null, + specificDates: dto.recurrence === 'specific_dates' ? specificDates.map(dayKeyToDate) : [], + isMandatory: dto.isMandatory ?? true, + allowSkip: dto.allowSkip ?? false, + skipReasonRequired: dto.skipReasonRequired ?? true, + reportRequired: dto.reportRequired ?? false, + allowAfterDeadline: dto.allowAfterDeadline ?? true, + carryOverOverdue: dto.carryOverOverdue ?? false, + entityLink: dto.entityLink ?? 'none', + reminderMinutesBefore: dto.reminderMinutesBefore ?? null, + isActive: dto.isActive ?? true, + }; + } + + /** همگام کردن Task امروزِ هنوز شروع‌نشده با تغییرات روتین */ + private async syncTodayTasks(routineId: string) { + const routine = await this.prisma.routine.findUnique({ where: { id: routineId } }); + if (!routine) return; + if (!routine.isActive || routine.deletedAt) { + await this.cancelTodayPending(routineId, 'غیرفعال شدن روتین'); + return; + } + const today = tehranDayKey(); + const pending = await this.prisma.routineTask.findMany({ + where: { routineId, scheduledFor: dayKeyToDate(today), status: 'todo' }, + select: { id: true }, + }); + if (pending.length) { + const dueAt = tehranDateTime(today, routine.timeOfDay); + await this.prisma.routineTask.updateMany({ + where: { id: { in: pending.map((p) => p.id) } }, + data: { + title: routine.title, + description: routine.description, + categoryId: routine.categoryId, + priority: routine.priority, + isMandatory: routine.isMandatory, + dueAt, + ...(routine.reminderMinutesBefore != null + ? { + reminderAt: new Date(dueAt.getTime() - routine.reminderMinutesBefore * 60_000), + reminderSentAt: null, + } + : {}), + }, + }); + } + await this.generator.generateForDay(today, { routineId }); + } + + private async cancelTodayPending(routineId: string, note: string) { + const rows = await this.prisma.routineTask.findMany({ + where: { + routineId, + scheduledFor: dayKeyToDate(tehranDayKey()), + status: { in: ['todo', 'in_progress'] }, + }, + select: { id: true, organizationId: true, status: true }, + }); + if (!rows.length) return; + await this.prisma.$transaction([ + this.prisma.routineTask.updateMany({ + where: { id: { in: rows.map((r) => r.id) } }, + data: { status: 'cancelled' }, + }), + this.prisma.routineTaskEvent.createMany({ + data: rows.map((r) => ({ + organizationId: r.organizationId, + taskId: r.id, + type: 'status_changed', + fromStatus: r.status, + toStatus: 'cancelled' as const, + note, + })), + }), + ]); + } +} diff --git a/apps/web/src/components/routines/ActivityHistoryList.vue b/apps/web/src/components/routines/ActivityHistoryList.vue new file mode 100644 index 0000000..5b18793 --- /dev/null +++ b/apps/web/src/components/routines/ActivityHistoryList.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/apps/web/src/components/routines/DailyScoreCard.vue b/apps/web/src/components/routines/DailyScoreCard.vue new file mode 100644 index 0000000..bc1e6c9 --- /dev/null +++ b/apps/web/src/components/routines/DailyScoreCard.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/apps/web/src/components/routines/EntityLinkPicker.vue b/apps/web/src/components/routines/EntityLinkPicker.vue new file mode 100644 index 0000000..71a0a50 --- /dev/null +++ b/apps/web/src/components/routines/EntityLinkPicker.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/apps/web/src/components/routines/KpiCard.vue b/apps/web/src/components/routines/KpiCard.vue new file mode 100644 index 0000000..73de070 --- /dev/null +++ b/apps/web/src/components/routines/KpiCard.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/apps/web/src/components/routines/MonthlyReport.vue b/apps/web/src/components/routines/MonthlyReport.vue new file mode 100644 index 0000000..d067c9b --- /dev/null +++ b/apps/web/src/components/routines/MonthlyReport.vue @@ -0,0 +1,269 @@ + + + + + diff --git a/apps/web/src/components/routines/QuickTaskModal.vue b/apps/web/src/components/routines/QuickTaskModal.vue new file mode 100644 index 0000000..387aa06 --- /dev/null +++ b/apps/web/src/components/routines/QuickTaskModal.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/apps/web/src/components/routines/RecurrencePicker.vue b/apps/web/src/components/routines/RecurrencePicker.vue new file mode 100644 index 0000000..7f09704 --- /dev/null +++ b/apps/web/src/components/routines/RecurrencePicker.vue @@ -0,0 +1,301 @@ + + + + + diff --git a/apps/web/src/components/routines/RelatedTasksPanel.vue b/apps/web/src/components/routines/RelatedTasksPanel.vue new file mode 100644 index 0000000..0a097b0 --- /dev/null +++ b/apps/web/src/components/routines/RelatedTasksPanel.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/apps/web/src/components/routines/RoutineMonthGrid.vue b/apps/web/src/components/routines/RoutineMonthGrid.vue new file mode 100644 index 0000000..4a5fb9c --- /dev/null +++ b/apps/web/src/components/routines/RoutineMonthGrid.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/apps/web/src/components/routines/RoutineTaskDrawer.vue b/apps/web/src/components/routines/RoutineTaskDrawer.vue new file mode 100644 index 0000000..1ba10e1 --- /dev/null +++ b/apps/web/src/components/routines/RoutineTaskDrawer.vue @@ -0,0 +1,663 @@ + + + + + diff --git a/apps/web/src/components/routines/RoutineTaskModals.vue b/apps/web/src/components/routines/RoutineTaskModals.vue new file mode 100644 index 0000000..146cc04 --- /dev/null +++ b/apps/web/src/components/routines/RoutineTaskModals.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/apps/web/src/components/routines/RoutineTaskRow.vue b/apps/web/src/components/routines/RoutineTaskRow.vue new file mode 100644 index 0000000..5596142 --- /dev/null +++ b/apps/web/src/components/routines/RoutineTaskRow.vue @@ -0,0 +1,440 @@ + + + + + diff --git a/apps/web/src/components/routines/TeamPerformanceTable.vue b/apps/web/src/components/routines/TeamPerformanceTable.vue new file mode 100644 index 0000000..b0eee3f --- /dev/null +++ b/apps/web/src/components/routines/TeamPerformanceTable.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/apps/web/src/components/routines/WeeklyReport.vue b/apps/web/src/components/routines/WeeklyReport.vue new file mode 100644 index 0000000..eee29b3 --- /dev/null +++ b/apps/web/src/components/routines/WeeklyReport.vue @@ -0,0 +1,301 @@ + + + + + diff --git a/apps/web/src/components/ui/UiDrawer.vue b/apps/web/src/components/ui/UiDrawer.vue new file mode 100644 index 0000000..50f4249 --- /dev/null +++ b/apps/web/src/components/ui/UiDrawer.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts index 2b05be6..2087dad 100644 --- a/apps/web/src/components/ui/index.ts +++ b/apps/web/src/components/ui/index.ts @@ -5,6 +5,7 @@ export { default as UiSelect } from './UiSelect.vue'; export { default as UiField } from './UiField.vue'; export { default as UiCard } from './UiCard.vue'; export { default as UiModal } from './UiModal.vue'; +export { default as UiDrawer } from './UiDrawer.vue'; export { default as UiBusyOverlay } from './UiBusyOverlay.vue'; export { default as UiPageHeader } from './UiPageHeader.vue'; export { default as UiBadge } from './UiBadge.vue'; diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index a526fe8..05e8069 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -38,6 +38,7 @@ const hubStack = ref([]); const isMobileNav = ref(false); const globalSearch = ref(''); const activityBadge = ref(0); +const routineBadge = ref(0); function pathIn(prefixes: string[]) { return prefixes.some( @@ -67,6 +68,7 @@ const salesActive = computed(() => ); const activitiesActive = computed(() => pathIn(['/activities'])); const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/')); +const routinesActive = computed(() => pathIn(['/routines'])); const conversationsActive = computed(() => pathIn(['/conversations'])); const supportActive = computed(() => pathIn(['/tickets'])); const automationsActive = computed( @@ -100,6 +102,7 @@ const settingsActive = computed(() => ); const moreSectionActive = computed( () => + routinesActive.value || notesActive.value || conversationsActive.value || supportActive.value || @@ -285,6 +288,16 @@ async function loadActivityBadge() { } } +async function loadRoutineBadge() { + if (!moduleAllowed('routines')) return; + try { + const { data } = await api.get<{ remaining: number; overdue: number }>('/routine-tasks/badge'); + routineBadge.value = data.remaining + data.overdue; + } catch { + routineBadge.value = 0; + } +} + async function loadChatBadge() { if (!moduleAllowed('conversations')) return; try { @@ -374,9 +387,11 @@ onMounted(() => { } void refreshNotifs(); void loadActivityBadge(); + void loadRoutineBadge(); void loadChatBadge(); pollTimer = setInterval(() => { void refreshNotifs(); + void loadRoutineBadge(); void refreshUnreadTotal().catch(() => undefined); }, 45000); mobileMq = window.matchMedia('(max-width: 900px)'); @@ -510,6 +525,19 @@ onUnmounted(() => { + + + کارهای روتین + {{ toFaDigits(routineBadge) }} + + { :title="`انقضا: ${subscription.expiresAt}`" > - + اشتراک دائمی + {{ toFaDigits(subscription.daysRemaining) }} روز باقی‌مانده @@ -858,6 +887,17 @@ onUnmounted(() => {