feat: routines module; super admin subscription badge, 7-day trial and lifetime plan

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-06 19:54:38 +03:30
co-authored by Cursor
parent 3de2d0fa67
commit b3784818fc
58 changed files with 13018 additions and 18 deletions
@@ -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"));
+207 -1
View File
@@ -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])
}
+2
View File
@@ -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 {}
+5 -3
View File
@@ -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,
+2 -2
View File
@@ -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()
+18 -2
View File
@@ -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<PlatformOrganizationDto> {
@@ -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 {
+311
View File
@@ -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;
}
+101
View File
@@ -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 '';
}
}
+100
View File
@@ -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)]!);
}
@@ -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<void> {
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<number> {
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<string>();
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<Map<string, number | null>> {
const map = new Map<string, number | null>();
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<void> {
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;
}
}
}
+195
View File
@@ -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(),
};
}
@@ -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<void> {
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}`,
});
}
}
}
+79
View File
@@ -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<RoutineScope> {
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 } };
}
@@ -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<RoutineScoreDto> {
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<Map<string, number>> {
const out = new Map<string, number>();
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<string, Map<string, { total: number; ok: number }>>();
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<string, Counts>();
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<RoutinePerformanceDto> {
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<RoutineTeamPerformanceDto> {
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<RoutineWeeklyReportDto> {
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<RoutineMonthlyReportDto> {
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<RoutineExecutionRowDto[]> {
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<string, Counts>();
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<RoutineHistoryDayDto[]> {
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<StatTask[]> {
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<string | null, Counts>();
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<RoutineMemberStatDto[]> {
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);
}
}
@@ -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);
}
}
@@ -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<RoutineScope> {
return resolveRoutineScope(this.prisma, actor);
}
/* ───────── خواندن ───────── */
async today(actor: Actor, userId?: string, date?: string): Promise<RoutineTodayDto> {
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<RoutineTaskDto[]> {
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<RoutineCalendarDto> {
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<RoutineTaskDto[]> {
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<RoutineTaskDetailDto> {
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<RoutineTaskDto> {
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<RoutineTaskDto> {
const activity = await this.prisma.activity.findFirst({
where: { id: activityId, organizationId: actor.organizationId },
select: {
id: true,
title: true,
description: true,
dueAt: true,
priority: true,
personId: true,
companyId: true,
dealId: true,
assigneeId: true,
},
});
if (!activity) throw new NotFoundException('پیگیری پیدا نشد');
const existing = await this.prisma.routineTask.findFirst({
where: { organizationId: actor.organizationId, activityId, status: { not: 'cancelled' } },
include: routineTaskInclude,
});
if (existing) return toTaskDto(existing);
const scope = await this.scope(actor);
const assigneeId =
activity.assigneeId && canManage(scope, activity.assigneeId)
? activity.assigneeId
: actor.userId;
return this.create(actor, {
title: activity.title,
description: activity.description,
dueAt: activity.dueAt?.toISOString() ?? null,
priority: activity.priority,
assigneeId,
personId: activity.personId,
companyId: activity.companyId,
dealId: activity.dealId,
activityId: activity.id,
source: 'follow_up',
});
}
async update(actor: Actor, id: string, dto: UpdateRoutineTaskDto): Promise<RoutineTaskDto> {
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<RoutineUserSettingsDto> {
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<RoutineTaskRow> {
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<RoutineTaskRow> {
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<number>, 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} انتخاب‌شده معتبر نیست`);
}
}
}
@@ -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);
}
}
+23
View File
@@ -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 {}
+498
View File
@@ -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<RoutineScope> {
return resolveRoutineScope(this.prisma, actor);
}
/* ───────── دسته‌بندی‌ها ───────── */
async categories(organizationId: string): Promise<RoutineCategoryDto[]> {
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<RoutineContextDto> {
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<RoutineDto[]> {
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<RoutineDto> {
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<RoutineDto> {
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<RoutineDto> {
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<RoutineDto> {
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<RoutineDto> {
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<RoutineDto> {
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<Map<string, { total: number; done: number }>> {
const map = new Map<string, { total: number; done: number }>();
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,
})),
}),
]);
}
}
@@ -0,0 +1,271 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import type { RoutineCalendarDto, RoutineHistoryDayDto, RoutineTaskDto } from '@mento/shared';
import { ROUTINE_TASK_STATUS_LABELS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiEmptyState } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
DAY_STATE_LABELS,
DAY_STATE_SYMBOLS,
apiError,
formatDayLong,
formatPercent,
formatTime,
} from '@/lib/routines';
const props = defineProps<{ userId?: string | null; refreshKey?: number }>();
const emit = defineEmits<{ open: [taskId: string] }>();
const days = ref<RoutineHistoryDayDto[]>([]);
const loading = ref(false);
const error = ref('');
const expanded = ref<string | null>(null);
const dayTasks = ref<Record<string, RoutineTaskDto[]>>({});
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<RoutineHistoryDayDto[]>('/routines/stats/history', {
params: props.userId ? { userId: props.userId } : {},
});
days.value = data;
dayTasks.value = {};
expanded.value = null;
} catch (e) {
error.value = apiError(e, 'بارگذاری تاریخچه ممکن نشد.');
} finally {
loading.value = false;
}
}
watch([() => props.userId, () => props.refreshKey], load, { immediate: true });
async function toggle(day: string) {
if (expanded.value === day) {
expanded.value = null;
return;
}
expanded.value = day;
if (dayTasks.value[day]) return;
try {
const { data } = await api.get<RoutineCalendarDto>('/routine-tasks/calendar', {
params: { from: day, to: day, ...(props.userId ? { userId: props.userId } : {}) },
});
dayTasks.value = { ...dayTasks.value, [day]: data.tasks };
} catch {
dayTasks.value = { ...dayTasks.value, [day]: [] };
}
}
</script>
<template>
<div class="history">
<p v-if="error" class="error">{{ error }}</p>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<UiEmptyState
v-else-if="!days.length"
icon="clock"
title="هنوز سابقه‌ای ثبت نشده"
description="تاریخچه انجام کارها در ۶۰ روز اخیر اینجا نمایش داده می‌شود."
/>
<ol v-else class="days">
<li v-for="d in days" :key="d.date" :class="`s-${d.state}`">
<button type="button" class="day" :aria-expanded="expanded === d.date" @click="toggle(d.date)">
<span class="sym" :title="DAY_STATE_LABELS[d.state]">{{ DAY_STATE_SYMBOLS[d.state] }}</span>
<span class="date">
<strong>{{ formatDayLong(d.date) }}</strong>
<small>
{{ toFaDigits(d.completed) }} از {{ toFaDigits(d.total) }} انجام شد
<template v-if="d.overdue"> · {{ toFaDigits(d.overdue) }} عقب‌افتاده</template>
<template v-if="d.skipped"> · {{ toFaDigits(d.skipped) }} رد شده</template>
</small>
</span>
<span class="pct">{{ formatPercent(d.percent) }}</span>
<UiIcon name="chevron" :size="16" class="chev" :class="{ open: expanded === d.date }" />
</button>
<ul v-if="expanded === d.date" class="tasks">
<li v-if="!dayTasks[d.date]" class="muted">در حال بارگذاری…</li>
<li
v-for="t in dayTasks[d.date] ?? []"
:key="t.id"
:class="`st-${t.status}`"
@click="emit('open', t.id)"
>
<span class="dot" />
<span class="t-title">{{ t.title }}</span>
<small>{{ ROUTINE_TASK_STATUS_LABELS[t.status] }}</small>
<small v-if="t.completedAt">ساعت {{ formatTime(t.completedAt) }}</small>
</li>
</ul>
</li>
</ol>
</div>
</template>
<style scoped>
.history {
display: grid;
gap: var(--sp-3);
}
.state {
padding: var(--sp-8);
text-align: center;
color: var(--muted);
}
.days {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--sp-2);
}
.days > li {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
overflow: hidden;
}
.day {
width: 100%;
display: grid;
grid-template-columns: 32px minmax(0, 1fr) auto 20px;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3);
border: 0;
background: transparent;
font: inherit;
text-align: right;
cursor: pointer;
}
.day:hover {
background: var(--surface-hover);
}
.sym {
width: 30px;
height: 30px;
border-radius: 8px;
display: grid;
place-items: center;
font-weight: var(--fw-bold);
background: var(--surface-subtle);
color: var(--muted);
}
.s-complete .sym {
background: var(--success-soft);
color: var(--success-deep);
}
.s-partial .sym {
background: var(--warning-soft);
color: var(--warning-deep);
}
.s-missed .sym {
background: var(--danger-soft);
color: var(--danger);
}
.date {
display: grid;
gap: 2px;
}
.date strong {
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.date small {
font-size: var(--fs-xs);
color: var(--muted);
}
.pct {
font-weight: var(--fw-bold);
color: var(--ink);
}
.chev {
color: var(--muted);
transition: transform var(--transition-fast);
}
.chev.open {
transform: rotate(180deg);
}
.tasks {
list-style: none;
margin: 0;
padding: 0 var(--sp-3) var(--sp-3);
display: grid;
gap: 4px;
}
.tasks li {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
padding: 8px 10px;
border-radius: 8px;
background: var(--surface-subtle);
font-size: var(--fs-sm);
cursor: pointer;
}
.tasks li:hover {
background: var(--accent-subtle);
}
.tasks li.muted {
cursor: default;
color: var(--muted);
}
.tasks small {
font-size: var(--fs-xs);
color: var(--muted);
}
.t-title {
flex: 1 1 auto;
min-width: 0;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--line-strong);
}
.st-completed .dot {
background: var(--success);
}
.st-overdue .dot {
background: var(--danger);
}
.st-skipped .dot,
.st-cancelled .dot {
background: var(--warning);
}
.error {
margin: 0;
}
</style>
@@ -0,0 +1,244 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { RoutineCalendarDayDto, RoutineScoreDto } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import {
DAY_STATE_LABELS,
DAY_STATE_SYMBOLS,
formatDayNumeric,
formatPercent,
percentTone,
weekdayIndex,
} from '@/lib/routines';
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
const props = defineProps<{
score: RoutineScoreDto | null;
/** روزهای اخیر برای نوار Streak (قدیمی → جدید) */
days?: RoutineCalendarDayDto[];
}>();
const rows = computed(() => {
const s = props.score;
return [
{ label: 'امروز', value: s?.today ?? null },
{ label: 'دیروز', value: s?.yesterday ?? null },
{ label: 'این هفته', value: s?.week ?? null },
{ label: 'این ماه', value: s?.month ?? null },
];
});
const strip = computed(() => props.days ?? []);
</script>
<template>
<section class="score-card">
<header>
<h3>امتیاز روزانه</h3>
<span class="streak" :title="'روزهای متوالی با انجام همه کارهای اجباری'">
<strong>{{ toFaDigits(score?.streak ?? 0) }}</strong>
روز پیاپی کامل
</span>
</header>
<div class="scores">
<div v-for="r in rows" :key="r.label" class="score" :class="`tone-${percentTone(r.value)}`">
<span>{{ r.label }}</span>
<strong>{{ formatPercent(r.value) }}</strong>
<i class="bar"><b :style="{ width: `${r.value ?? 0}%` }" /></i>
</div>
</div>
<div v-if="strip.length" class="strip-wrap">
<div class="strip" role="list" aria-label="وضعیت روزهای اخیر">
<span
v-for="d in strip"
:key="d.date"
role="listitem"
class="cell"
:class="`s-${d.state}`"
:title="`${formatDayNumeric(d.date)} — ${DAY_STATE_LABELS[d.state]}${d.total ? ` (${toFaDigits(d.completed)} از ${toFaDigits(d.total)})` : ''}`"
>
<em>{{ DAY_STATE_SYMBOLS[d.state] }}</em>
<small>{{ ROUTINE_WEEKDAY_LABELS[weekdayIndex(d.date)]?.charAt(0) }}</small>
</span>
</div>
<div class="legend">
<span><i class="s-complete">✓</i> کامل</span>
<span><i class="s-partial">△</i> ناقص</span>
<span><i class="s-missed">×</i> از دست رفته</span>
</div>
</div>
</section>
</template>
<style scoped>
.score-card {
display: grid;
gap: var(--sp-4);
padding: var(--sp-4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-xs);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
h3 {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.streak {
display: inline-flex;
align-items: baseline;
gap: 5px;
font-size: var(--fs-xs);
color: var(--muted);
}
.streak strong {
font-size: var(--fs-lg);
color: var(--accent-deep);
}
.scores {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.score {
display: grid;
grid-template-columns: 1fr auto;
align-items: baseline;
gap: 4px 8px;
}
.score span {
font-size: var(--fs-xs);
color: var(--muted);
}
.score strong {
font-size: var(--fs-md);
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.bar {
grid-column: 1 / -1;
height: 5px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.bar b {
display: block;
height: 100%;
border-radius: inherit;
background: var(--line-strong);
transition: width 0.4s ease;
}
.tone-good .bar b {
background: var(--success);
}
.tone-mid .bar b {
background: var(--warning);
}
.tone-low .bar b {
background: var(--danger);
}
.strip-wrap {
display: grid;
gap: var(--sp-2);
}
.strip {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 4px;
}
.cell {
display: grid;
justify-items: center;
gap: 2px;
}
.cell em {
width: 100%;
aspect-ratio: 1;
max-width: 26px;
border-radius: 6px;
display: grid;
place-items: center;
font-style: normal;
font-size: 0.75rem;
font-weight: var(--fw-bold);
background: var(--surface-subtle);
color: var(--muted-light);
}
.cell small {
font-size: 0.62rem;
color: var(--muted-light);
}
.s-complete,
.cell.s-complete em {
background: var(--success-soft);
color: var(--success-deep);
}
.s-partial,
.cell.s-partial em {
background: var(--warning-soft);
color: var(--warning-deep);
}
.s-missed,
.cell.s-missed em {
background: var(--danger-soft);
color: var(--danger);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: var(--sp-3);
font-size: var(--fs-xs);
color: var(--muted);
}
.legend span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.legend i {
width: 16px;
height: 16px;
border-radius: 4px;
display: inline-grid;
place-items: center;
font-style: normal;
font-size: 0.65rem;
font-weight: var(--fw-bold);
}
</style>
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import type { CompanyDto, LeadDto, PaginatedResponse, PersonDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiInput, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { ENTITY_TYPE_LABELS } from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
export type PickedEntity = { type: 'lead' | 'person' | 'company'; id: string; label: string };
const props = withDefaults(
defineProps<{
/** محدود کردن نوع پرونده (مثلاً وقتی روتین «الزامی به لید» است) */
only?: 'lead' | 'customer' | null;
}>(),
{ only: null },
);
const model = defineModel<PickedEntity | null>({ default: null });
const auth = useAuthStore();
function moduleOn(key: string) {
const mods = auth.user?.enabledModules;
return !mods || mods.length === 0 || mods.includes(key as never);
}
const typeOptions = computed(() => {
const list: Array<PickedEntity['type']> = [];
if (props.only !== 'customer' && moduleOn('leads')) list.push('lead');
if (props.only !== 'lead' && moduleOn('customers')) list.push('person', 'company');
return list;
});
const type = ref<PickedEntity['type']>(typeOptions.value[0] ?? 'person');
const q = ref('');
const results = ref<PickedEntity[]>([]);
const loading = ref(false);
let timer: ReturnType<typeof setTimeout> | null = null;
let seq = 0;
watch(typeOptions, (opts) => {
if (!opts.includes(type.value) && opts[0]) type.value = opts[0];
});
async function search() {
const term = q.value.trim();
const mine = ++seq;
if (term.length < 2) {
results.value = [];
loading.value = false;
return;
}
loading.value = true;
try {
const params = { q: term, page: 1, limit: 8 };
let next: PickedEntity[] = [];
if (type.value === 'lead') {
const { data } = await api.get<PaginatedResponse<LeadDto>>('/leads', { params });
next = data.items.map((l) => ({
type: 'lead',
id: l.id,
label: l.title || l.contactName || l.mobile,
}));
} else if (type.value === 'person') {
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', { params });
next = data.items.map((p) => ({
type: 'person',
id: p.id,
label: `${p.firstName} ${p.lastName}`.trim(),
}));
} else {
const { data } = await api.get<PaginatedResponse<CompanyDto>>('/companies', { params });
next = data.items.map((c) => ({ type: 'company', id: c.id, label: c.name }));
}
if (mine === seq) results.value = next;
} catch {
if (mine === seq) results.value = [];
} finally {
if (mine === seq) loading.value = false;
}
}
watch([q, type], () => {
if (timer) clearTimeout(timer);
timer = setTimeout(search, 280);
});
onBeforeUnmount(() => {
if (timer) clearTimeout(timer);
});
function pick(item: PickedEntity) {
model.value = item;
q.value = '';
results.value = [];
}
</script>
<template>
<div class="ent-picker">
<div v-if="model" class="picked">
<span class="picked-type">{{ ENTITY_TYPE_LABELS[model.type] }}</span>
<strong>{{ model.label }}</strong>
<button type="button" aria-label="حذف اتصال" @click="model = null">
<UiIcon name="x" :size="14" />
</button>
</div>
<template v-else-if="typeOptions.length">
<div class="search-row">
<UiSelect v-if="typeOptions.length > 1" v-model="type" class="type-sel">
<option v-for="t in typeOptions" :key="t" :value="t">{{ ENTITY_TYPE_LABELS[t] }}</option>
</UiSelect>
<UiInput v-model="q" :placeholder="`جستجوی ${ENTITY_TYPE_LABELS[type]}…`" />
</div>
<ul v-if="results.length || loading || q.trim().length >= 2" class="results">
<li v-if="loading" class="muted">در حال جستجو…</li>
<li v-else-if="!results.length" class="muted">موردی یافت نشد</li>
<li v-for="r in results" :key="r.id">
<button type="button" @click="pick(r)">{{ r.label }}</button>
</li>
</ul>
</template>
<p v-else class="muted">ماژول لید/مشتری برای سازمان فعال نیست.</p>
</div>
</template>
<style scoped>
.ent-picker {
display: grid;
gap: var(--sp-2);
}
.search-row {
display: flex;
gap: var(--sp-2);
}
.type-sel {
width: 110px;
flex-shrink: 0;
}
.search-row > :last-child {
flex: 1 1 auto;
}
.results {
list-style: none;
margin: 0;
padding: 4px;
border: 1px solid var(--line);
border-radius: var(--radius);
max-height: 220px;
overflow-y: auto;
background: var(--surface);
}
.results li button {
width: 100%;
text-align: right;
border: 0;
background: transparent;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
color: var(--ink);
}
.results li button:hover {
background: var(--accent-subtle);
color: var(--accent-deep);
}
.muted {
margin: 0;
padding: 8px 10px;
color: var(--muted);
font-size: var(--fs-sm);
}
.picked {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 6px 10px;
border-radius: var(--radius);
background: var(--accent-subtle);
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
color: var(--accent-deep);
min-width: 0;
}
.picked strong {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picked-type {
font-size: var(--fs-xs);
color: var(--muted);
}
.picked button {
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 6px;
}
.picked button:hover {
background: var(--surface);
color: var(--danger);
}
</style>
@@ -0,0 +1,151 @@
<script setup lang="ts">
import UiIcon from '@/components/UiIcon.vue';
type IconName = InstanceType<typeof UiIcon>['$props']['name'];
withDefaults(
defineProps<{
label: string;
value: string;
hint?: string;
icon?: IconName;
tone?: 'accent' | 'success' | 'warning' | 'danger' | 'info' | 'neutral';
/** تغییر نسبت به دوره قبل (درصد یا عدد)؛ مثبت = بهتر */
delta?: number | null;
deltaSuffix?: string;
}>(),
{ hint: '', icon: undefined, tone: 'neutral', delta: null, deltaSuffix: '' },
);
function fa(n: number) {
return Math.abs(n).toLocaleString('fa-IR');
}
</script>
<template>
<div class="kpi" :class="`tone-${tone}`">
<div class="kpi-top">
<span class="kpi-label">{{ label }}</span>
<span v-if="icon" class="kpi-ico"><UiIcon :name="icon" :size="16" /></span>
</div>
<strong class="kpi-value">{{ value }}</strong>
<div v-if="hint || delta != null" class="kpi-foot">
<span v-if="delta != null && delta !== 0" class="delta" :class="delta > 0 ? 'up' : 'down'">
{{ delta > 0 ? '▲' : '▼' }} {{ fa(delta) }}{{ deltaSuffix }}
</span>
<span v-else-if="delta === 0" class="delta flat">بدون تغییر</span>
<span v-if="hint" class="kpi-hint">{{ hint }}</span>
</div>
</div>
</template>
<style scoped>
.kpi {
display: grid;
gap: 6px;
padding: var(--sp-4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-xs);
min-width: 0;
}
.kpi-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
.kpi-label {
font-size: var(--fs-sm);
color: var(--muted);
font-weight: var(--fw-medium);
}
.kpi-ico {
width: 30px;
height: 30px;
border-radius: 8px;
display: grid;
place-items: center;
background: var(--surface-subtle);
color: var(--ink-secondary);
}
.tone-accent .kpi-ico {
background: var(--accent-subtle);
color: var(--accent-deep);
}
.tone-success .kpi-ico {
background: var(--success-soft);
color: var(--success-deep);
}
.tone-warning .kpi-ico {
background: var(--warning-soft);
color: var(--warning-deep);
}
.tone-danger .kpi-ico {
background: var(--danger-soft);
color: var(--danger);
}
.tone-info .kpi-ico {
background: var(--info-soft);
color: var(--info-deep);
}
.kpi-value {
font-size: var(--fs-2xl);
font-weight: var(--fw-bold);
color: var(--ink);
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.tone-danger .kpi-value {
color: var(--danger);
}
.kpi-foot {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: var(--fs-xs);
}
.kpi-hint {
color: var(--muted);
}
.delta {
font-weight: var(--fw-semibold);
}
.delta.up {
color: var(--success-deep);
}
.delta.down {
color: var(--danger);
}
.delta.flat {
color: var(--muted);
}
@media (max-width: 900px) {
.kpi {
padding: var(--sp-3);
}
.kpi-value {
font-size: var(--fs-xl);
}
}
</style>
@@ -0,0 +1,269 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { RoutineMonthlyReportDto, RoutineStatsQuery, RoutineTrendPointDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import KpiCard from '@/components/routines/KpiCard.vue';
import RoutineMonthGrid from '@/components/routines/RoutineMonthGrid.vue';
import TeamPerformanceTable from '@/components/routines/TeamPerformanceTable.vue';
import { UiButton, UiChart } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { daysBar, statusDonut, trendOption } from '@/lib/routineCharts';
import {
apiError,
formatPercent,
jalaliMonthRange,
shiftJalaliMonth,
todayKey,
} from '@/lib/routines';
const props = defineProps<{
view: 'me' | 'team';
filters?: RoutineStatsQuery;
refreshKey?: number;
}>();
const anchor = ref(todayKey());
const month = computed(() => jalaliMonthRange(anchor.value));
const data = ref<RoutineMonthlyReportDto | null>(null);
const loading = ref(false);
const error = ref('');
async function load() {
loading.value = true;
error.value = '';
try {
const rest = { ...(props.filters ?? {}) };
delete rest.from;
delete rest.to;
const res = await api.get<RoutineMonthlyReportDto>('/routines/stats/monthly', {
params: { ...rest, from: month.value.from, to: month.value.to, view: props.view },
});
data.value = res.data;
} catch (e) {
error.value = apiError(e, 'بارگذاری گزارش ماهانه ممکن نشد.');
} finally {
loading.value = false;
}
}
watch([() => month.value.from, () => JSON.stringify(props.filters ?? {}), () => props.refreshKey], load, {
immediate: true,
});
function diff(cur: number | null | undefined, prev: number | null | undefined) {
if (cur == null || prev == null) return null;
return cur - prev;
}
const t = computed(() => data.value?.totals ?? null);
const p = computed(() => data.value?.previous ?? null);
const trend = computed<RoutineTrendPointDto[]>(() =>
(data.value?.days ?? []).map((d) => ({ date: d.date, total: d.total, completed: d.completed, percent: d.percent })),
);
const lineOpt = computed(() => trendOption(trend.value));
const barOpt = computed(() => daysBar(data.value?.days ?? []));
const donutOpt = computed(() =>
statusDonut(data.value?.status ?? { completed: 0, notDone: 0, overdue: 0, skipped: 0, cancelled: 0 }),
);
const isCurrent = computed(() => month.value.from === jalaliMonthRange(todayKey()).from);
</script>
<template>
<div class="monthly">
<div class="head">
<div class="nav">
<UiButton variant="icon" aria-label="ماه قبل" @click="anchor = shiftJalaliMonth(anchor, -1)">
<UiIcon name="chevron-right" :size="18" />
</UiButton>
<strong>{{ month.label }}</strong>
<UiButton variant="icon" aria-label="ماه بعد" :disabled="isCurrent" @click="anchor = shiftJalaliMonth(anchor, 1)">
<UiIcon name="chevron-left" :size="18" />
</UiButton>
</div>
<span class="cmp">مقایسه با ماه قبل</span>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="kpis" :class="{ dim: loading }">
<KpiCard
label="درصد انجام"
:value="formatPercent(t?.percent)"
icon="target"
tone="accent"
:delta="diff(t?.percent, p?.percent)"
delta-suffix="٪"
/>
<KpiCard
label="انجام‌شده"
:value="toFaDigits(t?.completed ?? 0)"
:hint="`از ${toFaDigits(t?.total ?? 0)} کار`"
icon="check"
tone="success"
:delta="diff(t?.completed, p?.completed)"
/>
<KpiCard
label="انجام‌نشده"
:value="toFaDigits(t?.notDone ?? 0)"
:hint="`عقب‌افتاده ${toFaDigits(t?.overdue ?? 0)}`"
icon="x-circle"
tone="danger"
:delta="diff(p?.notDone, t?.notDone)"
/>
<KpiCard
label="روزهای کامل"
:value="toFaDigits(t?.completeDays ?? 0)"
:hint="`ناقص ${toFaDigits(t?.partialDays ?? 0)} · از دست رفته ${toFaDigits(t?.missedDays ?? 0)}`"
icon="calendar"
tone="info"
:delta="diff(t?.completeDays, p?.completeDays)"
/>
</div>
<div class="cards">
<section class="card wide">
<h3>روند درصد انجام روزانه</h3>
<UiChart :option="lineOpt" height="240px" :loading="loading" />
</section>
<section class="card">
<h3>وضعیت کارهای ماه</h3>
<UiChart :option="donutOpt" height="240px" :loading="loading" />
</section>
<section class="card wide">
<h3>تعداد کارها در هر روز</h3>
<UiChart :option="barOpt" height="240px" :loading="loading" />
</section>
<section class="card">
<h3>نقشه حرارتی انجام</h3>
<RoutineMonthGrid :from="month.from" :to="month.to" :days="data?.days ?? []" mode="heat" compact />
<div class="heat-legend">
<span>کمتر</span>
<i class="h0" /><i class="h1" /><i class="h2" /><i class="h3" /><i class="h4" />
<span>بیشتر</span>
</div>
</section>
</div>
<section v-if="view === 'team' && data?.byUser.length" class="card">
<h3>عملکرد اعضا در این ماه</h3>
<TeamPerformanceTable :members="data.byUser" />
</section>
</div>
</template>
<style scoped>
.monthly {
display: grid;
gap: var(--sp-4);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-2);
}
.nav {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.nav strong {
min-width: 120px;
text-align: center;
color: var(--ink);
}
.cmp {
font-size: var(--fs-xs);
color: var(--muted);
}
.kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--sp-3);
transition: opacity var(--transition-fast);
}
.dim {
opacity: 0.55;
}
.cards {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
gap: var(--sp-4);
}
.card {
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
display: grid;
gap: var(--sp-3);
align-content: start;
min-width: 0;
}
.card h3 {
margin: 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
}
.heat-legend {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--fs-xs);
color: var(--muted);
}
.heat-legend i {
width: 14px;
height: 14px;
border-radius: 4px;
}
.h0 {
background: var(--surface-subtle);
}
.h1 {
background: color-mix(in srgb, var(--accent) 14%, white);
}
.h2 {
background: color-mix(in srgb, var(--accent) 32%, white);
}
.h3 {
background: color-mix(in srgb, var(--accent) 55%, white);
}
.h4 {
background: color-mix(in srgb, var(--accent) 80%, white);
}
.error {
margin: 0;
}
@media (max-width: 1000px) {
.cards {
grid-template-columns: 1fr;
}
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-2);
}
}
</style>
@@ -0,0 +1,253 @@
<script setup lang="ts">
import { computed, inject, reactive, ref, watch } from 'vue';
import type {
ActivityPriority,
CreateRoutineTaskRequest,
RoutineContextDto,
RoutineEntityRefDto,
RoutineTaskDto,
RoutineTaskSource,
} from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import EntityLinkPicker, { type PickedEntity } from '@/components/routines/EntityLinkPicker.vue';
import { UiButton, UiField, UiInput, UiModal, UiSelect, UiTextarea } from '@/components/ui';
import { api } from '@/lib/api';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
ROUTINES_CTX,
apiError,
localInputToIso,
todayKey,
} from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
const props = withDefaults(
defineProps<{
open: boolean;
/** پرونده‌ای که Task از صفحه آن ساخته می‌شود */
preset?: RoutineEntityRefDto | null;
defaultDate?: string | null;
defaultAssigneeId?: string | null;
}>(),
{ preset: null, defaultDate: null, defaultAssigneeId: null },
);
const emit = defineEmits<{ close: []; created: [task: RoutineTaskDto] }>();
const auth = useAuthStore();
const shell = inject(ROUTINES_CTX, null);
const localCtx = ref<RoutineContextDto | null>(null);
const ctx = computed(() => shell?.context.value ?? localCtx.value);
const form = reactive({
title: '',
description: '',
date: todayKey(),
time: '',
priority: 'medium' as ActivityPriority,
categoryId: '',
assigneeId: '',
isMandatory: false,
entity: null as PickedEntity | null,
});
const saving = ref(false);
const error = ref('');
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
async function ensureContext() {
if (ctx.value) return;
try {
const { data } = await api.get<RoutineContextDto>('/routines/context');
localCtx.value = data;
} catch {
/* بدون زمینه هم فرم کار می‌کند */
}
}
watch(
() => props.open,
(open) => {
if (!open) return;
Object.assign(form, {
title: '',
description: '',
date: props.defaultDate || todayKey(),
time: '',
priority: 'medium',
categoryId: '',
assigneeId: props.defaultAssigneeId || auth.user?.id || '',
isMandatory: false,
entity: null,
});
error.value = '';
void ensureContext();
},
{ immediate: true },
);
const assignees = computed(() => {
const c = ctx.value;
if (!c?.isManager) return [];
return c.users;
});
function sourceFor(type: RoutineEntityRefDto['type'] | undefined): RoutineTaskSource {
if (type === 'lead') return 'lead';
if (type === 'person' || type === 'company') return 'customer';
if (type === 'deal') return 'deal';
if (type === 'activity') return 'follow_up';
return 'manual';
}
async function submit() {
error.value = '';
if (form.title.trim().length < 2) {
error.value = 'عنوان کار را وارد کنید.';
return;
}
const link = props.preset ?? form.entity;
const body: CreateRoutineTaskRequest = {
title: form.title.trim(),
description: form.description.trim() || null,
priority: form.priority,
categoryId: form.categoryId || null,
assigneeId: form.assigneeId || null,
isMandatory: form.isMandatory,
source: sourceFor(link?.type),
scheduledFor: form.date || todayKey(),
dueAt: form.time ? localInputToIso(form.date || todayKey(), form.time) : null,
};
if (link) (body as unknown as Record<string, unknown>)[`${link.type}Id`] = link.id;
saving.value = true;
try {
const { data } = await api.post<RoutineTaskDto>('/routine-tasks', body);
emit('created', data);
shell?.notifyChanged();
emit('close');
} catch (e) {
error.value = apiError(e, 'ثبت کار ممکن نشد.');
} finally {
saving.value = false;
}
}
</script>
<template>
<UiModal :open="open" title="کار جدید" @close="emit('close')">
<form class="qt-form" @submit.prevent="submit">
<UiField label="عنوان" required>
<UiInput v-model="form.title" placeholder="مثلاً: تماس با مشتری برای پیگیری پیش‌فاکتور" :maxlength="200" />
</UiField>
<UiField label="توضیحات">
<UiTextarea v-model="form.description" :rows="2" :maxlength="2000" />
</UiField>
<div class="grid-2">
<UiField label="تاریخ" required>
<JalaliDateInput v-model="form.date" mode="date" :clearable="false" />
</UiField>
<UiField label="ساعت" hint="خالی = تا پایان روز">
<JalaliDateInput v-model="form.time" mode="time" />
</UiField>
<UiField label="اولویت">
<UiSelect v-model="form.priority">
<option v-for="[k, label] in priorities" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</UiField>
<UiField label="دسته‌بندی">
<UiSelect v-model="form.categoryId">
<option value="">بدون دسته</option>
<option v-for="c in ctx?.categories ?? []" :key="c.id" :value="c.id">{{ c.name }}</option>
</UiSelect>
</UiField>
<UiField v-if="assignees.length > 1" label="مسئول" class="span-2">
<UiSelect v-model="form.assigneeId">
<option v-for="u in assignees" :key="u.id" :value="u.id">
{{ u.fullName }}{{ u.id === auth.user?.id ? ' (خودم)' : '' }}
</option>
</UiSelect>
</UiField>
</div>
<UiField label="اتصال به پرونده">
<div v-if="preset" class="preset">
<span>{{ ENTITY_TYPE_LABELS[preset.type] }}</span>
<strong>{{ preset.label }}</strong>
</div>
<EntityLinkPicker v-else v-model="form.entity" />
</UiField>
<label class="check">
<input v-model="form.isMandatory" type="checkbox" />
<span>کار اجباری (در امتیاز روزانه و Streak حساب می‌شود)</span>
</label>
<p v-if="error" class="error">{{ error }}</p>
</form>
<template #footer>
<UiButton :loading="saving" @click="submit">ثبت کار</UiButton>
<UiButton variant="ghost" @click="emit('close')">انصراف</UiButton>
</template>
</UiModal>
</template>
<style scoped>
.qt-form {
display: grid;
gap: var(--sp-4);
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.span-2 {
grid-column: 1 / -1;
}
.preset {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 8px 12px;
border-radius: var(--radius);
background: var(--accent-subtle);
color: var(--accent-deep);
}
.preset span {
font-size: var(--fs-xs);
color: var(--muted);
}
.check {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-sm);
color: var(--ink-secondary);
cursor: pointer;
}
.check input {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.error {
margin: 0;
}
@media (max-width: 600px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,301 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { RoutineRecurrence } from '@mento/shared';
import { ROUTINE_RECURRENCES, ROUTINE_RECURRENCE_LABELS, ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { UiButton, UiField } from '@/components/ui';
import { toFaDigits } from '@/lib/jalali';
import { formatDayNumeric } from '@/lib/routines';
export type RecurrenceValue = {
recurrence: RoutineRecurrence;
weekdays: number[];
intervalDays: number | null;
monthDay: number | null;
specificDates: string[];
};
const model = defineModel<RecurrenceValue>({ required: true });
function patch(p: Partial<RecurrenceValue>) {
model.value = { ...model.value, ...p };
}
function setType(r: RoutineRecurrence) {
const next: Partial<RecurrenceValue> = { recurrence: r };
if (r === 'weekdays' && !model.value.weekdays.length) next.weekdays = [0, 1, 2, 3, 4];
if ((r === 'weekly' || r === 'biweekly') && !model.value.weekdays.length) next.weekdays = [0];
if (r === 'every_n_days' && !model.value.intervalDays) next.intervalDays = 2;
if (r === 'monthly' && !model.value.monthDay) next.monthDay = 1;
patch(next);
}
function toggleDay(d: number) {
const set = new Set(model.value.weekdays);
if (set.has(d)) set.delete(d);
else set.add(d);
patch({ weekdays: [...set].sort((a, b) => a - b) });
}
const needsWeekdays = computed(() => ['weekdays', 'weekly', 'biweekly'].includes(model.value.recurrence));
function setIntervalDays(raw: string) {
const n = Number(raw.replace(/[۰-۹]/g, (c) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(c))));
patch({ intervalDays: Number.isFinite(n) && n > 0 ? Math.min(365, Math.floor(n)) : null });
}
const monthDays = Array.from({ length: 31 }, (_, i) => i + 1);
const newDate = ref('');
function addDate() {
if (!newDate.value) return;
const set = new Set(model.value.specificDates);
set.add(newDate.value);
patch({ specificDates: [...set].sort() });
newDate.value = '';
}
function removeDate(d: string) {
patch({ specificDates: model.value.specificDates.filter((x) => x !== d) });
}
</script>
<template>
<div class="rec">
<div class="types" role="radiogroup" aria-label="الگوی تکرار">
<button
v-for="r in ROUTINE_RECURRENCES"
:key="r"
type="button"
role="radio"
class="type"
:class="{ on: model.recurrence === r }"
:aria-checked="model.recurrence === r"
@click="setType(r)"
>
{{ ROUTINE_RECURRENCE_LABELS[r] }}
</button>
</div>
<div v-if="needsWeekdays" class="sub">
<span class="sub-label">
{{ model.recurrence === 'weekdays' ? 'روزهای انجام' : 'روز(های) هفته' }}
</span>
<div class="days">
<button
v-for="(label, i) in ROUTINE_WEEKDAY_LABELS"
:key="i"
type="button"
class="day"
:class="{ on: model.weekdays.includes(i) }"
:aria-pressed="model.weekdays.includes(i)"
@click="toggleDay(i)"
>
{{ label }}
</button>
</div>
<p v-if="model.recurrence === 'biweekly'" class="hint">
هفته اول از تاریخ شروع حساب می‌شود و یک هفته در میان تکرار می‌شود.
</p>
</div>
<div v-else-if="model.recurrence === 'every_n_days'" class="sub inline">
<span class="sub-label">هر</span>
<input
class="num"
type="number"
min="1"
max="365"
inputmode="numeric"
:value="model.intervalDays ?? ''"
aria-label="فاصله تکرار به روز"
@input="setIntervalDays(($event.target as HTMLInputElement).value)"
/>
<span class="sub-label">روز یک‌بار (از تاریخ شروع)</span>
</div>
<div v-else-if="model.recurrence === 'monthly'" class="sub">
<span class="sub-label">روز ماه (شمسی)</span>
<div class="mdays">
<button
v-for="d in monthDays"
:key="d"
type="button"
class="mday"
:class="{ on: model.monthDay === d }"
@click="patch({ monthDay: d })"
>
{{ toFaDigits(d) }}
</button>
</div>
<p class="hint">اگر ماهی آن روز را نداشته باشد (مثل ۳۱ در ماه‌های پاییز)، آخرین روز ماه در نظر گرفته می‌شود.</p>
</div>
<div v-else-if="model.recurrence === 'specific_dates'" class="sub">
<div class="add-date">
<UiField label="افزودن تاریخ">
<JalaliDateInput v-model="newDate" mode="date" />
</UiField>
<UiButton variant="outline" :disabled="!newDate" @click="addDate">
<template #icon><UiIcon name="plus" :size="15" /></template>
افزودن
</UiButton>
</div>
<div v-if="model.specificDates.length" class="dates">
<span v-for="d in model.specificDates" :key="d" class="date-chip">
{{ formatDayNumeric(d) }}
<button type="button" aria-label="حذف تاریخ" @click="removeDate(d)">
<UiIcon name="x" :size="12" />
</button>
</span>
</div>
<p v-else class="hint">هنوز تاریخی اضافه نشده.</p>
</div>
</div>
</template>
<style scoped>
.rec {
display: grid;
gap: var(--sp-3);
}
.types {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.type,
.day,
.mday {
min-height: 34px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink-secondary);
font: inherit;
font-size: var(--fs-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.type:hover,
.day:hover,
.mday:hover {
border-color: var(--line-strong);
}
.type.on,
.day.on,
.mday.on {
border-color: var(--accent);
background: var(--accent-subtle);
color: var(--accent-deep);
font-weight: var(--fw-semibold);
}
.sub {
display: grid;
gap: var(--sp-2);
padding: var(--sp-3);
border-radius: var(--radius);
background: var(--surface-subtle);
}
.sub.inline {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.sub-label {
font-size: var(--fs-sm);
color: var(--ink-secondary);
}
.num {
width: 80px;
height: var(--control-h);
padding: 0 10px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
text-align: center;
}
.num:focus {
outline: 2px solid var(--accent-soft);
border-color: var(--accent);
}
.days {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mdays {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
gap: 4px;
max-width: 420px;
}
.mday {
padding: 0;
}
.hint {
margin: 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.add-date {
display: flex;
align-items: flex-end;
gap: var(--sp-2);
flex-wrap: wrap;
}
.add-date > :first-child {
flex: 1 1 200px;
}
.dates {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.date-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 4px 3px 10px;
border-radius: 99px;
background: var(--surface);
border: 1px solid var(--line);
font-size: var(--fs-xs);
}
.date-chip button {
width: 20px;
height: 20px;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--muted);
display: grid;
place-items: center;
cursor: pointer;
}
.date-chip button:hover {
background: var(--danger-soft);
color: var(--danger);
}
</style>
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { RoutineEntityRefDto, RoutineTaskDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import { UiButton } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError, isOpenStatus } from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
import { useAuthStore } from '@/stores/auth';
const props = withDefaults(
defineProps<{
entity: RoutineEntityRefDto;
title?: string;
}>(),
{ title: 'کارهای مرتبط' },
);
const router = useRouter();
const auth = useAuthStore();
const enabled = computed(() => {
const mods = auth.user?.enabledModules;
return !mods || mods.includes('routines');
});
const items = ref<RoutineTaskDto[]>([]);
const loading = ref(false);
const error = ref('');
const showClosed = ref(false);
const quickOpen = ref(false);
async function load() {
if (!enabled.value || !props.entity.id) return;
loading.value = true;
error.value = '';
try {
const { data } = await api.get<RoutineTaskDto[]>('/routine-tasks/by-entity', {
params: { [`${props.entity.type}Id`]: props.entity.id },
});
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری کارهای مرتبط ممکن نشد.');
} finally {
loading.value = false;
}
}
watch(() => `${props.entity.type}:${props.entity.id}`, load, { immediate: true });
const actions = useRoutineTaskActions((updated) => {
const i = items.value.findIndex((t) => t.id === updated.id);
if (i >= 0) items.value[i] = updated;
});
function onAction(kind: RoutineTaskRowAction, task: RoutineTaskDto) {
if (kind === 'start') void actions.start(task);
else if (kind === 'complete-note') actions.openComplete(task);
else if (kind === 'reschedule') actions.openReschedule(task);
else if (kind === 'move-today') void actions.moveToday(task);
else if (kind === 'skip') actions.openSkip(task);
else actions.openCancel(task);
}
const open = computed(() => items.value.filter((t) => isOpenStatus(t.status)));
const closed = computed(() => items.value.filter((t) => !isOpenStatus(t.status)));
function openTask(t: RoutineTaskDto) {
void router.push({ path: '/routines', query: { task: t.id } });
}
function onCreated(t: RoutineTaskDto) {
items.value = [t, ...items.value];
}
</script>
<template>
<section v-if="enabled" class="related">
<header>
<h3>
<UiIcon name="check" :size="16" />
{{ title }}
<em v-if="open.length">{{ toFaDigits(open.length) }} باز</em>
</h3>
<UiButton size="sm" variant="outline" @click="quickOpen = true">
<template #icon><UiIcon name="plus" :size="14" /></template>
ایجاد Task
</UiButton>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
<div v-if="loading && !items.length" class="muted">در حال بارگذاری…</div>
<p v-else-if="!items.length" class="muted">هنوز کاری برای این پرونده ثبت نشده.</p>
<div v-else class="rows">
<RoutineTaskRow
v-for="t in open"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
show-assignee
show-delay
@toggle="actions.toggle"
@open="openTask"
@action="onAction"
/>
<button v-if="closed.length" type="button" class="toggle-closed" @click="showClosed = !showClosed">
{{ showClosed ? 'پنهان کردن' : 'نمایش' }} {{ toFaDigits(closed.length) }} کار انجام‌شده / بسته
</button>
<template v-if="showClosed">
<RoutineTaskRow
v-for="t in closed"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
show-assignee
@toggle="actions.toggle"
@open="openTask"
@action="onAction"
/>
</template>
</div>
<QuickTaskModal :open="quickOpen" :preset="entity" @close="quickOpen = false" @created="onCreated" />
<RoutineTaskModals :actions="actions" />
</section>
</template>
<style scoped>
.related {
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
h3 {
margin: 0;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
h3 em {
font-style: normal;
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
padding: 1px 8px;
border-radius: 99px;
background: var(--accent-subtle);
color: var(--accent-deep);
}
.rows {
display: grid;
gap: var(--sp-2);
}
.muted {
margin: 0;
color: var(--muted);
font-size: var(--fs-sm);
}
.toggle-closed {
justify-self: start;
border: 0;
background: transparent;
color: var(--accent-deep);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
cursor: pointer;
padding: 4px 0;
}
.error {
margin: 0;
}
</style>
@@ -0,0 +1,265 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { RoutineCalendarDayDto } from '@mento/shared';
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import { dateToJalali, toFaDigits } from '@/lib/jalali';
import { DAY_STATE_LABELS, addDaysKey, keyToDate, todayKey, weekdayIndex } from '@/lib/routines';
const props = withDefaults(
defineProps<{
from: string;
to: string;
days: RoutineCalendarDayDto[];
selected?: string | null;
/** heat = رنگ بر اساس درصد (گزارش ماهانه) */
mode?: 'state' | 'heat';
compact?: boolean;
}>(),
{ selected: null, mode: 'state', compact: false },
);
const emit = defineEmits<{ select: [day: string] }>();
const byDate = computed(() => new Map(props.days.map((d) => [d.date, d])));
const today = todayKey();
const cells = computed(() => {
const out: Array<{ key: string; blank: boolean; day?: RoutineCalendarDayDto; jd: number; future: boolean }> = [];
const lead = weekdayIndex(props.from);
for (let i = 0; i < lead; i++) out.push({ key: `b${i}`, blank: true, jd: 0, future: false });
for (let k = props.from; k <= props.to; k = addDaysKey(k, 1)) {
out.push({
key: k,
blank: false,
day: byDate.value.get(k),
jd: dateToJalali(keyToDate(k)).jd,
future: k > today,
});
}
return out;
});
function heatLevel(d?: RoutineCalendarDayDto): number {
if (!d || !d.total || d.percent == null) return 0;
if (d.percent >= 90) return 4;
if (d.percent >= 70) return 3;
if (d.percent >= 40) return 2;
return 1;
}
function title(c: (typeof cells.value)[number]) {
const d = c.day;
if (!d || !d.total) return 'بدون کار';
return `${DAY_STATE_LABELS[d.state]} — ${toFaDigits(d.completed)} از ${toFaDigits(d.total)} انجام شده`;
}
</script>
<template>
<div class="mgrid" :class="[mode, { compact }]">
<div v-for="w in ROUTINE_WEEKDAY_LABELS" :key="w" class="wd">{{ compact ? w.charAt(0) : w }}</div>
<template v-for="c in cells" :key="c.key">
<span v-if="c.blank" class="cell blank" />
<button
v-else
type="button"
class="cell"
:class="[
mode === 'state' ? `s-${c.future && !c.day?.total ? 'future' : (c.day?.state ?? 'empty')}` : `h-${heatLevel(c.day)}`,
{ sel: selected === c.key, today: c.key === today },
]"
:title="title(c)"
@click="emit('select', c.key)"
>
<span class="num">{{ toFaDigits(c.jd) }}</span>
<template v-if="!compact && c.day?.total">
<span class="frac">{{ toFaDigits(c.day.completed) }}/{{ toFaDigits(c.day.total) }}</span>
<i v-if="mode === 'state'" class="dot" />
</template>
</button>
</template>
</div>
</template>
<style scoped>
.mgrid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
}
.wd {
text-align: center;
font-size: var(--fs-xs);
color: var(--muted);
font-weight: var(--fw-semibold);
padding-bottom: 2px;
}
.cell {
position: relative;
min-height: 74px;
padding: 8px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
gap: 4px;
cursor: pointer;
font: inherit;
text-align: right;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.cell.blank {
border: 0;
background: transparent;
cursor: default;
}
.cell:not(.blank):hover {
border-color: var(--line-strong);
}
.cell.sel {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.cell.today .num {
background: var(--accent);
color: #fff;
}
.num {
min-width: 24px;
height: 24px;
padding: 0 4px;
border-radius: 99px;
display: inline-grid;
place-items: center;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.frac {
font-size: var(--fs-xs);
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.dot {
position: absolute;
top: 10px;
inset-inline-end: 10px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--line-strong);
}
/* وضعیت روز */
.s-complete {
background: color-mix(in srgb, var(--success-soft) 45%, var(--surface));
}
.s-complete .dot {
background: var(--success);
}
.s-partial {
background: color-mix(in srgb, var(--warning-soft) 45%, var(--surface));
}
.s-partial .dot {
background: var(--warning);
}
.s-missed {
background: color-mix(in srgb, var(--danger-soft) 45%, var(--surface));
}
.s-missed .dot {
background: var(--danger);
}
.s-future {
background: var(--surface);
}
.s-empty {
background: var(--surface);
}
/* Heatmap */
.h-0 {
background: var(--surface-subtle);
}
.h-1 {
background: color-mix(in srgb, var(--accent) 14%, white);
}
.h-2 {
background: color-mix(in srgb, var(--accent) 32%, white);
}
.h-3 {
background: color-mix(in srgb, var(--accent) 55%, white);
}
.h-4 {
background: color-mix(in srgb, var(--accent) 80%, white);
}
.h-3 .num,
.h-4 .num,
.h-3 .frac,
.h-4 .frac {
color: #fff;
}
.compact {
gap: 4px;
}
.compact .cell {
min-height: 0;
aspect-ratio: 1;
padding: 2px;
align-items: center;
justify-content: center;
border-color: transparent;
}
.compact .num {
font-size: 0.7rem;
min-width: 0;
height: auto;
}
@media (max-width: 700px) {
.mgrid {
gap: 3px;
}
.cell {
min-height: 54px;
padding: 4px;
align-items: center;
}
.frac {
font-size: 0.62rem;
}
.dot {
position: static;
width: 7px;
height: 7px;
}
}
</style>
@@ -0,0 +1,663 @@
<script setup lang="ts">
import { computed, inject, reactive, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import type {
ActivityPriority,
RoutineTaskDetailDto,
RoutineTaskDto,
RoutineTaskEventDto,
UpdateRoutineTaskRequest,
} from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import {
UiBadge,
UiButton,
UiDrawer,
UiField,
UiInput,
UiSelect,
UiTextarea,
} from '@/components/ui';
import { api } from '@/lib/api';
import { formatJalaliDayClock } from '@/lib/jalali';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINES_CTX,
ROUTINE_ENTITY_LINK_LABELS,
ROUTINE_TASK_SOURCE_LABELS,
ROUTINE_TASK_STATUS_LABELS,
STATUS_VARIANTS,
apiError,
entityRoute,
formatDayLong,
formatDelay,
formatTime,
hasExplicitTime,
isOpenStatus,
isoToLocalInput,
localInputToIso,
} from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
const props = defineProps<{ taskId: string | null }>();
const emit = defineEmits<{ close: []; changed: [task: RoutineTaskDto] }>();
const shell = inject(ROUTINES_CTX, null);
const task = ref<RoutineTaskDetailDto | null>(null);
const loading = ref(false);
const loadError = ref('');
const editing = ref(false);
const saving = ref(false);
const editError = ref('');
const edit = reactive({
title: '',
description: '',
priority: 'medium' as ActivityPriority,
categoryId: '',
assigneeId: '',
date: '',
time: '',
});
async function load() {
if (!props.taskId) return;
loading.value = true;
loadError.value = '';
try {
const { data } = await api.get<RoutineTaskDetailDto>(`/routine-tasks/${props.taskId}`);
task.value = data;
} catch (e) {
loadError.value = apiError(e, 'کار پیدا نشد.');
task.value = null;
} finally {
loading.value = false;
}
}
watch(
() => props.taskId,
(id) => {
editing.value = false;
if (id) void load();
else task.value = null;
},
{ immediate: true },
);
const actions = useRoutineTaskActions((updated) => {
emit('changed', updated);
shell?.notifyChanged();
void load();
});
const isOpen = computed(() => (task.value ? isOpenStatus(task.value.status) : false));
const done = computed(() => task.value?.status === 'completed');
const canManage = computed(() => !!shell?.context.value?.isManager);
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
function startEdit() {
const t = task.value;
if (!t) return;
const due = isoToLocalInput(t.dueAt);
Object.assign(edit, {
title: t.title,
description: t.description ?? '',
priority: t.priority,
categoryId: t.category?.id ?? '',
assigneeId: t.assignee?.id ?? '',
date: due.date || t.scheduledFor,
time: due.time === '23:59' ? '' : due.time,
});
editError.value = '';
editing.value = true;
}
async function saveEdit() {
const t = task.value;
if (!t) return;
if (edit.title.trim().length < 2) {
editError.value = 'عنوان را وارد کنید.';
return;
}
const body: UpdateRoutineTaskRequest = {
title: edit.title.trim(),
description: edit.description.trim() || null,
priority: edit.priority,
categoryId: edit.categoryId || null,
};
if (!t.routineId) {
const dueAt = localInputToIso(edit.date, edit.time);
if (dueAt && dueAt !== t.dueAt) body.dueAt = dueAt;
}
if (canManage.value && edit.assigneeId && edit.assigneeId !== t.assignee?.id) {
body.assigneeId = edit.assigneeId;
}
saving.value = true;
editError.value = '';
try {
const { data } = await api.patch<RoutineTaskDto>(`/routine-tasks/${t.id}`, body);
emit('changed', data);
shell?.notifyChanged();
editing.value = false;
await load();
} catch (e) {
editError.value = apiError(e, 'ذخیره ممکن نشد.');
} finally {
saving.value = false;
}
}
function eventText(ev: RoutineTaskEventDto): string {
switch (ev.type) {
case 'created':
return 'کار ایجاد شد';
case 'status_changed':
return ev.toStatus
? `وضعیت ${ev.fromStatus ? `از «${ROUTINE_TASK_STATUS_LABELS[ev.fromStatus]}» ` : ''}به «${ROUTINE_TASK_STATUS_LABELS[ev.toStatus]}» تغییر کرد`
: 'وضعیت تغییر کرد';
case 'rescheduled':
return 'زمان کار تغییر کرد';
case 'reassigned':
return 'مسئول کار تغییر کرد';
case 'note':
return 'گزارش نتیجه ثبت شد';
default:
return 'کار ویرایش شد';
}
}
</script>
<template>
<UiDrawer :open="!!taskId" title="جزئیات کار" width="460px" @close="emit('close')">
<div v-if="loading && !task" class="state">در حال بارگذاری…</div>
<div v-else-if="loadError" class="state error">{{ loadError }}</div>
<div v-else-if="task" class="td">
<!-- عنوان و وضعیت -->
<section class="td-head">
<div class="badges">
<UiBadge :variant="STATUS_VARIANTS[task.status]">{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}</UiBadge>
<UiBadge :variant="PRIORITY_VARIANTS[task.priority]" size="sm">
اولویت {{ PRIORITY_LABELS[task.priority] }}
</UiBadge>
<UiBadge v-if="task.isMandatory" variant="warning" size="sm">اجباری</UiBadge>
</div>
<h3 class="td-title" :class="{ done }">{{ task.title }}</h3>
<p v-if="task.description" class="td-desc">{{ task.description }}</p>
</section>
<!-- اکشن اصلی -->
<section v-if="!editing" class="td-actions">
<UiButton
v-if="isOpen"
variant="success"
block
size="lg"
:loading="actions.busyId.value === task.id"
@click="actions.toggle(task)"
>
<template #icon><UiIcon name="check-plain" :size="18" /></template>
انجام شد
</UiButton>
<UiButton
v-else-if="task.status !== 'cancelled' || canManage"
variant="outline"
block
:loading="actions.busyId.value === task.id"
@click="actions.toggle(task)"
>
<template #icon><UiIcon name="refresh" :size="16" /></template>
برگرداندن به انجام‌نشده
</UiButton>
<div class="td-actions-row">
<UiButton v-if="task.status === 'todo'" variant="ghost" size="sm" @click="actions.start(task)">
شروع کار
</UiButton>
<UiButton v-if="isOpen || done" variant="ghost" size="sm" @click="actions.openComplete(task)">
{{ done ? 'ویرایش گزارش' : 'انجام با گزارش' }}
</UiButton>
<UiButton v-if="isOpen" variant="ghost" size="sm" @click="actions.openReschedule(task)">
تغییر زمان
</UiButton>
<UiButton v-if="task.status === 'overdue'" variant="ghost" size="sm" @click="actions.moveToday(task)">
انتقال به امروز
</UiButton>
<UiButton v-if="isOpen && task.rules.allowSkip" variant="ghost" size="sm" @click="actions.openSkip(task)">
رد کردن
</UiButton>
<UiButton v-if="isOpen" variant="ghost" size="sm" class="danger-text" @click="actions.openCancel(task)">
لغو
</UiButton>
</div>
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
</section>
<!-- ویرایش -->
<section v-if="editing" class="td-edit">
<UiField label="عنوان" required>
<UiInput v-model="edit.title" :maxlength="200" />
</UiField>
<UiField label="توضیحات">
<UiTextarea v-model="edit.description" :rows="3" />
</UiField>
<div class="grid-2">
<UiField label="اولویت">
<UiSelect v-model="edit.priority">
<option v-for="[k, label] in priorities" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</UiField>
<UiField label="دسته‌بندی">
<UiSelect v-model="edit.categoryId">
<option value="">بدون دسته</option>
<option v-for="c in shell?.context.value?.categories ?? []" :key="c.id" :value="c.id">
{{ c.name }}
</option>
</UiSelect>
</UiField>
<template v-if="!task.routineId">
<UiField label="تاریخ">
<JalaliDateInput v-model="edit.date" mode="date" :clearable="false" />
</UiField>
<UiField label="ساعت">
<JalaliDateInput v-model="edit.time" mode="time" />
</UiField>
</template>
<UiField v-if="canManage" label="مسئول" class="span-2">
<UiSelect v-model="edit.assigneeId">
<option v-for="u in shell?.context.value?.users ?? []" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</UiSelect>
</UiField>
</div>
<p v-if="editError" class="error">{{ editError }}</p>
<div class="td-edit-actions">
<UiButton :loading="saving" @click="saveEdit">ذخیره</UiButton>
<UiButton variant="ghost" @click="editing = false">انصراف</UiButton>
</div>
</section>
<!-- مشخصات -->
<section class="td-info">
<div class="info-row">
<span>مسئول</span>
<strong v-if="task.assignee" class="with-avatar">
<UserAvatar :name="task.assignee.fullName" :user-id="task.assignee.id" :size="22" />
{{ task.assignee.fullName }}
</strong>
<strong v-else>—</strong>
</div>
<div class="info-row">
<span>روز برنامه</span>
<strong>{{ formatDayLong(task.scheduledFor) }}</strong>
</div>
<div class="info-row">
<span>مهلت</span>
<strong>
{{ hasExplicitTime(task) ? `ساعت ${formatTime(task.dueAt)}` : 'تا پایان روز' }}
<em v-if="task.status === 'overdue'" class="late">({{ formatDelay(task.dueAt) }} تأخیر)</em>
</strong>
</div>
<div v-if="task.completedAt" class="info-row">
<span>زمان انجام</span>
<strong class="ok">{{ formatJalaliDayClock(new Date(task.completedAt)) }}</strong>
</div>
<div v-if="task.category" class="info-row">
<span>دسته‌بندی</span>
<strong class="cat"><i :style="{ background: task.category.color }" />{{ task.category.name }}</strong>
</div>
<div class="info-row">
<span>منبع</span>
<strong>{{ ROUTINE_TASK_SOURCE_LABELS[task.source] }}</strong>
</div>
<div v-if="task.createdBy" class="info-row">
<span>ثبت‌کننده</span>
<strong>{{ task.createdBy.fullName }}</strong>
</div>
</section>
<section v-if="task.entities.length" class="td-block">
<h4>پرونده‌های مرتبط</h4>
<div class="ents">
<RouterLink v-for="ent in task.entities" :key="`${ent.type}-${ent.id}`" :to="entityRoute(ent)" class="ent">
<span>{{ ENTITY_TYPE_LABELS[ent.type] }}</span>
<strong>{{ ent.label }}</strong>
<UiIcon name="chevron-left" :size="14" />
</RouterLink>
</div>
</section>
<section v-if="task.resultNote || task.skipReason" class="td-block">
<h4>{{ task.resultNote ? 'گزارش نتیجه' : 'دلیل رد شدن' }}</h4>
<p class="note">{{ task.resultNote || task.skipReason }}</p>
</section>
<section v-if="task.routineId" class="td-block">
<h4>قوانین این روتین</h4>
<div class="rules">
<span :class="{ on: task.rules.allowSkip }">{{ task.rules.allowSkip ? 'رد کردن مجاز' : 'رد کردن غیرمجاز' }}</span>
<span v-if="task.rules.skipReasonRequired" class="on">دلیل رد الزامی</span>
<span v-if="task.rules.reportRequired" class="on">گزارش الزامی</span>
<span :class="{ on: task.rules.allowAfterDeadline }">
{{ task.rules.allowAfterDeadline ? 'انجام بعد از مهلت مجاز' : 'انجام بعد از مهلت غیرمجاز' }}
</span>
<span v-if="task.rules.entityLink !== 'none'">
اتصال پرونده: {{ ROUTINE_ENTITY_LINK_LABELS[task.rules.entityLink] }}
</span>
</div>
</section>
<section class="td-block">
<h4>تاریخچه تغییرات</h4>
<ol class="events">
<li v-for="ev in task.events" :key="ev.id">
<span class="dot" :class="ev.toStatus ? `st-${ev.toStatus}` : ''" />
<div>
<p>{{ eventText(ev) }}</p>
<p v-if="ev.note" class="ev-note">{{ ev.note }}</p>
<small>
{{ ev.actor?.fullName ?? 'سیستم' }} · {{ formatJalaliDayClock(new Date(ev.createdAt)) }}
</small>
</div>
</li>
</ol>
</section>
</div>
<template v-if="task && !editing" #footer>
<UiButton variant="ghost" size="sm" @click="startEdit">
<template #icon><UiIcon name="pencil" :size="14" /></template>
ویرایش
</UiButton>
</template>
</UiDrawer>
<RoutineTaskModals :actions="actions" />
</template>
<style scoped>
.state {
padding: var(--sp-8) 0;
text-align: center;
color: var(--muted);
}
.td {
display: grid;
gap: var(--sp-5);
}
.td-head {
display: grid;
gap: var(--sp-2);
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.td-title {
margin: 0;
font-size: var(--fs-lg);
font-weight: var(--fw-bold);
color: var(--ink);
line-height: 1.6;
}
.td-title.done {
color: var(--muted);
text-decoration: line-through;
}
.td-desc {
margin: 0;
color: var(--ink-secondary);
font-size: var(--fs-sm);
line-height: 1.8;
white-space: pre-wrap;
}
.td-actions {
display: grid;
gap: var(--sp-2);
}
.td-actions-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.danger-text {
color: var(--danger) !important;
}
.td-edit {
display: grid;
gap: var(--sp-3);
padding: var(--sp-3);
border: 1px dashed var(--line-strong);
border-radius: var(--radius);
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.span-2 {
grid-column: 1 / -1;
}
.td-edit-actions {
display: flex;
gap: var(--sp-2);
}
.td-info {
display: grid;
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
.info-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: 10px 12px;
font-size: var(--fs-sm);
}
.info-row + .info-row {
border-top: 1px solid var(--line-light);
}
.info-row > span {
color: var(--muted);
}
.info-row strong {
color: var(--ink);
font-weight: var(--fw-medium);
text-align: left;
}
.with-avatar,
.cat {
display: inline-flex;
align-items: center;
gap: 6px;
}
.cat i {
width: 9px;
height: 9px;
border-radius: 50%;
}
.late {
color: var(--danger);
font-style: normal;
font-size: var(--fs-xs);
}
.ok {
color: var(--success-deep) !important;
}
.td-block {
display: grid;
gap: var(--sp-2);
}
.td-block h4 {
margin: 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
}
.ents {
display: grid;
gap: 6px;
}
.ent {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--ink);
}
.ent:hover {
border-color: var(--accent);
background: var(--accent-subtle);
}
.ent span {
font-size: var(--fs-xs);
color: var(--muted);
}
.ent strong {
flex: 1 1 auto;
font-weight: var(--fw-medium);
}
.note {
margin: 0;
padding: var(--sp-3);
border-radius: var(--radius);
background: var(--surface-subtle);
font-size: var(--fs-sm);
line-height: 1.8;
white-space: pre-wrap;
}
.rules {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rules span {
padding: 3px 10px;
border-radius: var(--radius-full);
background: var(--surface-subtle);
color: var(--muted);
font-size: var(--fs-xs);
}
.rules span.on {
background: var(--accent-subtle);
color: var(--accent-deep);
}
.events {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--sp-3);
}
.events li {
display: grid;
grid-template-columns: 12px minmax(0, 1fr);
gap: var(--sp-2);
position: relative;
}
.events li:not(:last-child)::after {
content: '';
position: absolute;
inset-inline-start: 5px;
top: 16px;
bottom: calc(-1 * var(--sp-3));
width: 2px;
background: var(--line-light);
}
.events .dot {
width: 12px;
height: 12px;
margin-top: 4px;
border-radius: 50%;
background: var(--line-strong);
border: 2px solid var(--surface);
box-shadow: 0 0 0 1px var(--line);
}
.dot.st-completed {
background: var(--success);
}
.dot.st-overdue {
background: var(--danger);
}
.dot.st-skipped {
background: var(--warning);
}
.dot.st-in_progress {
background: var(--info);
}
.events p {
margin: 0;
font-size: var(--fs-sm);
color: var(--ink);
}
.events .ev-note {
margin-top: 2px;
color: var(--ink-secondary);
font-size: var(--fs-xs);
}
.events small {
color: var(--muted);
font-size: var(--fs-xs);
}
@media (max-width: 600px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed } from 'vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { UiButton, UiField, UiModal, UiTextarea } from '@/components/ui';
import type { RoutineTaskActions } from '@/lib/useRoutineTaskActions';
const props = defineProps<{ actions: RoutineTaskActions }>();
const modal = computed(() => props.actions.modal);
const title = computed(() => {
switch (modal.value.kind) {
case 'complete':
return modal.value.task?.status === 'completed' ? 'گزارش نتیجه' : 'ثبت انجام کار';
case 'skip':
return 'رد کردن کار';
case 'reschedule':
return 'تغییر زمان کار';
case 'cancel':
return 'لغو کار';
default:
return '';
}
});
const noteRequired = computed(() => {
const t = modal.value.task;
if (!t) return false;
if (modal.value.kind === 'complete') return t.rules.reportRequired;
if (modal.value.kind === 'skip') return t.rules.skipReasonRequired;
return false;
});
const noteLabel = computed(() => {
if (modal.value.kind === 'complete') return 'نتیجه / گزارش';
if (modal.value.kind === 'skip') return 'دلیل رد کردن';
return 'توضیح (اختیاری)';
});
const placeholder = computed(() => {
if (modal.value.kind === 'complete') return 'مثلاً: با مشتری تماس گرفته شد و قرار جلسه برای فردا تنظیم شد.';
if (modal.value.kind === 'skip') return 'چرا این کار امروز انجام نمی‌شود؟';
return '';
});
const submitLabel = computed(() => {
switch (modal.value.kind) {
case 'complete':
return modal.value.task?.status === 'completed' ? 'ذخیره گزارش' : 'انجام شد';
case 'skip':
return 'رد کردن';
case 'reschedule':
return 'ذخیره زمان جدید';
case 'cancel':
return 'لغو کار';
default:
return 'ذخیره';
}
});
</script>
<template>
<UiModal :open="!!modal.kind" :title="title" nest @close="actions.close()">
<form class="rt-modal" @submit.prevent="actions.submit()">
<p v-if="modal.task" class="task-name">{{ modal.task.title }}</p>
<template v-if="modal.kind === 'reschedule'">
<div class="when">
<UiField label="تاریخ" required>
<JalaliDateInput v-model="modal.date" mode="date" required :clearable="false" />
</UiField>
<UiField label="ساعت" hint="خالی = تا پایان روز">
<JalaliDateInput v-model="modal.time" mode="time" />
</UiField>
</div>
</template>
<UiField v-else :label="noteLabel" :required="noteRequired">
<UiTextarea v-model="modal.note" :rows="4" :placeholder="placeholder" :maxlength="2000" />
</UiField>
<p v-if="modal.kind === 'cancel'" class="hint">
کار لغو شده در محاسبه درصد انجام حساب نمی‌شود.
</p>
<p v-if="modal.error" class="error">{{ modal.error }}</p>
</form>
<template #footer>
<UiButton
:variant="modal.kind === 'cancel' ? 'danger' : modal.kind === 'complete' ? 'success' : 'primary'"
:loading="modal.saving"
@click="actions.submit()"
>
{{ submitLabel }}
</UiButton>
<UiButton variant="ghost" @click="actions.close()">انصراف</UiButton>
</template>
</UiModal>
</template>
<style scoped>
.rt-modal {
display: grid;
gap: var(--sp-4);
}
.task-name {
margin: 0;
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius);
background: var(--surface-subtle);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.when {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.hint {
margin: 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.error {
margin: 0;
}
@media (max-width: 600px) {
.when {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,440 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { RouterLink } from 'vue-router';
import type { RoutineTaskDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import { UiBadge, UiDropdownMenu } from '@/components/ui';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINE_TASK_SOURCE_LABELS,
ROUTINE_TASK_STATUS_LABELS,
STATUS_VARIANTS,
entityRoute,
formatDayNumeric,
formatDelay,
formatTime,
hasExplicitTime,
isOpenStatus,
} from '@/lib/routines';
export type RoutineTaskRowAction =
| 'start'
| 'complete-note'
| 'reschedule'
| 'move-today'
| 'skip'
| 'cancel';
const props = withDefaults(
defineProps<{
task: RoutineTaskDto;
busy?: boolean;
showAssignee?: boolean;
showDate?: boolean;
showDelay?: boolean;
}>(),
{ busy: false, showAssignee: false, showDate: false, showDelay: false },
);
const emit = defineEmits<{
toggle: [task: RoutineTaskDto];
open: [task: RoutineTaskDto];
action: [kind: RoutineTaskRowAction, task: RoutineTaskDto];
}>();
const menuOpen = ref(false);
const done = computed(() => props.task.status === 'completed');
const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled');
const open = computed(() => isOpenStatus(props.task.status));
const showTime = computed(() => hasExplicitTime(props.task));
const checkLabel = computed(() =>
done.value ? `برگرداندن «${props.task.title}» به حالت انجام‌نشده` : `علامت‌زدن «${props.task.title}» به‌عنوان انجام‌شده`,
);
function act(kind: RoutineTaskRowAction) {
emit('action', kind, props.task);
}
</script>
<template>
<article
class="rt-row"
:class="[`st-${task.status}`, { done, closed, busy }]"
@click="emit('open', task)"
>
<button
type="button"
class="rt-check"
:class="{ on: done }"
:disabled="busy || closed"
:aria-pressed="done"
:aria-label="checkLabel"
@click.stop="emit('toggle', task)"
>
<span class="box">
<UiIcon v-if="done" name="check-plain" :size="16" />
<span v-else-if="busy" class="spin" aria-hidden="true" />
</span>
</button>
<div class="rt-main">
<div class="rt-title-line">
<h3 class="rt-title">{{ task.title }}</h3>
<UiIcon v-if="task.isMandatory" class="mand" name="flag" :size="13" title="اجباری" />
</div>
<p v-if="task.description" class="rt-desc">{{ task.description }}</p>
<div class="rt-meta">
<span v-if="showDate" class="meta-item">
<UiIcon name="calendar" :size="13" />
{{ formatDayNumeric(task.scheduledFor) }}
</span>
<span v-if="showTime" class="meta-item">
<UiIcon name="clock" :size="13" />
{{ formatTime(task.dueAt) }}
</span>
<span v-if="showDelay && task.status === 'overdue'" class="meta-item late">
<UiIcon name="warning" :size="13" />
{{ formatDelay(task.dueAt) }} تأخیر
</span>
<span v-if="done && task.completedAt" class="meta-item ok">
<UiIcon name="check" :size="13" />
{{ formatTime(task.completedAt) }}
</span>
<span v-if="task.category" class="cat-chip">
<i :style="{ background: task.category.color }" />
{{ task.category.name }}
</span>
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[task.priority]">
{{ PRIORITY_LABELS[task.priority] }}
</UiBadge>
<UiBadge v-if="task.status !== 'todo'" size="sm" :variant="STATUS_VARIANTS[task.status]">
{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}
</UiBadge>
<span class="src-chip" :title="`منبع: ${ROUTINE_TASK_SOURCE_LABELS[task.source]}`">
<UiIcon :name="task.source === 'routine' ? 'refresh' : 'pin'" :size="12" />
{{ ROUTINE_TASK_SOURCE_LABELS[task.source] }}
</span>
<RouterLink
v-for="ent in task.entities"
:key="`${ent.type}-${ent.id}`"
class="ent-link"
:to="entityRoute(ent)"
@click.stop
>
<span class="ent-type">{{ ENTITY_TYPE_LABELS[ent.type] }}</span>
{{ ent.label }}
</RouterLink>
</div>
</div>
<div class="rt-side" @click.stop>
<span v-if="showAssignee && task.assignee" class="assignee" :title="task.assignee.fullName">
<UserAvatar :name="task.assignee.fullName" :user-id="task.assignee.id" :size="26" />
<span class="assignee-name">{{ task.assignee.fullName }}</span>
</span>
<UiDropdownMenu v-model:open="menuOpen" align="end">
<template #trigger="{ toggle }">
<button type="button" class="rt-more" aria-label="عملیات بیشتر" @click="toggle">
<UiIcon name="dots" :size="18" />
</button>
</template>
<button type="button" @click="emit('open', task)">جزئیات</button>
<button v-if="task.status === 'todo'" type="button" @click="act('start')">شروع کار</button>
<button v-if="open || done" type="button" @click="act('complete-note')">
{{ done ? 'ویرایش گزارش نتیجه' : 'انجام با ثبت گزارش' }}
</button>
<button v-if="open" type="button" @click="act('reschedule')">تغییر زمان</button>
<button v-if="task.status === 'overdue'" type="button" @click="act('move-today')">انتقال به امروز</button>
<button v-if="open && task.rules.allowSkip" type="button" @click="act('skip')">رد کردن</button>
<button v-if="open" type="button" class="danger" @click="act('cancel')">لغو کار</button>
</UiDropdownMenu>
</div>
</article>
</template>
<style scoped>
.rt-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
cursor: pointer;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), opacity var(--transition-fast);
}
.rt-row:hover {
border-color: var(--line-strong);
box-shadow: var(--shadow-xs);
}
.rt-row.st-overdue {
border-inline-start: 3px solid var(--danger);
}
.rt-row.done {
background: color-mix(in srgb, var(--success-soft) 28%, var(--surface));
}
.rt-row.closed {
opacity: 0.62;
}
.rt-check {
width: 40px;
height: 40px;
margin: -6px;
padding: 0;
border: 0;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
border-radius: var(--radius);
}
.rt-check:disabled {
cursor: not-allowed;
}
.rt-check:focus-visible {
outline: 2px solid var(--accent);
}
.rt-check .box {
width: 24px;
height: 24px;
border-radius: 7px;
border: 2px solid var(--line-strong);
background: var(--surface);
display: grid;
place-items: center;
color: #fff;
transition: background var(--transition-fast), border-color var(--transition-fast), transform 0.12s ease;
}
.rt-check:not(:disabled):hover .box {
border-color: var(--accent);
}
.rt-check:active .box {
transform: scale(0.92);
}
.rt-check.on .box {
background: var(--success);
border-color: var(--success);
}
.st-overdue .rt-check .box {
border-color: color-mix(in srgb, var(--danger) 55%, white);
}
.spin {
width: 12px;
height: 12px;
border: 2px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: rt-spin 0.7s linear infinite;
}
@keyframes rt-spin {
to {
transform: rotate(360deg);
}
}
.rt-main {
min-width: 0;
display: grid;
gap: 4px;
}
.rt-title-line {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.rt-title {
margin: 0;
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
color: var(--ink);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.done .rt-title {
color: var(--muted);
text-decoration: line-through;
text-decoration-color: color-mix(in srgb, var(--muted) 60%, transparent);
}
.mand {
color: var(--warning-deep);
flex-shrink: 0;
}
.rt-desc {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.rt-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
margin-top: 2px;
font-size: var(--fs-xs);
color: var(--muted);
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.meta-item.late {
color: var(--danger);
font-weight: var(--fw-semibold);
}
.meta-item.ok {
color: var(--success-deep);
}
.cat-chip,
.src-chip {
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.cat-chip i {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.src-chip {
color: var(--muted-light);
}
.ent-link {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 220px;
padding: 1px 8px;
border-radius: var(--radius-full);
background: var(--accent-subtle);
color: var(--accent-deep);
font-weight: var(--fw-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ent-link:hover {
background: var(--accent-soft);
}
.ent-type {
color: var(--muted);
font-weight: var(--fw-normal);
}
.rt-side {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.assignee {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-xs);
color: var(--ink-secondary);
max-width: 150px;
}
.assignee-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rt-more {
width: 32px;
height: 32px;
border: 0;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
display: grid;
place-items: center;
cursor: pointer;
}
.rt-more:hover {
background: var(--surface-subtle);
color: var(--ink);
}
:global(.ui-dd-panel button.danger) {
color: var(--danger);
}
@media (max-width: 900px) {
.rt-row {
padding: var(--sp-3);
gap: var(--sp-2);
}
.rt-check {
width: 48px;
height: 48px;
margin: -8px -6px;
}
.rt-check .box {
width: 28px;
height: 28px;
border-radius: 8px;
}
.rt-title {
white-space: normal;
}
.assignee-name {
display: none;
}
}
</style>
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { RoutineMemberStatDto } from '@mento/shared';
import UserAvatar from '@/components/UserAvatar.vue';
import { toFaDigits } from '@/lib/jalali';
import { formatMinutes, formatPercent, percentTone } from '@/lib/routines';
const props = defineProps<{ members: RoutineMemberStatDto[] }>();
const emit = defineEmits<{ select: [userId: string] }>();
type SortKey = 'name' | 'total' | 'completed' | 'overdue' | 'skipped' | 'percent' | 'streak' | 'avg';
const sortKey = ref<SortKey>('percent');
const sortDir = ref<'asc' | 'desc'>('desc');
const columns: Array<{ key: SortKey; label: string }> = [
{ key: 'name', label: 'کاربر' },
{ key: 'total', label: 'کل کارها' },
{ key: 'completed', label: 'انجام شده' },
{ key: 'overdue', label: 'عقب‌افتاده' },
{ key: 'skipped', label: 'رد شده' },
{ key: 'percent', label: 'درصد انجام' },
{ key: 'streak', label: 'روز پیاپی کامل' },
{ key: 'avg', label: 'میانگین زمان انجام' },
];
function value(m: RoutineMemberStatDto, key: SortKey): number | string {
switch (key) {
case 'name':
return m.user.fullName;
case 'percent':
return m.percent ?? -1;
case 'avg':
return m.avgCompletionMinutes ?? Number.MAX_SAFE_INTEGER;
default:
return m[key];
}
}
const rows = computed(() => {
const dir = sortDir.value === 'asc' ? 1 : -1;
return [...props.members].sort((a, b) => {
const va = value(a, sortKey.value);
const vb = value(b, sortKey.value);
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb, 'fa') * dir;
return ((va as number) - (vb as number)) * dir;
});
});
function sortBy(key: SortKey) {
if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
else {
sortKey.value = key;
sortDir.value = key === 'name' || key === 'avg' ? 'asc' : 'desc';
}
}
</script>
<template>
<div class="crm-table-container">
<table class="crm-table">
<thead>
<tr>
<th
v-for="c in columns"
:key="c.key"
:aria-sort="sortKey === c.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'"
>
<button type="button" class="sort" :class="{ on: sortKey === c.key }" @click="sortBy(c.key)">
{{ c.label }}
<span class="arrow">{{ sortKey === c.key ? (sortDir === 'asc' ? '▲' : '▼') : '↕' }}</span>
</button>
</th>
</tr>
</thead>
<tbody>
<tr v-for="m in rows" :key="m.user.id" class="clickable" @click="emit('select', m.user.id)">
<td>
<span class="who">
<UserAvatar :name="m.user.fullName" :user-id="m.user.id" :size="28" />
{{ m.user.fullName }}
</span>
</td>
<td>{{ toFaDigits(m.total) }}</td>
<td class="ok">{{ toFaDigits(m.completed) }}</td>
<td :class="{ bad: m.overdue > 0 }">{{ toFaDigits(m.overdue) }}</td>
<td>{{ toFaDigits(m.skipped) }}</td>
<td>
<span class="pct" :class="`tone-${percentTone(m.percent)}`">
<strong>{{ formatPercent(m.percent) }}</strong>
<i><b :style="{ width: `${m.percent ?? 0}%` }" /></i>
</span>
</td>
<td>{{ toFaDigits(m.streak) }}</td>
<td class="nowrap">{{ formatMinutes(m.avgCompletionMinutes) }}</td>
</tr>
<tr v-if="!rows.length">
<td colspan="8" class="empty">داده‌ای برای این بازه نیست.</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.sort {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-weight: inherit;
cursor: pointer;
padding: 0;
white-space: nowrap;
}
.sort.on {
color: var(--accent-deep);
}
.arrow {
font-size: 0.6rem;
opacity: 0.7;
}
.clickable {
cursor: pointer;
}
.who {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
font-weight: var(--fw-medium);
}
.ok {
color: var(--success-deep);
}
.bad {
color: var(--danger);
font-weight: var(--fw-semibold);
}
.pct {
display: grid;
grid-template-columns: 44px 80px;
align-items: center;
gap: 8px;
}
.pct i {
height: 6px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.pct b {
display: block;
height: 100%;
background: var(--line-strong);
}
.tone-good b {
background: var(--success);
}
.tone-mid b {
background: var(--warning);
}
.tone-low b {
background: var(--danger);
}
.nowrap {
white-space: nowrap;
}
.empty {
text-align: center;
color: var(--muted);
padding: var(--sp-6);
}
</style>
@@ -0,0 +1,301 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { RoutinePeriodTotalsDto, RoutineStatsQuery, RoutineWeeklyReportDto } from '@mento/shared';
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import KpiCard from '@/components/routines/KpiCard.vue';
import { UiButton, UiChart } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { daysBar } from '@/lib/routineCharts';
import {
DAY_STATE_LABELS,
DAY_STATE_SYMBOLS,
addDaysKey,
apiError,
formatDayNumeric,
formatPercent,
todayKey,
weekStart,
} from '@/lib/routines';
const props = defineProps<{
view: 'me' | 'team';
filters?: RoutineStatsQuery;
refreshKey?: number;
}>();
const anchor = ref(weekStart(todayKey()));
const data = ref<RoutineWeeklyReportDto | null>(null);
const loading = ref(false);
const error = ref('');
async function load() {
loading.value = true;
error.value = '';
try {
const rest = { ...(props.filters ?? {}) };
delete rest.from;
delete rest.to;
const res = await api.get<RoutineWeeklyReportDto>('/routines/stats/weekly', {
params: { ...rest, from: anchor.value, view: props.view },
});
data.value = res.data;
} catch (e) {
error.value = apiError(e, 'بارگذاری گزارش هفتگی ممکن نشد.');
} finally {
loading.value = false;
}
}
watch([anchor, () => JSON.stringify(props.filters ?? {}), () => props.refreshKey], load, { immediate: true });
function diff(cur: number | null, prev: number | null) {
if (cur == null || prev == null) return null;
return cur - prev;
}
const t = computed<RoutinePeriodTotalsDto | null>(() => data.value?.totals ?? null);
const p = computed<RoutinePeriodTotalsDto | null>(() => data.value?.previous ?? null);
const chart = computed(() =>
daysBar(
data.value?.days ?? [],
(data.value?.days ?? []).map((_, i) => ROUTINE_WEEKDAY_LABELS[i]!),
),
);
const isCurrent = computed(() => anchor.value === weekStart(todayKey()));
</script>
<template>
<div class="weekly">
<div class="head">
<div class="nav">
<UiButton variant="icon" aria-label="هفته قبل" @click="anchor = addDaysKey(anchor, -7)">
<UiIcon name="chevron-right" :size="18" />
</UiButton>
<strong>
{{ data ? `${formatDayNumeric(data.from)} تا ${formatDayNumeric(data.to)}` : '…' }}
</strong>
<UiButton variant="icon" aria-label="هفته بعد" :disabled="isCurrent" @click="anchor = addDaysKey(anchor, 7)">
<UiIcon name="chevron-left" :size="18" />
</UiButton>
</div>
<span class="cmp">مقایسه با هفته قبل</span>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="kpis" :class="{ dim: loading }">
<KpiCard
label="درصد انجام"
:value="formatPercent(t?.percent)"
icon="target"
tone="accent"
:delta="diff(t?.percent ?? null, p?.percent ?? null)"
delta-suffix="٪"
/>
<KpiCard
label="کارهای انجام‌شده"
:value="toFaDigits(t?.completed ?? 0)"
:hint="`از ${toFaDigits(t?.total ?? 0)}`"
icon="check"
tone="success"
:delta="diff(t?.completed ?? null, p?.completed ?? null)"
/>
<KpiCard
label="عقب‌افتاده"
:value="toFaDigits(t?.overdue ?? 0)"
icon="warning"
tone="danger"
:delta="diff(p?.overdue ?? null, t?.overdue ?? null)"
/>
<KpiCard
label="روزهای کامل"
:value="toFaDigits(t?.completeDays ?? 0)"
:hint="`ناقص ${toFaDigits(t?.partialDays ?? 0)} · از دست رفته ${toFaDigits(t?.missedDays ?? 0)}`"
icon="calendar"
tone="info"
:delta="diff(t?.completeDays ?? null, p?.completeDays ?? null)"
/>
</div>
<div class="cards">
<section class="card">
<h3>انجام کارها در روزهای هفته</h3>
<UiChart :option="chart" height="260px" :loading="loading" />
</section>
<section class="card">
<h3>وضعیت روزها</h3>
<ul class="days">
<li v-for="(d, i) in data?.days ?? []" :key="d.date" :class="`s-${d.state}`">
<span class="sym">{{ DAY_STATE_SYMBOLS[d.state] }}</span>
<span class="lbl">
<strong>{{ ROUTINE_WEEKDAY_LABELS[i] }}</strong>
<small>{{ formatDayNumeric(d.date) }}</small>
</span>
<span class="val">
<template v-if="d.total">{{ toFaDigits(d.completed) }}/{{ toFaDigits(d.total) }} · {{ formatPercent(d.percent) }}</template>
<template v-else>{{ DAY_STATE_LABELS.empty }}</template>
</span>
</li>
</ul>
<p class="avg">
میانگین درصد روزانه: <strong>{{ formatPercent(t?.avgPercent) }}</strong>
<template v-if="p?.avgPercent != null"> (هفته قبل {{ formatPercent(p.avgPercent) }})</template>
</p>
</section>
</div>
</div>
</template>
<style scoped>
.weekly {
display: grid;
gap: var(--sp-4);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-2);
}
.nav {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.nav strong {
min-width: 170px;
text-align: center;
color: var(--ink);
}
.cmp {
font-size: var(--fs-xs);
color: var(--muted);
}
.kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--sp-3);
transition: opacity var(--transition-fast);
}
.dim {
opacity: 0.55;
}
.cards {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: var(--sp-4);
}
.card {
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
display: grid;
gap: var(--sp-3);
align-content: start;
min-width: 0;
}
.card h3 {
margin: 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
}
.days {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.days li {
display: grid;
grid-template-columns: 28px 1fr auto;
align-items: center;
gap: var(--sp-2);
padding: 6px 8px;
border-radius: 8px;
background: var(--surface-subtle);
}
.sym {
width: 24px;
height: 24px;
border-radius: 6px;
display: grid;
place-items: center;
font-weight: var(--fw-bold);
font-size: 0.75rem;
background: var(--surface);
color: var(--muted-light);
}
.s-complete .sym {
background: var(--success-soft);
color: var(--success-deep);
}
.s-partial .sym {
background: var(--warning-soft);
color: var(--warning-deep);
}
.s-missed .sym {
background: var(--danger-soft);
color: var(--danger);
}
.lbl {
display: grid;
}
.lbl strong {
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
}
.lbl small,
.val {
font-size: var(--fs-xs);
color: var(--muted);
}
.avg {
margin: 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.avg strong {
color: var(--ink);
}
.error {
margin: 0;
}
@media (max-width: 1000px) {
.cards {
grid-template-columns: 1fr;
}
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-2);
}
}
</style>
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { onUnmounted, watch } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
const props = withDefaults(
defineProps<{
open: boolean;
title?: string;
width?: string;
}>(),
{ title: '', width: '440px' },
);
const emit = defineEmits<{ close: [] }>();
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close');
}
watch(
() => props.open,
(open) => {
if (open) window.addEventListener('keydown', onKey);
else window.removeEventListener('keydown', onKey);
},
{ immediate: true },
);
onUnmounted(() => window.removeEventListener('keydown', onKey));
</script>
<template>
<Teleport to="body">
<Transition name="ui-drawer">
<div v-if="open" class="ui-drawer-layer">
<button class="ui-drawer-dim" type="button" aria-label="بستن" @click="emit('close')" />
<aside class="ui-drawer" role="dialog" aria-modal="true" :aria-label="title" :style="{ '--drawer-w': width }">
<header class="ui-drawer-head">
<slot name="header">
<h2>{{ title }}</h2>
</slot>
<button type="button" class="ui-drawer-close" aria-label="بستن" @click="emit('close')">
<UiIcon name="x" :size="18" />
</button>
</header>
<div class="ui-drawer-body">
<slot />
</div>
<footer v-if="$slots.footer" class="ui-drawer-foot">
<slot name="footer" />
</footer>
</aside>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.ui-drawer-layer {
position: fixed;
inset: 0;
/* زیر UiModal (۸۰) تا مودال‌های داخل دراور رویش باز شوند */
z-index: 75;
}
.ui-drawer-dim {
position: absolute;
inset: 0;
border: 0;
padding: 0;
background: rgba(15, 23, 42, 0.28);
cursor: default;
}
/* RTL: inset-inline-start = سمت راست صفحه */
.ui-drawer {
position: absolute;
top: 0;
bottom: 0;
inset-inline-start: 0;
width: min(var(--drawer-w), 100vw);
display: flex;
flex-direction: column;
background: var(--surface);
box-shadow: var(--shadow-xl);
border-inline-end: 1px solid var(--line);
}
.ui-drawer-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: var(--sp-4);
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.ui-drawer-head h2 {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-bold);
color: var(--ink);
}
.ui-drawer-close {
width: 36px;
height: 36px;
flex-shrink: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--muted);
display: grid;
place-items: center;
cursor: pointer;
}
.ui-drawer-close:hover {
background: var(--surface-subtle);
color: var(--ink);
}
.ui-drawer-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--sp-4);
}
.ui-drawer-foot {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
border-top: 1px solid var(--line);
flex-shrink: 0;
}
.ui-drawer-enter-active,
.ui-drawer-leave-active {
transition: opacity 0.2s ease;
}
.ui-drawer-enter-active .ui-drawer,
.ui-drawer-leave-active .ui-drawer {
transition: transform 0.22s ease;
}
.ui-drawer-enter-from,
.ui-drawer-leave-to {
opacity: 0;
}
.ui-drawer-enter-from .ui-drawer,
.ui-drawer-leave-to .ui-drawer {
transform: translateX(100%);
}
@media (max-width: 900px) {
.ui-drawer {
top: auto;
inset-inline: 0;
width: 100%;
max-height: 92dvh;
border-radius: 18px 18px 0 0;
border: 0;
}
.ui-drawer-foot {
padding-bottom: calc(var(--sp-3) + env(safe-area-inset-bottom, 0px));
}
.ui-drawer-enter-from .ui-drawer,
.ui-drawer-leave-to .ui-drawer {
transform: translateY(100%);
}
}
</style>
+1
View File
@@ -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';
+41 -1
View File
@@ -38,6 +38,7 @@ const hubStack = ref<NavHubKey[]>([]);
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(() => {
</button>
</div>
<RouterLink
v-if="moduleAllowed('routines')"
to="/routines"
class="nav-item"
title="کارهای روتین"
:class="{ 'router-link-active': routinesActive }"
@click="closeHub(); closeMenu()"
>
<UiIcon name="refresh" :size="collapsed ? 20 : 18" />
<span>کارهای روتین</span>
<span v-if="routineBadge" class="nav-badge">{{ toFaDigits(routineBadge) }}</span>
</RouterLink>
<RouterLink
v-if="moduleAllowed('conversations')"
to="/conversations"
@@ -725,7 +753,8 @@ onUnmounted(() => {
:title="`انقضا: ${subscription.expiresAt}`"
>
<UiIcon name="clock" :size="15" />
<span>
<span v-if="subscription.planCode === 'lifetime'">اشتراک دائمی</span>
<span v-else>
{{ toFaDigits(subscription.daysRemaining) }} روز باقی‌مانده
</span>
</div>
@@ -858,6 +887,17 @@ onUnmounted(() => {
<button class="round-btn sm" type="button" aria-label="بستن" @click="closeMore">✕</button>
</header>
<nav class="more-body">
<RouterLink
v-if="moduleAllowed('routines')"
to="/routines"
class="nav-item"
:class="{ 'router-link-active': routinesActive }"
@click="closeHub(); closeMore()"
>
<UiIcon name="refresh" :size="18" />
<span>کارهای روتین</span>
<span v-if="routineBadge" class="nav-badge">{{ toFaDigits(routineBadge) }}</span>
</RouterLink>
<RouterLink to="/notes" class="nav-item" @click="closeHub(); closeMore()">
<UiIcon name="document" :size="18" />
<span>یادداشت‌ها</span>
+248
View File
@@ -0,0 +1,248 @@
import type {
RoutineCalendarDayDto,
RoutineCategoryStatDto,
RoutineMemberStatDto,
RoutineStatusBreakdownDto,
RoutineTrendPointDto,
} from '@mento/shared';
import type { UiChartOption } from '@/components/ui';
import { toFaDigits } from '@/lib/jalali';
import { formatDayNumeric } from '@/lib/routines';
const FONT = 'IRANSans, Tahoma, sans-serif';
const AXIS_LABEL = { color: '#64748b', fontFamily: FONT, fontSize: 11 };
const SPLIT = { lineStyle: { color: '#f1f5f9', type: 'dashed' as const } };
const LEGEND = { bottom: 0, left: 'center', textStyle: { fontFamily: FONT, fontSize: 11, color: '#475569' } };
export const STATUS_COLORS = {
completed: '#10b981',
notDone: '#94a3b8',
overdue: '#ef4444',
skipped: '#f59e0b',
cancelled: '#cbd5e1',
};
/* eslint-disable @typescript-eslint/no-explicit-any */
/** روند درصد انجام روزانه */
export function trendOption(trend: RoutineTrendPointDto[]): UiChartOption {
return {
textStyle: { fontFamily: FONT },
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params;
const p = trend[item?.dataIndex ?? -1];
if (!p) return '';
return `${formatDayNumeric(p.date)}<br/>درصد انجام: <strong>${p.percent == null ? '—' : `${toFaDigits(p.percent)}٪`}</strong><br/>${toFaDigits(p.completed)} از ${toFaDigits(p.total)}`;
},
},
grid: { left: 8, right: 16, top: 16, bottom: 8, containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: trend.map((p) => formatDayNumeric(p.date)),
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { ...AXIS_LABEL, hideOverlap: true },
},
yAxis: {
type: 'value',
min: 0,
max: 100,
splitLine: SPLIT,
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => `${toFaDigits(v)}٪` },
},
series: [
{
name: 'درصد انجام',
type: 'line',
smooth: true,
connectNulls: true,
showSymbol: false,
itemStyle: { color: '#14b8a6' },
lineStyle: { width: 3, color: '#0d9488' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(20, 184, 166, 0.3)' },
{ offset: 1, color: 'rgba(20, 184, 166, 0.01)' },
],
},
},
data: trend.map((p) => p.percent),
},
],
};
}
/** دونات وضعیت کارها */
export function statusDonut(s: RoutineStatusBreakdownDto): UiChartOption {
const rows = [
{ name: 'انجام شده', value: s.completed, color: STATUS_COLORS.completed },
{ name: 'انجام نشده', value: s.notDone, color: STATUS_COLORS.notDone },
{ name: 'عقب‌افتاده', value: s.overdue, color: STATUS_COLORS.overdue },
{ name: 'رد شده', value: s.skipped, color: STATUS_COLORS.skipped },
].filter((r) => r.value > 0);
return {
textStyle: { fontFamily: FONT },
tooltip: {
trigger: 'item',
formatter: (p: any) => `${p.name}: <strong>${toFaDigits(p.value)}</strong> (${toFaDigits(p.percent)}٪)`,
},
legend: LEGEND,
series: [
{
name: 'وضعیت',
type: 'pie',
radius: ['48%', '72%'],
center: ['50%', '44%'],
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: rows.map((r) => ({ name: r.name, value: r.value, itemStyle: { color: r.color } })),
},
],
};
}
/** میله‌ای انجام به تفکیک دسته */
export function categoryBar(rows: RoutineCategoryStatDto[]): UiChartOption {
return {
textStyle: { fontFamily: FONT },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params;
const r = rows[item?.dataIndex ?? -1];
if (!r) return '';
return `${r.name}<br/>${toFaDigits(r.completed)} از ${toFaDigits(r.total)} · <strong>${r.percent == null ? '—' : `${toFaDigits(r.percent)}٪`}</strong>`;
},
},
grid: { left: 8, right: 16, top: 16, bottom: 8, containLabel: true },
xAxis: {
type: 'category',
data: rows.map((r) => r.name),
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { ...AXIS_LABEL, interval: 0 },
},
yAxis: {
type: 'value',
min: 0,
max: 100,
splitLine: SPLIT,
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => `${toFaDigits(v)}٪` },
},
series: [
{
name: 'درصد انجام',
type: 'bar',
barMaxWidth: 36,
data: rows.map((r) => ({
value: r.percent ?? 0,
itemStyle: { color: r.color, borderRadius: [6, 6, 0, 0] },
})),
},
],
};
}
/** میله‌ای انجام‌شده/کل به تفکیک روز */
export function daysBar(days: RoutineCalendarDayDto[], labels?: string[]): UiChartOption {
return {
textStyle: { fontFamily: FONT },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const list = Array.isArray(params) ? params : [params];
const d = days[list[0]?.dataIndex ?? -1];
if (!d) return '';
return `${formatDayNumeric(d.date)}<br/>انجام شده: <strong>${toFaDigits(d.completed)}</strong> از ${toFaDigits(d.total)}`;
},
},
legend: LEGEND,
grid: { left: 8, right: 16, top: 16, bottom: 32, containLabel: true },
xAxis: {
type: 'category',
data: labels ?? days.map((d) => formatDayNumeric(d.date)),
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { ...AXIS_LABEL, hideOverlap: true },
},
yAxis: {
type: 'value',
minInterval: 1,
splitLine: SPLIT,
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => toFaDigits(v) },
},
series: [
{
name: 'انجام شده',
type: 'bar',
stack: 'all',
barMaxWidth: 28,
itemStyle: { color: STATUS_COLORS.completed },
data: days.map((d) => d.completed),
},
{
name: 'باقی‌مانده',
type: 'bar',
stack: 'all',
barMaxWidth: 28,
itemStyle: { color: '#e2e8f0', borderRadius: [6, 6, 0, 0] },
data: days.map((d) => Math.max(0, d.total - d.completed)),
},
],
};
}
/** میله‌ای افقی مقایسه اعضا */
export function membersBar(members: RoutineMemberStatDto[]): UiChartOption {
const rows = [...members].filter((m) => m.total > 0).sort((a, b) => (a.percent ?? 0) - (b.percent ?? 0));
return {
textStyle: { fontFamily: FONT },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params;
const m = rows[item?.dataIndex ?? -1];
if (!m) return '';
return `${m.user.fullName}<br/>${toFaDigits(m.completed)} از ${toFaDigits(m.total)} · <strong>${m.percent == null ? '—' : `${toFaDigits(m.percent)}٪`}</strong>`;
},
},
grid: { left: 8, right: 24, top: 8, bottom: 8, containLabel: true },
xAxis: {
type: 'value',
min: 0,
max: 100,
splitLine: SPLIT,
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => `${toFaDigits(v)}٪` },
},
yAxis: {
type: 'category',
data: rows.map((m) => m.user.fullName),
axisLabel: AXIS_LABEL,
axisLine: { show: false },
axisTick: { show: false },
},
series: [
{
name: 'درصد انجام',
type: 'bar',
barMaxWidth: 18,
data: rows.map((m) => ({
value: m.percent ?? 0,
itemStyle: {
borderRadius: [0, 6, 6, 0],
color: (m.percent ?? 0) >= 85 ? '#10b981' : (m.percent ?? 0) >= 60 ? '#f59e0b' : '#ef4444',
},
})),
},
],
};
}
+256
View File
@@ -0,0 +1,256 @@
import axios from 'axios';
import type { InjectionKey, Ref } from 'vue';
import type {
ActivityPriority,
RoutineContextDto,
RoutineDayState,
RoutineEntityRefDto,
RoutineTaskDto,
RoutineTaskStatus,
} from '@mento/shared';
import {
addJalaliMonths,
dateToJalali,
formatJalaliLong,
formatJalaliShort,
jalaliMonthLength,
jalaliMonthName,
jalaliToDate,
toFaDigits,
} from '@/lib/jalali';
export {
ROUTINE_ENTITY_LINK_LABELS,
ROUTINE_RECURRENCE_LABELS,
ROUTINE_TASK_SOURCE_LABELS,
ROUTINE_TASK_STATUS_LABELS,
ROUTINE_WEEKDAY_LABELS,
} from '@mento/shared';
type BadgeVariant = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info';
export const PRIORITY_LABELS: Record<ActivityPriority, string> = {
very_low: 'خیلی کم',
low: 'کم',
medium: 'متوسط',
high: 'زیاد',
very_high: 'خیلی زیاد',
};
export const PRIORITY_VARIANTS: Record<ActivityPriority, BadgeVariant> = {
very_low: 'neutral',
low: 'neutral',
medium: 'info',
high: 'warning',
very_high: 'danger',
};
export const STATUS_VARIANTS: Record<RoutineTaskStatus, BadgeVariant> = {
todo: 'neutral',
in_progress: 'info',
completed: 'success',
overdue: 'danger',
skipped: 'warning',
cancelled: 'neutral',
};
export const DAY_STATE_LABELS: Record<RoutineDayState, string> = {
complete: 'روز کامل',
partial: 'روز ناقص',
missed: 'روز از دست رفته',
empty: 'بدون کار',
};
export const DAY_STATE_SYMBOLS: Record<RoutineDayState, string> = {
complete: '✓',
partial: '△',
missed: '×',
empty: '·',
};
/** زمینه مشترک بخش کارهای روتین (کاربران، دسته‌ها، نقش مدیر، رفرش) */
export type RoutinesShellContext = {
context: Ref<RoutineContextDto | null>;
refreshTick: Ref<number>;
notifyChanged: () => void;
openTask: (id: string) => void;
};
export const ROUTINES_CTX: InjectionKey<RoutinesShellContext> = Symbol('routines-ctx');
/* ───────── تاریخ‌ها (کلید روز YYYY-MM-DD به وقت تهران) ───────── */
const TEHRAN_DAY = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const TEHRAN_TIME = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Tehran',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
});
export function todayKey(): string {
return TEHRAN_DAY.format(new Date());
}
export function dayKeyOf(iso: string | Date): string {
return TEHRAN_DAY.format(typeof iso === 'string' ? new Date(iso) : iso);
}
/** Date محلی (نیمه‌شب) برای نمایش شمسی */
export function keyToDate(key: string): Date {
const [y, m, d] = key.split('-').map(Number);
return new Date(y!, m! - 1, d!);
}
export function dateToKey(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
export function addDaysKey(key: string, n: number): string {
const d = keyToDate(key);
d.setDate(d.getDate() + n);
return dateToKey(d);
}
/** ۰ = شنبه */
export function weekdayIndex(key: string): number {
return (keyToDate(key).getDay() + 1) % 7;
}
export function weekStart(key: string): string {
return addDaysKey(key, -weekdayIndex(key));
}
export function jalaliMonthRange(key: string): { from: string; to: string; label: string } {
const { jy, jm } = dateToJalali(keyToDate(key));
const from = dateToKey(jalaliToDate(jy, jm, 1));
const to = dateToKey(jalaliToDate(jy, jm, jalaliMonthLength(jy, jm)));
return { from, to, label: `${jalaliMonthName(jm)} ${toFaDigits(jy)}` };
}
export function shiftJalaliMonth(key: string, delta: number): string {
const { jy, jm } = dateToJalali(keyToDate(key));
const next = addJalaliMonths(jy, jm, delta);
return dateToKey(jalaliToDate(next.jy, next.jm, 1));
}
export function formatDayLong(key: string): string {
return formatJalaliLong(keyToDate(key));
}
export function formatDayShort(key: string): string {
return formatJalaliShort(keyToDate(key));
}
export function formatDayNumeric(key: string): string {
const { jm, jd } = dateToJalali(keyToDate(key));
return `${toFaDigits(jd)} ${jalaliMonthName(jm)}`;
}
export function formatTime(iso: string | null | undefined): string {
if (!iso) return '—';
return toFaDigits(TEHRAN_TIME.format(new Date(iso)));
}
export function timeOf(iso: string | null | undefined): string {
if (!iso) return '';
return TEHRAN_TIME.format(new Date(iso));
}
/** زمان پایان روز بدون ساعت مشخص (۲۳:۵۹) در UI نمایش داده نمی‌شود */
export function hasExplicitTime(task: Pick<RoutineTaskDto, 'dueAt'>): boolean {
return !!task.dueAt && timeOf(task.dueAt) !== '23:59';
}
export function formatDelay(iso: string | null | undefined): string {
if (!iso) return '—';
const diff = Date.now() - new Date(iso).getTime();
if (diff <= 0) return '—';
const minutes = Math.floor(diff / 60_000);
if (minutes < 60) return `${toFaDigits(minutes)} دقیقه`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${toFaDigits(hours)} ساعت`;
return `${toFaDigits(Math.floor(hours / 24))} روز`;
}
export function formatMinutes(min: number | null | undefined): string {
if (min == null) return '—';
if (min < 60) return `${toFaDigits(min)} دقیقه`;
const h = Math.floor(min / 60);
const m = min % 60;
return m ? `${toFaDigits(h)} ساعت و ${toFaDigits(m)} دقیقه` : `${toFaDigits(h)} ساعت`;
}
export function formatPercent(p: number | null | undefined): string {
return p == null ? '—' : `${toFaDigits(p)}٪`;
}
export function percentTone(p: number | null | undefined): 'good' | 'mid' | 'low' | 'none' {
if (p == null) return 'none';
if (p >= 85) return 'good';
if (p >= 60) return 'mid';
return 'low';
}
export function isoToLocalInput(iso: string | null | undefined): { date: string; time: string } {
if (!iso) return { date: '', time: '' };
return { date: dayKeyOf(iso), time: timeOf(iso) };
}
/** ورودی تاریخ/ساعت همیشه به وقت تهران تفسیر می‌شود */
export function localInputToIso(date: string, time: string): string | null {
if (!date) return null;
const d = new Date(`${date}T${time || '23:59'}:00+03:30`);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
/* ───────── پیوند پرونده‌ها ───────── */
export const ENTITY_TYPE_LABELS: Record<RoutineEntityRefDto['type'], string> = {
lead: 'لید',
person: 'مشتری',
company: 'شرکت',
deal: 'معامله',
invoice: 'فاکتور',
activity: 'پیگیری',
};
export function entityRoute(ref: RoutineEntityRefDto): string {
switch (ref.type) {
case 'lead':
return `/leads/${ref.id}`;
case 'person':
return `/contacts/people/${ref.id}`;
case 'company':
return `/contacts/companies/${ref.id}`;
case 'deal':
return `/deals/${ref.id}`;
case 'invoice':
return `/invoices/${ref.id}`;
case 'activity':
return `/activities/${ref.id}`;
default:
return '/';
}
}
export function apiError(e: unknown, fallback: string): string {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
if (Array.isArray(msg)) return msg.join('، ');
if (msg) return msg;
if (!e.response) return 'اتصال به سرور برقرار نشد.';
}
return fallback;
}
export function isOpenStatus(status: RoutineTaskStatus): boolean {
return status === 'todo' || status === 'in_progress' || status === 'overdue';
}
+131
View File
@@ -0,0 +1,131 @@
import { reactive, ref } from 'vue';
import type { RoutineTaskDto } from '@mento/shared';
import { api } from '@/lib/api';
import { apiError, isoToLocalInput, localInputToIso, todayKey } from '@/lib/routines';
export type RoutineTaskModalKind = 'complete' | 'skip' | 'reschedule' | 'cancel';
/**
* اکشن‌های مشترک روی یک Task (تیک، رد، جابه‌جایی، لغو) + وضعیت مودال‌ها.
* `onChanged` بعد از هر تغییر موفق با Task به‌روزشده صدا زده می‌شود.
*/
export function useRoutineTaskActions(onChanged: (task: RoutineTaskDto) => void) {
const busyId = ref<string | null>(null);
const error = ref('');
const modal = reactive({
kind: null as RoutineTaskModalKind | null,
task: null as RoutineTaskDto | null,
note: '',
date: '',
time: '',
saving: false,
error: '',
});
async function run(task: RoutineTaskDto, path: string, body: object = {}) {
busyId.value = task.id;
error.value = '';
try {
const { data } = await api.post<RoutineTaskDto>(`/routine-tasks/${task.id}/${path}`, body);
onChanged(data);
return data;
} catch (e) {
error.value = apiError(e, 'انجام عملیات ممکن نشد.');
return null;
} finally {
busyId.value = null;
}
}
function open(kind: RoutineTaskModalKind, task: RoutineTaskDto) {
const due = isoToLocalInput(task.dueAt);
Object.assign(modal, {
kind,
task,
note: kind === 'complete' ? (task.resultNote ?? '') : '',
date: kind === 'reschedule' ? due.date || todayKey() : '',
time: kind === 'reschedule' ? (due.time === '23:59' ? '' : due.time) : '',
saving: false,
error: '',
});
}
function close() {
modal.kind = null;
modal.task = null;
}
/** تیک یک‌کلیکی؛ اگر گزارش اجباری باشد مودال نتیجه باز می‌شود */
async function toggle(task: RoutineTaskDto) {
if (busyId.value) return;
if (task.status === 'completed') return run(task, 'uncomplete');
if (task.status === 'cancelled' || task.status === 'skipped') return null;
if (task.rules.reportRequired) {
open('complete', task);
return null;
}
return run(task, 'complete');
}
async function submit() {
const task = modal.task;
if (!task || !modal.kind) return;
modal.error = '';
let path = '';
let body: object = {};
if (modal.kind === 'complete') {
if (task.rules.reportRequired && !modal.note.trim()) {
modal.error = 'ثبت گزارش برای این کار الزامی است.';
return;
}
path = 'complete';
body = { note: modal.note.trim() || null };
} else if (modal.kind === 'skip') {
if (task.rules.skipReasonRequired && !modal.note.trim()) {
modal.error = 'ثبت دلیل رد کردن الزامی است.';
return;
}
path = 'skip';
body = { reason: modal.note.trim() || null };
} else if (modal.kind === 'cancel') {
path = 'cancel';
body = { reason: modal.note.trim() || null };
} else {
const dueAt = localInputToIso(modal.date, modal.time);
if (!dueAt) {
modal.error = 'تاریخ جدید را انتخاب کنید.';
return;
}
path = 'reschedule';
body = { dueAt };
}
modal.saving = true;
try {
const { data } = await api.post<RoutineTaskDto>(`/routine-tasks/${task.id}/${path}`, body);
onChanged(data);
close();
} catch (e) {
modal.error = apiError(e, 'ذخیره ممکن نشد.');
} finally {
modal.saving = false;
}
}
return {
busyId,
error,
modal,
toggle,
start: (task: RoutineTaskDto) => run(task, 'start'),
moveToday: (task: RoutineTaskDto) => run(task, 'move-today'),
openComplete: (task: RoutineTaskDto) => open('complete', task),
openSkip: (task: RoutineTaskDto) => open('skip', task),
openReschedule: (task: RoutineTaskDto) => open('reschedule', task),
openCancel: (task: RoutineTaskDto) => open('cancel', task),
close,
submit,
};
}
export type RoutineTaskActions = ReturnType<typeof useRoutineTaskActions>;
+56
View File
@@ -132,6 +132,62 @@ export const router = createRouter({
name: 'activity-detail',
component: () => import('@/views/ActivityDetailView.vue'),
},
{
path: 'routines',
component: () => import('@/views/routines/RoutinesShellView.vue'),
children: [
{
path: '',
name: 'routines-today',
component: () => import('@/views/routines/RoutinesTodayView.vue'),
},
{
path: 'overdue',
name: 'routines-overdue',
component: () => import('@/views/routines/RoutinesOverdueView.vue'),
},
{
path: 'calendar',
name: 'routines-calendar',
component: () => import('@/views/routines/RoutinesCalendarView.vue'),
},
{
path: 'list',
name: 'routines-list',
component: () => import('@/views/routines/RoutinesListView.vue'),
},
{
path: 'me',
name: 'routines-me',
component: () => import('@/views/routines/RoutinesMyPerformanceView.vue'),
},
{
path: 'team',
name: 'routines-team',
component: () => import('@/views/routines/RoutinesTeamView.vue'),
},
{
path: 'manage',
name: 'routines-manage',
component: () => import('@/views/routines/RoutinesManageView.vue'),
},
{
path: 'manage/new',
name: 'routine-new',
component: () => import('@/views/routines/RoutineFormView.vue'),
},
{
path: 'manage/:id/edit',
name: 'routine-edit',
component: () => import('@/views/routines/RoutineFormView.vue'),
},
{
path: 'settings',
name: 'routines-settings',
component: () => import('@/views/routines/RoutinesSettingsView.vue'),
},
],
},
{
path: 'quotes',
name: 'quotes',
+26
View File
@@ -223,6 +223,29 @@ async function remove() {
}
}
const routinesEnabled = computed(() => {
const mods = auth.user?.enabledModules;
return !mods || mods.includes('routines');
});
const converting = ref(false);
/** پیگیری → Task در «کارهای روتین» (تکراری نمی‌سازد) */
async function convertToTask() {
if (!id.value || converting.value) return;
converting.value = true;
error.value = '';
try {
const { data } = await api.post<{ id: string }>(`/routine-tasks/from-activity/${id.value}`);
moreOpen.value = false;
void router.push({ path: '/routines', query: { task: data.id } });
} catch (e) {
const msg = (e as { response?: { data?: { message?: string | string[] } } }).response?.data?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'تبدیل به Task ممکن نشد.');
} finally {
converting.value = false;
}
}
watch(id, () => {
commentOpen.value = false;
postponeOpen.value = false;
@@ -247,6 +270,9 @@ onMounted(() => {
</button>
<div v-if="moreOpen && activity" class="menu">
<RouterLink :to="{ name: 'activity-edit', params: { id } }">ویرایش</RouterLink>
<button v-if="routinesEnabled" type="button" :disabled="converting" @click="convertToTask">
تبدیل به Task
</button>
<button type="button" class="danger" @click="remove">حذف</button>
</div>
</div>
+2
View File
@@ -6,6 +6,7 @@ import { api } from '@/lib/api';
import type { CompanyDto, CreateCompanyRequest } from '@mento/shared';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { formatLocalDateInput, parseLocalInput } from '@/lib/jalali';
import {
@@ -347,6 +348,7 @@ onMounted(load);
</UiCard>
<CustomFieldsSection entity-type="company" v-model="customFields" />
<RelatedTasksPanel v-if="!isNew && id" :entity="{ type: 'company', id, label: name }" />
<EntityTimeline v-if="!isNew && id" entity-type="company" :entity-id="id" />
<div class="form-bottom-actions">
+8
View File
@@ -8,6 +8,7 @@ import { toFaDigits } from '@/lib/jalali';
import { currencySuffix, formatMoney } from '@/lib/money';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import UiIcon from '@/components/UiIcon.vue';
@@ -813,6 +814,13 @@ onMounted(load);
/>
</div>
<RelatedTasksPanel
v-if="!isNew && id"
class="timeline"
:class="{ hide: tab !== 'main' }"
:entity="{ type: 'deal', id, label: title }"
/>
<EntityTimeline
v-if="!isNew && id"
class="timeline"
+3
View File
@@ -15,6 +15,7 @@ import {
UiPageHeader,
UiSelect,
} from '@/components/ui';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import type {
ConvertLeadResult,
LeadDto,
@@ -391,6 +392,8 @@ onMounted(load);
</dl>
</UiCard>
<RelatedTasksPanel :entity="{ type: 'lead', id: lead.id, label: lead.title }" />
<UiCard title="جدول زمانی">
<ul v-if="lead.events.length" class="timeline">
<li v-for="ev in lead.events" :key="ev.id">
+8 -1
View File
@@ -24,6 +24,7 @@ import ProfileEmptyState from '@/components/person-profile/ProfileEmptyState.vue
import ProfileMoreMenu from '@/components/person-profile/ProfileMoreMenu.vue';
import PersonAssetsPanel from '@/components/person-assets/PersonAssetsPanel.vue';
import InternalPartyBalance from '@/components/person-assets/InternalPartyBalance.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import type { ProfileMoreItem } from '@/components/person-profile/ProfileMoreMenu.vue';
import type {
ActivityDto,
@@ -44,7 +45,7 @@ import type {
} from '@mento/shared';
import { PERSON_MOOD_LABELS } from '@/lib/person-mood';
type TabKey = 'activities' | 'deals' | 'cards' | 'assets' | 'info';
type TabKey = 'activities' | 'tasks' | 'deals' | 'cards' | 'assets' | 'info';
type TimelineTone = 'call' | 'quote' | 'note' | 'meeting' | 'task';
type HistoryFilter = 'all' | TimelineTone;
@@ -67,6 +68,7 @@ type AttachmentRow = {
const TABS: Array<{ key: TabKey; label: string }> = [
{ key: 'activities', label: 'فعالیت‌ها' },
{ key: 'tasks', label: 'کارهای مرتبط' },
{ key: 'deals', label: 'معاملات' },
{ key: 'cards', label: 'کارت‌ها' },
{ key: 'assets', label: 'دارایی' },
@@ -389,6 +391,7 @@ function readTab() {
const q = route.query.tab;
if (
q === 'activities' ||
q === 'tasks' ||
q === 'deals' ||
q === 'cards' ||
q === 'assets' ||
@@ -928,6 +931,10 @@ watch(() => route.query.tab, readTab, { immediate: true });
</div>
</div>
<div v-if="tab === 'tasks'" class="panel">
<RelatedTasksPanel :entity="{ type: 'person', id, label: fullName }" />
</div>
<div v-show="tab === 'assets'" class="panel assets-panel">
<InternalPartyBalance :person-id="id" />
<PersonAssetsPanel :person-id="id" @link-changed="onTahesabLinkChanged" />
+6 -2
View File
@@ -1,6 +1,10 @@
<script setup lang="ts">
import type { BillingAccountDto, BillingPlanCode } from '@mento/shared';
import { BILLING_PLAN_CODES, BILLING_PLAN_LABELS } from '@mento/shared';
import {
BILLING_PLAN_CODES,
BILLING_PLAN_LABELS,
isPermanentBillingPlan,
} from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
defineProps<{
@@ -20,7 +24,7 @@ const emit = defineEmits<{
<p v-if="billing" class="muted">
{{ BILLING_PLAN_LABELS[billing.planCode] }}
· {{ billing.status }}
· {{ toFaDigits(billing.daysRemaining) }} روز
· {{ isPermanentBillingPlan(billing.planCode) ? 'بدون انقضا' : `${toFaDigits(billing.daysRemaining)} روز` }}
· سقف محیط {{ toFaDigits(billing.maxOrganizations) }}
</p>
<p v-else class="muted">هنوز پلن ندارد — یکی از پلن‌ها را اعمال کنید.</p>
@@ -20,6 +20,7 @@ import {
USER_ROLE_LABELS,
USER_ROLES,
isBusinessField,
isPermanentBillingPlan,
} from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
@@ -89,6 +90,27 @@ function resetCreateForm() {
createForm.ownerMobile = '';
}
function subscriptionBadge(org: PlatformOrganizationDto): {
label: string;
tone: 'ok' | 'warn' | 'expired' | 'none';
} {
const sub = org.subscription;
if (!sub) return { label: 'بدون اشتراک', tone: 'none' };
const plan = BILLING_PLAN_LABELS[sub.planCode] ?? sub.planCode;
if (sub.status === 'suspended') return { label: `${plan} · معلق`, tone: 'expired' };
if (isPermanentBillingPlan(sub.planCode)) return { label: plan, tone: 'ok' };
if (sub.status === 'expired' || sub.daysRemaining <= 0) {
return {
label: `${plan} · منقضی شده (${formatJalaliNumeric(new Date(sub.expiresAt))})`,
tone: 'expired',
};
}
return {
label: `${plan} · ${toFaDigits(sub.daysRemaining)} روز مانده`,
tone: sub.daysRemaining <= 3 ? 'warn' : 'ok',
};
}
async function load() {
loading.value = true;
error.value = '';
@@ -356,6 +378,11 @@ async function applyPlan(userId: string, planCode: BillingPlanCode) {
);
detail.value = data;
}
const { data: list } = await platformApi.get<PlatformOrganizationDto[]>(
'/platform/organizations',
{ params: query.value.trim() ? { q: query.value.trim() } : undefined },
);
items.value = list;
success.value = `پلن «${BILLING_PLAN_LABELS[planCode]}» اعمال شد`;
} catch (e) {
error.value = apiError(e, 'اعمال پلن ناموفق بود');
@@ -438,6 +465,9 @@ onMounted(() => {
</div>
<div class="meta">
<em :class="org.isActive ? 'on' : 'off'">{{ org.isActive ? 'فعال' : 'غیرفعال' }}</em>
<b class="sub-badge" :class="subscriptionBadge(org).tone">
{{ subscriptionBadge(org).label }}
</b>
<small>
{{ toFaDigits(org.memberCount) }} عضو ·
{{ toFaDigits(org.personCount) }} شخص ·
@@ -779,6 +809,40 @@ label span {
text-align: end;
}
.sub-badge {
display: inline-block;
margin-inline-start: 0.4rem;
padding: 0.12rem 0.5rem;
border-radius: 999px;
font-size: 0.74rem;
font-weight: 700;
border: 1px solid transparent;
}
.sub-badge.ok {
color: var(--success-deep);
background: #ecfdf5;
border-color: #a7f3d0;
}
.sub-badge.warn {
color: #b45309;
background: #fffbeb;
border-color: #fde68a;
}
.sub-badge.expired {
color: var(--danger);
background: #fef2f2;
border-color: #fecaca;
}
.sub-badge.none {
color: var(--muted);
background: #f1f5f9;
border-color: var(--line);
}
.actions {
display: flex;
gap: 0.45rem;
@@ -0,0 +1,530 @@
<script setup lang="ts">
import { computed, inject, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { ActivityPriority, RoutineDto, RoutineEntityLink, SaveRoutineRequest } from '@mento/shared';
import { ROUTINE_ENTITY_LINK_LABELS, ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import RecurrencePicker, { type RecurrenceValue } from '@/components/routines/RecurrencePicker.vue';
import {
UiButton,
UiCard,
UiField,
UiInput,
UiPageHeader,
UiSegmented,
UiSelect,
UiTextarea,
} from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { PRIORITY_LABELS, ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines';
const shell = inject(ROUTINES_CTX)!;
const route = useRoute();
const router = useRouter();
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : null));
const isEdit = computed(() => !!id.value);
const form = reactive({
title: '',
description: '',
target: 'user' as 'user' | 'team',
assigneeId: '',
teamId: '',
categoryId: '',
priority: 'medium' as ActivityPriority,
timeOfDay: '09:00',
durationMinutes: '' as string | number,
startDate: todayKey(),
endDate: '',
isMandatory: true,
allowSkip: false,
skipReasonRequired: true,
reportRequired: false,
allowAfterDeadline: true,
carryOverOverdue: false,
entityLink: 'none' as RoutineEntityLink,
reminderMinutesBefore: '' as string,
isActive: true,
});
const recurrence = ref<RecurrenceValue>({
recurrence: 'daily',
weekdays: [],
intervalDays: null,
monthDay: null,
specificDates: [],
});
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const targetOptions = [
{ value: 'user' as const, label: 'یک کاربر' },
{ value: 'team' as const, label: 'همه اعضای یک تیم' },
];
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
const entityLinks = Object.entries(ROUTINE_ENTITY_LINK_LABELS) as Array<[RoutineEntityLink, string]>;
const reminderOptions = [
{ value: '', label: 'طبق تنظیمات هر کاربر' },
{ value: '0', label: 'سر ساعت' },
{ value: '10', label: '۱۰ دقیقه قبل' },
{ value: '15', label: '۱۵ دقیقه قبل' },
{ value: '30', label: '۳۰ دقیقه قبل' },
{ value: '60', label: '۱ ساعت قبل' },
{ value: '120', label: '۲ ساعت قبل' },
];
function fill(r: RoutineDto) {
Object.assign(form, {
title: r.title,
description: r.description ?? '',
target: r.team ? 'team' : 'user',
assigneeId: r.assignee?.id ?? '',
teamId: r.team?.id ?? '',
categoryId: r.category?.id ?? '',
priority: r.priority,
timeOfDay: r.timeOfDay,
durationMinutes: r.durationMinutes ?? '',
startDate: r.startDate,
endDate: r.endDate ?? '',
isMandatory: r.isMandatory,
allowSkip: r.allowSkip,
skipReasonRequired: r.skipReasonRequired,
reportRequired: r.reportRequired,
allowAfterDeadline: r.allowAfterDeadline,
carryOverOverdue: r.carryOverOverdue,
entityLink: r.entityLink,
reminderMinutesBefore: r.reminderMinutesBefore == null ? '' : String(r.reminderMinutesBefore),
isActive: r.isActive,
});
recurrence.value = {
recurrence: r.recurrence,
weekdays: [...r.weekdays],
intervalDays: r.intervalDays,
monthDay: r.monthDay,
specificDates: [...r.specificDates],
};
}
onMounted(async () => {
if (!id.value) return;
loading.value = true;
try {
const { data } = await api.get<RoutineDto>(`/routines/${id.value}`);
fill(data);
} catch (e) {
error.value = apiError(e, 'روتین پیدا نشد.');
} finally {
loading.value = false;
}
});
const users = computed(() => shell.context.value?.users ?? []);
const teams = computed(() => shell.context.value?.teams ?? []);
const categories = computed(() => shell.context.value?.categories ?? []);
function joinFa(items: string[]) {
if (items.length <= 1) return items.join('');
return `${items.slice(0, -1).join('، ')} و ${items[items.length - 1]}`;
}
const summary = computed(() => {
const r = recurrence.value;
const days = r.weekdays.map((d) => ROUTINE_WEEKDAY_LABELS[d]!);
let rec = '';
switch (r.recurrence) {
case 'daily':
rec = 'هر روز';
break;
case 'weekdays':
case 'weekly':
rec = days.length ? `هر ${joinFa(days)}` : 'روز هفته انتخاب نشده';
break;
case 'biweekly':
rec = days.length ? `یک هفته در میان، ${joinFa(days)}` : 'روز هفته انتخاب نشده';
break;
case 'monthly':
rec = r.monthDay === 1 ? 'اول هر ماه' : `روز ${toFaDigits(r.monthDay ?? 1)} هر ماه`;
break;
case 'every_n_days':
rec = `هر ${toFaDigits(r.intervalDays ?? 1)} روز یک‌بار`;
break;
case 'specific_dates':
rec = `${toFaDigits(r.specificDates.length)} تاریخ مشخص`;
break;
}
const who =
form.target === 'team'
? teams.value.find((t) => t.id === form.teamId)?.name
? `برای همه اعضای تیم «${teams.value.find((t) => t.id === form.teamId)?.name}»`
: ''
: users.value.find((u) => u.id === form.assigneeId)?.fullName
? `برای ${users.value.find((u) => u.id === form.assigneeId)?.fullName}`
: '';
return `${rec}، ساعت ${toFaDigits(form.timeOfDay || '—')}${who ? ` ${who}` : ''} · از ${formatDayNumeric(form.startDate)}${form.endDate ? ` تا ${formatDayNumeric(form.endDate)}` : ''}`;
});
function validate(): string | null {
if (form.title.trim().length < 2) return 'عنوان روتین را وارد کنید.';
if (form.target === 'user' && !form.assigneeId) return 'مسئول روتین را انتخاب کنید.';
if (form.target === 'team' && !form.teamId) return 'تیم را انتخاب کنید.';
if (!/^\d{2}:\d{2}$/.test(form.timeOfDay)) return 'ساعت انجام را وارد کنید.';
if (!form.startDate) return 'تاریخ شروع را انتخاب کنید.';
if (form.endDate && form.endDate < form.startDate) return 'تاریخ پایان نباید قبل از تاریخ شروع باشد.';
const r = recurrence.value;
if (['weekdays', 'weekly', 'biweekly'].includes(r.recurrence) && !r.weekdays.length) {
return 'حداقل یک روز هفته را انتخاب کنید.';
}
if (r.recurrence === 'every_n_days' && !r.intervalDays) return 'فاصله تکرار را وارد کنید.';
if (r.recurrence === 'specific_dates' && !r.specificDates.length) return 'حداقل یک تاریخ اضافه کنید.';
return null;
}
async function save() {
error.value = validate() ?? '';
if (error.value) return;
const r = recurrence.value;
const duration = Number(form.durationMinutes);
const body: SaveRoutineRequest = {
title: form.title.trim(),
description: form.description.trim() || null,
assigneeId: form.target === 'user' ? form.assigneeId : null,
teamId: form.target === 'team' ? form.teamId : null,
categoryId: form.categoryId || null,
priority: form.priority,
timeOfDay: form.timeOfDay,
durationMinutes: duration > 0 ? Math.floor(duration) : null,
startDate: form.startDate,
endDate: form.endDate || null,
recurrence: r.recurrence,
weekdays: ['weekdays', 'weekly', 'biweekly'].includes(r.recurrence) ? r.weekdays : [],
intervalDays: r.recurrence === 'every_n_days' ? r.intervalDays : null,
monthDay: r.recurrence === 'monthly' ? r.monthDay : null,
specificDates: r.recurrence === 'specific_dates' ? r.specificDates : [],
isMandatory: form.isMandatory,
allowSkip: form.allowSkip,
skipReasonRequired: form.allowSkip && form.skipReasonRequired,
reportRequired: form.reportRequired,
allowAfterDeadline: form.allowAfterDeadline,
carryOverOverdue: form.carryOverOverdue,
entityLink: form.entityLink,
reminderMinutesBefore: form.reminderMinutesBefore === '' ? null : Number(form.reminderMinutesBefore),
isActive: form.isActive,
};
saving.value = true;
try {
if (isEdit.value) await api.patch(`/routines/${id.value}`, body);
else await api.post('/routines', body);
shell.notifyChanged();
void router.push('/routines/manage');
} catch (e) {
error.value = apiError(e, 'ذخیره روتین ممکن نشد.');
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="rform">
<UiPageHeader
:title="isEdit ? 'ویرایش روتین' : 'روتین جدید'"
lead="کاری که باید به‌صورت منظم انجام شود؛ هر روزِ تکرار، یک کار برای مسئول ساخته می‌شود."
back-to="/routines/manage"
/>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<form v-else class="grid" @submit.prevent="save">
<div class="col">
<UiCard>
<h3 class="sec-title">اطلاعات پایه</h3>
<div class="fields">
<UiField label="عنوان" required>
<UiInput v-model="form.title" placeholder="مثلاً: تماس با لیدهای جدید" :maxlength="200" />
</UiField>
<UiField label="توضیحات">
<UiTextarea v-model="form.description" :rows="3" placeholder="جزئیات یا دستورالعمل انجام کار" />
</UiField>
<div class="grid-2">
<UiField label="دسته‌بندی">
<UiSelect v-model="form.categoryId">
<option value="">بدون دسته</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</UiSelect>
</UiField>
<UiField label="اولویت">
<UiSelect v-model="form.priority">
<option v-for="[k, label] in priorities" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</UiField>
</div>
</div>
</UiCard>
<UiCard>
<h3 class="sec-title">مسئول</h3>
<div class="fields">
<UiSegmented v-model="form.target" :options="targetOptions" />
<UiField v-if="form.target === 'user'" label="کاربر" required>
<UiSelect v-model="form.assigneeId">
<option value="" disabled>انتخاب کنید</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</UiSelect>
</UiField>
<UiField v-else label="تیم" required hint="برای هر عضو فعال تیم یک کار جداگانه ساخته می‌شود.">
<UiSelect v-model="form.teamId">
<option value="" disabled>انتخاب کنید</option>
<option v-for="t in teams" :key="t.id" :value="t.id">{{ t.name }}</option>
</UiSelect>
</UiField>
</div>
</UiCard>
<UiCard>
<h3 class="sec-title">زمان‌بندی و تکرار</h3>
<div class="fields">
<div class="grid-2">
<UiField label="ساعت انجام" required>
<JalaliDateInput v-model="form.timeOfDay" mode="time" :clearable="false" />
</UiField>
<UiField label="مدت تقریبی (دقیقه)">
<UiInput v-model="form.durationMinutes" type="number" placeholder="اختیاری" />
</UiField>
<UiField label="تاریخ شروع" required>
<JalaliDateInput v-model="form.startDate" mode="date" :clearable="false" />
</UiField>
<UiField label="تاریخ پایان" hint="خالی = بدون پایان">
<JalaliDateInput v-model="form.endDate" mode="date" />
</UiField>
</div>
<UiField label="الگوی تکرار" required>
<RecurrencePicker v-model="recurrence" />
</UiField>
</div>
</UiCard>
</div>
<div class="col side">
<UiCard>
<h3 class="sec-title">قوانین انجام</h3>
<div class="toggles">
<label class="tg">
<input v-model="form.isMandatory" type="checkbox" />
<span>
<strong>اجباری</strong>
<small>در امتیاز روزانه و روزهای پیاپی کامل حساب می‌شود.</small>
</span>
</label>
<label class="tg">
<input v-model="form.allowSkip" type="checkbox" />
<span>
<strong>امکان رد کردن</strong>
<small>کاربر می‌تواند این کار را برای یک روز رد کند.</small>
</span>
</label>
<label v-if="form.allowSkip" class="tg nested">
<input v-model="form.skipReasonRequired" type="checkbox" />
<span><strong>ثبت دلیل رد کردن الزامی باشد</strong></span>
</label>
<label class="tg">
<input v-model="form.reportRequired" type="checkbox" />
<span>
<strong>گزارش نتیجه الزامی</strong>
<small>قبل از انجام‌شدن، کاربر باید نتیجه را بنویسد.</small>
</span>
</label>
<label class="tg">
<input v-model="form.allowAfterDeadline" type="checkbox" />
<span>
<strong>انجام بعد از مهلت مجاز باشد</strong>
<small>اگر خاموش باشد، کار عقب‌افتاده فقط توسط مدیر بسته می‌شود.</small>
</span>
</label>
<label class="tg">
<input v-model="form.carryOverOverdue" type="checkbox" />
<span>
<strong>انتقال به روز بعد</strong>
<small>کار انجام‌نشده در فهرست «کارهای امروز» روز بعد هم نمایش داده می‌شود.</small>
</span>
</label>
</div>
<UiField label="اتصال به پرونده" class="mt">
<UiSelect v-model="form.entityLink">
<option v-for="[k, label] in entityLinks" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</UiField>
</UiCard>
<UiCard>
<h3 class="sec-title">یادآوری و وضعیت</h3>
<div class="fields">
<UiField label="یادآوری">
<UiSelect v-model="form.reminderMinutesBefore">
<option v-for="o in reminderOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
</UiSelect>
</UiField>
<label class="tg">
<input v-model="form.isActive" type="checkbox" />
<span><strong>روتین فعال است</strong></span>
</label>
</div>
</UiCard>
<div class="summary">
<span>خلاصه</span>
<p>{{ summary }}</p>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<UiButton type="submit" :loading="saving" block>{{ isEdit ? 'ذخیره تغییرات' : 'ایجاد روتین' }}</UiButton>
<UiButton variant="ghost" block @click="router.push('/routines/manage')">انصراف</UiButton>
</div>
</div>
</form>
</div>
</template>
<style scoped>
.rform {
display: grid;
gap: var(--sp-4);
}
.state {
padding: var(--sp-8);
text-align: center;
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: var(--sp-4);
align-items: start;
}
.col {
display: grid;
gap: var(--sp-4);
min-width: 0;
}
.side {
position: sticky;
top: var(--sp-4);
}
.sec-title {
margin: 0 0 var(--sp-4);
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.fields {
display: grid;
gap: var(--sp-4);
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.toggles {
display: grid;
gap: var(--sp-3);
}
.tg {
display: flex;
align-items: flex-start;
gap: var(--sp-2);
cursor: pointer;
}
.tg.nested {
margin-inline-start: 26px;
}
.tg input {
width: 18px;
height: 18px;
margin-top: 2px;
accent-color: var(--accent);
flex-shrink: 0;
}
.tg span {
display: grid;
gap: 2px;
}
.tg strong {
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--ink);
}
.tg small {
font-size: var(--fs-xs);
color: var(--muted);
line-height: 1.6;
}
.mt {
margin-top: var(--sp-4);
}
.summary {
padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius);
background: var(--accent-subtle);
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
}
.summary span {
font-size: var(--fs-xs);
color: var(--accent-deep);
font-weight: var(--fw-semibold);
}
.summary p {
margin: 4px 0 0;
font-size: var(--fs-sm);
color: var(--ink);
line-height: 1.8;
}
.actions {
display: grid;
gap: var(--sp-2);
}
.error {
margin: 0;
}
@media (max-width: 1100px) {
.grid {
grid-template-columns: 1fr;
}
.side {
position: static;
}
}
@media (max-width: 600px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,553 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref, watch } from 'vue';
import type { RoutineCalendarDayDto, RoutineCalendarDto, RoutineTaskDto } from '@mento/shared';
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import RoutineMonthGrid from '@/components/routines/RoutineMonthGrid.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import { UiButton, UiEmptyState, UiSegmented, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
DAY_STATE_LABELS,
ROUTINES_CTX,
addDaysKey,
apiError,
formatDayLong,
formatDayNumeric,
formatPercent,
formatTime,
hasExplicitTime,
jalaliMonthRange,
shiftJalaliMonth,
todayKey,
weekStart,
} from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
type View = 'day' | 'week' | 'month';
const shell = inject(ROUTINES_CTX)!;
const view = ref<View>('month');
const anchor = ref(todayKey());
const selected = ref(todayKey());
const userId = ref('');
const cal = ref<RoutineCalendarDto>({ days: [], tasks: [] });
const loading = ref(true);
const error = ref('');
const viewOptions = [
{ value: 'day' as const, label: 'روز' },
{ value: 'week' as const, label: 'هفته' },
{ value: 'month' as const, label: 'ماه' },
];
const range = computed(() => {
if (view.value === 'day') return { from: anchor.value, to: anchor.value, label: formatDayLong(anchor.value) };
if (view.value === 'week') {
const from = weekStart(anchor.value);
const to = addDaysKey(from, 6);
return { from, to, label: `${formatDayNumeric(from)} تا ${formatDayNumeric(to)}` };
}
return jalaliMonthRange(anchor.value);
});
async function load(silent = false) {
if (!silent) loading.value = true;
error.value = '';
try {
const { data } = await api.get<RoutineCalendarDto>('/routine-tasks/calendar', {
params: {
from: range.value.from,
to: range.value.to,
...(userId.value ? { userId: userId.value } : {}),
},
});
cal.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری تقویم ممکن نشد.');
} finally {
loading.value = false;
}
}
watch(view, (v) => {
if (v !== 'month') anchor.value = selected.value;
});
onMounted(() => load());
watch([() => range.value.from, () => range.value.to, userId], () => load());
watch(shell.refreshTick, () => load(true));
function move(delta: number) {
if (view.value === 'day') anchor.value = addDaysKey(anchor.value, delta);
else if (view.value === 'week') anchor.value = addDaysKey(anchor.value, delta * 7);
else anchor.value = shiftJalaliMonth(anchor.value, delta);
if (view.value === 'day') selected.value = anchor.value;
}
function goToday() {
anchor.value = todayKey();
selected.value = todayKey();
}
function selectDay(day: string) {
selected.value = day;
}
function openDay(day: string) {
anchor.value = day;
selected.value = day;
view.value = 'day';
}
const tasksByDay = computed(() => {
const map = new Map<string, RoutineTaskDto[]>();
for (const t of cal.value.tasks) {
const list = map.get(t.scheduledFor) ?? [];
list.push(t);
map.set(t.scheduledFor, list);
}
return map;
});
const dayMap = computed(() => new Map(cal.value.days.map((d) => [d.date, d])));
const weekDays = computed(() => {
const out: Array<{ key: string; day?: RoutineCalendarDayDto; tasks: RoutineTaskDto[] }> = [];
for (let i = 0; i < 7; i++) {
const key = addDaysKey(range.value.from, i);
out.push({ key, day: dayMap.value.get(key), tasks: tasksByDay.value.get(key) ?? [] });
}
return out;
});
const focusDay = computed(() => (view.value === 'day' ? anchor.value : selected.value));
const focusTasks = computed(() => tasksByDay.value.get(focusDay.value) ?? []);
const focusStat = computed(() => dayMap.value.get(focusDay.value));
const actions = useRoutineTaskActions((updated) => {
const i = cal.value.tasks.findIndex((t) => t.id === updated.id);
if (i >= 0) cal.value.tasks[i] = updated;
shell.notifyChanged();
});
function onAction(kind: RoutineTaskRowAction, task: RoutineTaskDto) {
if (kind === 'start') void actions.start(task);
else if (kind === 'complete-note') actions.openComplete(task);
else if (kind === 'reschedule') actions.openReschedule(task);
else if (kind === 'move-today') void actions.moveToday(task);
else if (kind === 'skip') actions.openSkip(task);
else actions.openCancel(task);
}
const users = computed(() => shell.context.value?.users ?? []);
const isManager = computed(() => !!shell.context.value?.isManager);
const today = todayKey();
</script>
<template>
<div class="calendar">
<div class="cal-toolbar">
<div class="nav">
<UiButton variant="icon" aria-label="قبلی" @click="move(-1)">
<UiIcon name="chevron-right" :size="18" />
</UiButton>
<strong class="range-label">{{ range.label }}</strong>
<UiButton variant="icon" aria-label="بعدی" @click="move(1)">
<UiIcon name="chevron-left" :size="18" />
</UiButton>
<UiButton variant="ghost" size="sm" @click="goToday">امروز</UiButton>
</div>
<div class="tools">
<UiSelect v-if="isManager && users.length > 1" v-model="userId" size="sm" class="who">
<option value="">تقویم خودم</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</UiSelect>
<UiSegmented v-model="view" :options="viewOptions" />
</div>
</div>
<div class="legend">
<span><i class="lg complete" /> روز کامل</span>
<span><i class="lg partial" /> بخشی انجام شده</span>
<span><i class="lg missed" /> انجام نشده</span>
<span><i class="lg empty" /> بدون کار</span>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
<div class="cal-body" :class="{ dim: loading }">
<!-- ماه -->
<div v-if="view === 'month'" class="month-layout">
<RoutineMonthGrid
:from="range.from"
:to="range.to"
:days="cal.days"
:selected="selected"
@select="selectDay"
/>
</div>
<!-- هفته -->
<div v-else-if="view === 'week'" class="week">
<div
v-for="(d, i) in weekDays"
:key="d.key"
class="week-col"
:class="[`s-${d.day?.state ?? 'empty'}`, { today: d.key === today, sel: d.key === selected }]"
>
<button type="button" class="week-head" @click="openDay(d.key)">
<span>{{ ROUTINE_WEEKDAY_LABELS[i] }}</span>
<strong>{{ formatDayNumeric(d.key) }}</strong>
<em v-if="d.day?.total">{{ toFaDigits(d.day.completed) }}/{{ toFaDigits(d.day.total) }}</em>
</button>
<ul class="week-tasks">
<li
v-for="t in d.tasks"
:key="t.id"
:class="`st-${t.status}`"
@click="shell.openTask(t.id)"
>
<span class="wt-dot" />
<span class="wt-title">{{ t.title }}</span>
<small v-if="hasExplicitTime(t)">{{ formatTime(t.dueAt) }}</small>
</li>
<li v-if="!d.tasks.length" class="none">—</li>
</ul>
</div>
</div>
</div>
<!-- جزئیات روز انتخاب‌شده / نمای روز -->
<section v-if="view !== 'week'" class="day-panel">
<header>
<div>
<h3>{{ formatDayLong(focusDay) }}</h3>
<p v-if="focusStat?.total" class="day-sub">
<span :class="`chip s-${focusStat.state}`">{{ DAY_STATE_LABELS[focusStat.state] }}</span>
{{ toFaDigits(focusStat.completed) }} از {{ toFaDigits(focusStat.total) }} انجام شده
· {{ formatPercent(focusStat.percent) }}
</p>
</div>
<UiButton v-if="view === 'month'" variant="ghost" size="sm" @click="openDay(focusDay)">نمای روز</UiButton>
</header>
<div v-if="focusTasks.length" class="rows">
<RoutineTaskRow
v-for="t in focusTasks"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
:show-assignee="!!userId"
@toggle="actions.toggle"
@open="shell.openTask(t.id)"
@action="onAction"
/>
</div>
<UiEmptyState
v-else
icon="calendar"
title="کاری برای این روز نیست"
:description="focusDay > today ? 'کارهای روتین روزهای آینده صبح همان روز ساخته می‌شوند.' : undefined"
/>
</section>
<RoutineTaskModals :actions="actions" />
</div>
</template>
<style scoped>
.calendar {
display: grid;
gap: var(--sp-4);
}
.cal-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-3);
}
.nav {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.range-label {
min-width: 150px;
text-align: center;
font-size: var(--fs-md);
color: var(--ink);
}
.tools {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
}
.who {
width: 200px;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: var(--sp-4);
font-size: var(--fs-xs);
color: var(--muted);
}
.legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.lg {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.lg.complete {
background: var(--success);
}
.lg.partial {
background: var(--warning);
}
.lg.missed {
background: var(--danger);
}
.lg.empty {
background: var(--surface);
border: 1px solid var(--line-strong);
}
.cal-body {
transition: opacity var(--transition-fast);
}
.cal-body.dim {
opacity: 0.55;
}
.week {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
}
.week-col {
display: grid;
grid-template-rows: auto 1fr;
min-height: 260px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
overflow: hidden;
}
.week-col.today {
border-color: var(--accent);
}
.week-head {
display: grid;
gap: 2px;
padding: 8px;
border: 0;
border-bottom: 1px solid var(--line-light);
background: var(--surface-subtle);
text-align: right;
font: inherit;
cursor: pointer;
}
.s-complete .week-head {
background: var(--success-soft);
}
.s-partial .week-head {
background: var(--warning-soft);
}
.s-missed .week-head {
background: var(--danger-soft);
}
.week-head span {
font-size: var(--fs-xs);
color: var(--muted);
}
.week-head strong {
font-size: var(--fs-sm);
color: var(--ink);
}
.week-head em {
font-style: normal;
font-size: var(--fs-xs);
color: var(--ink-secondary);
}
.week-tasks {
list-style: none;
margin: 0;
padding: 6px;
display: grid;
align-content: start;
gap: 4px;
}
.week-tasks li {
display: grid;
grid-template-columns: 8px minmax(0, 1fr);
align-items: center;
gap: 2px 6px;
padding: 6px;
border-radius: 6px;
font-size: var(--fs-xs);
cursor: pointer;
background: var(--surface-subtle);
}
.week-tasks li:hover {
background: var(--accent-subtle);
}
.week-tasks li small {
grid-column: 2;
color: var(--muted);
}
.week-tasks li.none {
background: transparent;
color: var(--muted-light);
cursor: default;
text-align: center;
display: block;
}
.wt-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--line-strong);
}
.st-completed .wt-dot {
background: var(--success);
}
.st-completed .wt-title {
text-decoration: line-through;
color: var(--muted);
}
.st-overdue .wt-dot {
background: var(--danger);
}
.st-skipped .wt-dot,
.st-cancelled .wt-dot {
background: var(--warning);
}
.wt-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.day-panel {
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.day-panel header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-2);
}
.day-panel h3 {
margin: 0;
font-size: var(--fs-md);
color: var(--ink);
}
.day-sub {
margin: 4px 0 0;
font-size: var(--fs-sm);
color: var(--muted);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.chip {
padding: 1px 8px;
border-radius: 99px;
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
background: var(--surface-subtle);
}
.chip.s-complete {
background: var(--success-soft);
color: var(--success-deep);
}
.chip.s-partial {
background: var(--warning-soft);
color: var(--warning-deep);
}
.chip.s-missed {
background: var(--danger-soft);
color: var(--danger);
}
.rows {
display: grid;
gap: var(--sp-2);
}
.error {
margin: 0;
}
@media (max-width: 900px) {
.week {
grid-template-columns: 1fr;
}
.week-col {
min-height: 0;
}
.tools,
.who {
width: 100%;
}
}
</style>
@@ -0,0 +1,255 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref, watch } from 'vue';
import type { RoutineDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiBadge, UiEmptyState, UiSearch } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINES_CTX,
apiError,
formatDayNumeric,
formatPercent,
percentTone,
} from '@/lib/routines';
const shell = inject(ROUTINES_CTX)!;
const items = ref<RoutineDto[]>([]);
const loading = ref(true);
const error = ref('');
const q = ref('');
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<RoutineDto[]>('/routines', { params: { scope: 'mine' } });
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری روتین‌ها ممکن نشد.');
} finally {
loading.value = false;
}
}
onMounted(load);
watch(shell.refreshTick, load);
const filtered = computed(() => {
const term = q.value.trim();
if (!term) return items.value;
return items.value.filter((r) => r.title.includes(term) || (r.description ?? '').includes(term));
});
</script>
<template>
<div class="rlist">
<div class="toolbar">
<p class="lead">روتین‌هایی که به شما یا تیم شما سپرده شده‌اند. کارهای هر روز از روی این‌ها ساخته می‌شوند.</p>
<UiSearch v-model="q" placeholder="جستجوی روتین…" class="search" />
</div>
<p v-if="error" class="error">{{ error }}</p>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<UiEmptyState
v-else-if="!filtered.length"
icon="refresh"
:title="q ? 'روتینی با این عنوان پیدا نشد' : 'هنوز روتینی برای شما تعریف نشده'"
description="روتین‌ها توسط مدیر تیم تعریف می‌شوند و هر روز به‌صورت خودکار در «کارهای امروز» ظاهر می‌شوند."
/>
<div v-else class="cards">
<article v-for="r in filtered" :key="r.id" class="rcard">
<header>
<span class="ico" :style="{ background: r.category?.color ?? 'var(--accent)' }">
<UiIcon name="refresh" :size="16" />
</span>
<div class="titles">
<h3>{{ r.title }}</h3>
<p>{{ r.recurrenceLabel }} · ساعت {{ toFaDigits(r.timeOfDay) }}</p>
</div>
</header>
<p v-if="r.description" class="desc">{{ r.description }}</p>
<div class="tags">
<UiBadge v-if="r.category" size="sm">{{ r.category.name }}</UiBadge>
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[r.priority]">{{ PRIORITY_LABELS[r.priority] }}</UiBadge>
<UiBadge v-if="r.isMandatory" size="sm" variant="warning">اجباری</UiBadge>
<UiBadge v-if="r.reportRequired" size="sm" variant="info">گزارش الزامی</UiBadge>
<UiBadge v-if="r.team" size="sm" variant="accent">تیم {{ r.team.name }}</UiBadge>
</div>
<footer>
<div class="exec" :class="`tone-${percentTone(r.executionRate)}`">
<span>اجرا در ۳۰ روز اخیر</span>
<strong>{{ formatPercent(r.executionRate) }}</strong>
<i class="bar"><b :style="{ width: `${r.executionRate ?? 0}%` }" /></i>
</div>
<small class="since">
از {{ formatDayNumeric(r.startDate) }}{{ r.endDate ? ` تا ${formatDayNumeric(r.endDate)}` : '' }}
</small>
</footer>
</article>
</div>
</div>
</template>
<style scoped>
.rlist {
display: grid;
gap: var(--sp-4);
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-3);
}
.lead {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
}
.search {
width: 260px;
}
.state {
padding: var(--sp-8);
text-align: center;
color: var(--muted);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--sp-3);
}
.rcard {
display: grid;
gap: var(--sp-3);
align-content: start;
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow-xs);
}
.rcard header {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.ico {
width: 36px;
height: 36px;
border-radius: 10px;
display: grid;
place-items: center;
color: #fff;
flex-shrink: 0;
}
.titles {
min-width: 0;
}
.titles h3 {
margin: 0;
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.titles p {
margin: 2px 0 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.desc {
margin: 0;
font-size: var(--fs-sm);
color: var(--ink-secondary);
line-height: 1.7;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rcard footer {
display: grid;
gap: 6px;
padding-top: var(--sp-3);
border-top: 1px solid var(--line-light);
}
.exec {
display: grid;
grid-template-columns: 1fr auto;
gap: 4px;
font-size: var(--fs-xs);
color: var(--muted);
}
.exec strong {
color: var(--ink);
}
.bar {
grid-column: 1 / -1;
height: 5px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.bar b {
display: block;
height: 100%;
background: var(--line-strong);
}
.tone-good .bar b {
background: var(--success);
}
.tone-mid .bar b {
background: var(--warning);
}
.tone-low .bar b {
background: var(--danger);
}
.since {
color: var(--muted-light);
font-size: var(--fs-xs);
}
.error {
margin: 0;
}
@media (max-width: 900px) {
.search {
width: 100%;
}
.cards {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,673 @@
<script setup lang="ts">
import { computed, inject, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { RoutineCategoryDto, RoutineDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import {
UiBadge,
UiButton,
UiDropdownMenu,
UiEmptyState,
UiField,
UiInput,
UiModal,
UiSearch,
UiSegmented,
UiSelect,
} from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { ROUTINES_CTX, apiError, formatPercent, percentTone } from '@/lib/routines';
const shell = inject(ROUTINES_CTX)!;
const router = useRouter();
const items = ref<RoutineDto[]>([]);
const loading = ref(true);
const error = ref('');
const q = ref('');
const status = ref<'all' | 'active' | 'inactive'>('all');
const assignee = ref('');
const busyId = ref<string | null>(null);
const menuFor = ref<string | null>(null);
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<RoutineDto[]>('/routines', { params: { scope: 'all' } });
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری روتین‌ها ممکن نشد.');
} finally {
loading.value = false;
}
}
onMounted(load);
watch(shell.refreshTick, load);
const statusOptions = [
{ value: 'all' as const, label: 'همه' },
{ value: 'active' as const, label: 'فعال' },
{ value: 'inactive' as const, label: 'غیرفعال' },
];
const filtered = computed(() => {
const term = q.value.trim();
return items.value.filter((r) => {
if (status.value === 'active' && !r.isActive) return false;
if (status.value === 'inactive' && r.isActive) return false;
if (assignee.value && r.assignee?.id !== assignee.value && `team:${r.team?.id}` !== assignee.value) return false;
if (term && !r.title.includes(term)) return false;
return true;
});
});
const counts = computed(() => ({
total: items.value.length,
active: items.value.filter((r) => r.isActive).length,
}));
function replace(updated: RoutineDto) {
const i = items.value.findIndex((r) => r.id === updated.id);
if (i >= 0) items.value[i] = updated;
else items.value.unshift(updated);
}
async function toggle(r: RoutineDto) {
busyId.value = r.id;
error.value = '';
try {
const { data } = await api.patch<RoutineDto>(`/routines/${r.id}/toggle`, { isActive: !r.isActive });
replace(data);
shell.notifyChanged();
} catch (e) {
error.value = apiError(e, 'تغییر وضعیت ممکن نشد.');
} finally {
busyId.value = null;
}
}
async function duplicate(r: RoutineDto) {
busyId.value = r.id;
error.value = '';
try {
const { data } = await api.post<RoutineDto>(`/routines/${r.id}/duplicate`);
replace(data);
} catch (e) {
error.value = apiError(e, 'کپی روتین ممکن نشد.');
} finally {
busyId.value = null;
}
}
/* حذف */
const removing = ref<RoutineDto | null>(null);
const removeBusy = ref(false);
async function confirmRemove() {
const r = removing.value;
if (!r) return;
removeBusy.value = true;
try {
await api.delete(`/routines/${r.id}`);
items.value = items.value.filter((x) => x.id !== r.id);
removing.value = null;
shell.notifyChanged();
} catch (e) {
error.value = apiError(e, 'حذف روتین ممکن نشد.');
removing.value = null;
} finally {
removeBusy.value = false;
}
}
/* تغییر مسئول */
const reassign = reactive({ routine: null as RoutineDto | null, target: '', saving: false, error: '' });
function openReassign(r: RoutineDto) {
Object.assign(reassign, {
routine: r,
target: r.team ? `team:${r.team.id}` : (r.assignee?.id ?? ''),
saving: false,
error: '',
});
}
async function saveReassign() {
const r = reassign.routine;
if (!r || !reassign.target) {
reassign.error = 'مسئول را انتخاب کنید.';
return;
}
const body = reassign.target.startsWith('team:')
? { teamId: reassign.target.slice(5), assigneeId: null }
: { assigneeId: reassign.target, teamId: null };
reassign.saving = true;
try {
const { data } = await api.patch<RoutineDto>(`/routines/${r.id}/assignee`, body);
replace(data);
reassign.routine = null;
shell.notifyChanged();
} catch (e) {
reassign.error = apiError(e, 'تغییر مسئول ممکن نشد.');
} finally {
reassign.saving = false;
}
}
/* دسته‌بندی‌ها */
const catsOpen = ref(false);
const catForm = reactive({ id: '', name: '', color: '#14b8a6', saving: false, error: '' });
const CAT_COLORS = ['#14b8a6', '#3b82f6', '#8b5cf6', '#f59e0b', '#f43f5e', '#10b981', '#06b6d4', '#64748b'];
const categories = computed(() => shell.context.value?.categories ?? []);
function editCat(c: RoutineCategoryDto | null) {
Object.assign(catForm, { id: c?.id ?? '', name: c?.name ?? '', color: c?.color ?? '#14b8a6', error: '' });
}
async function refreshCategories() {
const ctx = shell.context.value;
if (!ctx) return;
const { data } = await api.get<RoutineCategoryDto[]>('/routines/categories');
ctx.categories = data;
}
async function saveCat() {
if (!catForm.name.trim()) {
catForm.error = 'نام دسته را وارد کنید.';
return;
}
catForm.saving = true;
catForm.error = '';
try {
const body = { name: catForm.name.trim(), color: catForm.color };
if (catForm.id) await api.patch(`/routines/categories/${catForm.id}`, body);
else await api.post('/routines/categories', body);
await refreshCategories();
editCat(null);
} catch (e) {
catForm.error = apiError(e, 'ذخیره دسته ممکن نشد.');
} finally {
catForm.saving = false;
}
}
async function deleteCat(c: RoutineCategoryDto) {
catForm.error = '';
try {
await api.delete(`/routines/categories/${c.id}`);
await refreshCategories();
if (catForm.id === c.id) editCat(null);
} catch (e) {
catForm.error = apiError(e, 'حذف دسته ممکن نشد.');
}
}
const users = computed(() => shell.context.value?.users ?? []);
const teams = computed(() => shell.context.value?.teams ?? []);
</script>
<template>
<div class="manage">
<div class="toolbar">
<UiSearch v-model="q" placeholder="جستجوی عنوان…" class="search" />
<UiSelect v-model="assignee" size="sm" class="who">
<option value="">همه مسئول‌ها</option>
<optgroup label="کاربران">
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</optgroup>
<optgroup v-if="teams.length" label="تیم‌ها">
<option v-for="t in teams" :key="t.id" :value="`team:${t.id}`">{{ t.name }}</option>
</optgroup>
</UiSelect>
<UiSegmented v-model="status" :options="statusOptions" />
<span class="spacer" />
<UiButton variant="ghost" size="sm" @click="catsOpen = true; editCat(null)">
<template #icon><UiIcon name="layers" :size="15" /></template>
دسته‌بندی‌ها
</UiButton>
<UiButton @click="router.push('/routines/manage/new')">
<template #icon><UiIcon name="plus" :size="16" /></template>
روتین جدید
</UiButton>
</div>
<p class="summary">
{{ toFaDigits(counts.total) }} روتین · {{ toFaDigits(counts.active) }} فعال
</p>
<p v-if="error" class="error">{{ error }}</p>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<UiEmptyState
v-else-if="!filtered.length"
icon="refresh"
:title="items.length ? 'روتینی با این فیلتر پیدا نشد' : 'هنوز روتینی تعریف نشده'"
description="روتین‌ها کارهای تکرارشونده‌ای هستند که هر روز به‌صورت خودکار برای مسئولشان ساخته می‌شوند."
>
<template v-if="!items.length" #action>
<UiButton @click="router.push('/routines/manage/new')">ساخت اولین روتین</UiButton>
</template>
</UiEmptyState>
<div v-else class="crm-table-container">
<table class="crm-table">
<thead>
<tr>
<th>عنوان</th>
<th>مسئول</th>
<th>تکرار</th>
<th>ساعت</th>
<th>وضعیت</th>
<th>درصد انجام</th>
<th />
</tr>
</thead>
<tbody>
<tr v-for="r in filtered" :key="r.id" :class="{ inactive: !r.isActive }">
<td class="title-cell">
<span class="cat-dot" :style="{ background: r.category?.color ?? 'var(--line-strong)' }" />
<div>
<strong>{{ r.title }}</strong>
<small>
{{ r.category?.name ?? 'بدون دسته' }}
<template v-if="r.isMandatory"> · اجباری</template>
</small>
</div>
</td>
<td>
<span v-if="r.team" class="who-cell">
<span class="team-ico"><UiIcon name="users" :size="13" /></span>
تیم {{ r.team.name }}
</span>
<span v-else-if="r.assignee" class="who-cell">
<UserAvatar :name="r.assignee.fullName" :user-id="r.assignee.id" :size="24" />
{{ r.assignee.fullName }}
</span>
</td>
<td class="nowrap">{{ r.recurrenceLabel }}</td>
<td class="nowrap">{{ toFaDigits(r.timeOfDay) }}</td>
<td>
<button
type="button"
class="switch"
:class="{ on: r.isActive }"
:disabled="busyId === r.id"
:aria-pressed="r.isActive"
:aria-label="r.isActive ? 'غیرفعال کردن' : 'فعال کردن'"
@click="toggle(r)"
>
<i />
</button>
<UiBadge size="sm" :variant="r.isActive ? 'success' : 'neutral'">
{{ r.isActive ? 'فعال' : 'غیرفعال' }}
</UiBadge>
</td>
<td>
<div class="exec" :class="`tone-${percentTone(r.executionRate)}`">
<strong>{{ formatPercent(r.executionRate) }}</strong>
<i class="bar"><b :style="{ width: `${r.executionRate ?? 0}%` }" /></i>
<small v-if="r.executionTotal">
{{ toFaDigits(r.executionDone) }} از {{ toFaDigits(r.executionTotal) }}
</small>
</div>
</td>
<td class="actions-cell">
<div class="crm-table-actions">
<UiButton
variant="icon"
size="sm"
aria-label="ویرایش"
@click="router.push(`/routines/manage/${r.id}/edit`)"
>
<UiIcon name="pencil" :size="15" />
</UiButton>
<UiDropdownMenu
:open="menuFor === r.id"
@update:open="(v: boolean) => (menuFor = v ? r.id : null)"
>
<template #trigger="{ toggle: t }">
<UiButton variant="icon" size="sm" aria-label="عملیات بیشتر" @click="t">
<UiIcon name="dots" :size="16" />
</UiButton>
</template>
<button type="button" @click="router.push(`/routines/manage/${r.id}/edit`)">ویرایش</button>
<button type="button" @click="openReassign(r)">تغییر مسئول</button>
<button type="button" @click="duplicate(r)">کپی روتین</button>
<button type="button" @click="toggle(r)">{{ r.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}</button>
<button type="button" class="danger" @click="removing = r">حذف</button>
</UiDropdownMenu>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- حذف -->
<UiModal :open="!!removing" title="حذف روتین" @close="removing = null">
<p class="confirm">
روتین «{{ removing?.title }}» حذف شود؟ کارهای انجام‌نشده امروزِ آن لغو می‌شوند؛ سوابق قبلی در گزارش‌ها باقی
می‌ماند.
</p>
<template #footer>
<UiButton variant="danger" :loading="removeBusy" @click="confirmRemove">حذف روتین</UiButton>
<UiButton variant="ghost" @click="removing = null">انصراف</UiButton>
</template>
</UiModal>
<!-- تغییر مسئول -->
<UiModal :open="!!reassign.routine" title="تغییر مسئول روتین" @close="reassign.routine = null">
<div class="form">
<p class="confirm">{{ reassign.routine?.title }}</p>
<UiField label="مسئول جدید" hint="کارهای انجام‌نشده امروز به مسئول جدید منتقل می‌شوند.">
<UiSelect v-model="reassign.target">
<option value="" disabled>انتخاب کنید</option>
<optgroup label="کاربران">
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</optgroup>
<optgroup v-if="teams.length" label="کل اعضای تیم">
<option v-for="t in teams" :key="t.id" :value="`team:${t.id}`">{{ t.name }}</option>
</optgroup>
</UiSelect>
</UiField>
<p v-if="reassign.error" class="error">{{ reassign.error }}</p>
</div>
<template #footer>
<UiButton :loading="reassign.saving" @click="saveReassign">ذخیره</UiButton>
<UiButton variant="ghost" @click="reassign.routine = null">انصراف</UiButton>
</template>
</UiModal>
<!-- دسته‌بندی‌ها -->
<UiModal :open="catsOpen" title="دسته‌بندی کارها" @close="catsOpen = false">
<div class="form">
<ul class="cat-list">
<li v-for="c in categories" :key="c.id">
<span class="cat-dot" :style="{ background: c.color }" />
<span class="cat-name">{{ c.name }}</span>
<UiButton variant="icon" size="sm" aria-label="ویرایش" @click="editCat(c)">
<UiIcon name="pencil" :size="14" />
</UiButton>
<UiButton variant="icon" size="sm" aria-label="حذف" @click="deleteCat(c)">
<UiIcon name="trash" :size="14" />
</UiButton>
</li>
</ul>
<div class="cat-form">
<UiField :label="catForm.id ? 'ویرایش دسته' : 'دسته جدید'">
<UiInput v-model="catForm.name" placeholder="مثلاً: پشتیبانی" :maxlength="60" />
</UiField>
<div class="swatches" role="radiogroup" aria-label="رنگ">
<button
v-for="col in CAT_COLORS"
:key="col"
type="button"
class="swatch"
:class="{ on: catForm.color === col }"
:style="{ background: col }"
:aria-label="col"
@click="catForm.color = col"
/>
</div>
<p v-if="catForm.error" class="error">{{ catForm.error }}</p>
</div>
</div>
<template #footer>
<UiButton :loading="catForm.saving" @click="saveCat">{{ catForm.id ? 'ذخیره تغییرات' : 'افزودن دسته' }}</UiButton>
<UiButton v-if="catForm.id" variant="ghost" @click="editCat(null)">انصراف از ویرایش</UiButton>
<UiButton variant="ghost" @click="catsOpen = false">بستن</UiButton>
</template>
</UiModal>
</div>
</template>
<style scoped>
.manage {
display: grid;
gap: var(--sp-3);
}
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
}
.search {
width: 240px;
}
.who {
width: 190px;
}
.spacer {
flex: 1 1 auto;
}
.summary {
margin: 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.state {
padding: var(--sp-8);
text-align: center;
color: var(--muted);
}
tr.inactive td {
color: var(--muted);
}
tr.inactive .title-cell strong {
color: var(--muted);
}
.title-cell {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 220px;
}
.title-cell div {
display: grid;
gap: 2px;
}
.title-cell strong {
font-weight: var(--fw-semibold);
}
.title-cell small {
color: var(--muted);
font-size: var(--fs-xs);
}
.cat-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.who-cell {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.team-ico {
width: 24px;
height: 24px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--accent-subtle);
color: var(--accent-deep);
}
.nowrap {
white-space: nowrap;
}
td:has(.switch) {
white-space: nowrap;
}
.switch {
width: 34px;
height: 20px;
padding: 2px;
margin-inline-end: 8px;
border: 0;
border-radius: 99px;
background: var(--line-strong);
cursor: pointer;
vertical-align: middle;
transition: background var(--transition-fast);
}
.switch i {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: var(--shadow-xs);
transition: transform var(--transition-fast);
}
.switch.on {
background: var(--accent);
}
/* RTL: روشن = دکمه سمت چپ */
.switch.on i {
transform: translateX(-14px);
}
.exec {
display: grid;
grid-template-columns: auto 80px;
align-items: center;
gap: 2px 8px;
min-width: 130px;
}
.exec small {
grid-column: 1 / -1;
color: var(--muted);
font-size: var(--fs-xs);
}
.bar {
height: 6px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.bar b {
display: block;
height: 100%;
background: var(--line-strong);
}
.tone-good .bar b {
background: var(--success);
}
.tone-mid .bar b {
background: var(--warning);
}
.tone-low .bar b {
background: var(--danger);
}
:global(.ui-dd-panel button.danger) {
color: var(--danger);
}
.confirm {
margin: 0;
line-height: 1.8;
color: var(--ink-secondary);
}
.form {
display: grid;
gap: var(--sp-4);
}
.cat-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.cat-list li {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 6px 10px;
border-radius: var(--radius);
background: var(--surface-subtle);
}
.cat-name {
flex: 1 1 auto;
}
.cat-form {
display: grid;
gap: var(--sp-3);
padding-top: var(--sp-3);
border-top: 1px solid var(--line-light);
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.swatch {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid var(--surface);
box-shadow: 0 0 0 1px var(--line);
cursor: pointer;
}
.swatch.on {
box-shadow: 0 0 0 2px var(--ink);
}
.error {
margin: 0;
}
@media (max-width: 900px) {
.search,
.who {
width: 100%;
}
.spacer {
display: none;
}
}
</style>
@@ -0,0 +1,272 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue';
import type { RoutinePerformanceDto } from '@mento/shared';
import KpiCard from '@/components/routines/KpiCard.vue';
import WeeklyReport from '@/components/routines/WeeklyReport.vue';
import MonthlyReport from '@/components/routines/MonthlyReport.vue';
import ActivityHistoryList from '@/components/routines/ActivityHistoryList.vue';
import { UiChart, UiSegmented, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { categoryBar, statusDonut, trendOption } from '@/lib/routineCharts';
import {
ROUTINES_CTX,
addDaysKey,
apiError,
formatDayNumeric,
formatMinutes,
formatPercent,
todayKey,
} from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
const shell = inject(ROUTINES_CTX)!;
const auth = useAuthStore();
type Section = 'overview' | 'weekly' | 'monthly' | 'history';
const section = ref<Section>('overview');
const sections = [
{ value: 'overview' as const, label: 'نمای کلی' },
{ value: 'weekly' as const, label: 'گزارش هفتگی' },
{ value: 'monthly' as const, label: 'گزارش ماهانه' },
{ value: 'history' as const, label: 'تاریخچه فعالیت' },
];
const days = ref<'7' | '30' | '90'>('30');
const dayOptions = [
{ value: '7' as const, label: '۷ روز' },
{ value: '30' as const, label: '۳۰ روز' },
{ value: '90' as const, label: '۹۰ روز' },
];
/** مدیر می‌تواند عملکرد یک عضو را هم ببیند */
const userId = ref('');
const users = computed(() => shell.context.value?.users ?? []);
const canPickUser = computed(() => !!shell.context.value?.isManager && users.value.length > 1);
const filters = computed(() => (userId.value ? { userId: userId.value } : {}));
const data = ref<RoutinePerformanceDto | null>(null);
const loading = ref(false);
const error = ref('');
async function load() {
if (section.value !== 'overview') return;
loading.value = true;
error.value = '';
const to = todayKey();
try {
const res = await api.get<RoutinePerformanceDto>('/routines/stats/me', {
params: { ...filters.value, from: addDaysKey(to, -(Number(days.value) - 1)), to },
});
data.value = res.data;
} catch (e) {
error.value = apiError(e, 'بارگذاری عملکرد ممکن نشد.');
} finally {
loading.value = false;
}
}
watch([section, days, userId, shell.refreshTick], load, { immediate: true });
const d = computed(() => data.value);
const trendOpt = computed(() => trendOption(d.value?.trend ?? []));
const donutOpt = computed(() =>
statusDonut(d.value?.status ?? { completed: 0, notDone: 0, overdue: 0, skipped: 0, cancelled: 0 }),
);
const catOpt = computed(() => categoryBar(d.value?.byCategory ?? []));
</script>
<template>
<div class="perf">
<div class="toolbar">
<UiSegmented v-model="section" :options="sections" />
<div class="tools">
<UiSelect v-if="canPickUser" v-model="userId" size="sm" class="who">
<option value="">عملکرد خودم</option>
<option v-for="u in users.filter((x) => x.id !== auth.user?.id)" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</UiSelect>
<UiSegmented v-if="section === 'overview'" v-model="days" :options="dayOptions" />
</div>
</div>
<template v-if="section === 'overview'">
<p v-if="error" class="error">{{ error }}</p>
<p v-if="d" class="range">بازه {{ formatDayNumeric(d.from) }} تا {{ formatDayNumeric(d.to) }}</p>
<div class="kpis" :class="{ dim: loading }">
<KpiCard label="درصد انجام" :value="formatPercent(d?.percent)" icon="target" tone="accent" />
<KpiCard
label="کارهای انجام‌شده"
:value="toFaDigits(d?.completed ?? 0)"
:hint="`از ${toFaDigits(d?.total ?? 0)} کار`"
icon="check"
tone="success"
/>
<KpiCard
label="انجام‌نشده"
:value="toFaDigits(d?.notDone ?? 0)"
:hint="`عقب‌افتاده ${toFaDigits(d?.overdue ?? 0)}`"
icon="x-circle"
tone="danger"
/>
<KpiCard
label="میانگین زمان انجام"
:value="formatMinutes(d?.avgCompletionMinutes)"
hint="از زمان برنامه تا انجام"
icon="clock"
tone="info"
/>
<KpiCard
label="روزهای کامل"
:value="toFaDigits(d?.completeDays ?? 0)"
:hint="`ناقص ${toFaDigits(d?.partialDays ?? 0)} · از دست رفته ${toFaDigits(d?.missedDays ?? 0)}`"
icon="calendar"
tone="success"
/>
<KpiCard
label="روزهای پیاپی کامل"
:value="toFaDigits(d?.streak ?? 0)"
hint="انجام همه کارهای اجباری"
icon="flag"
tone="warning"
/>
</div>
<div class="charts">
<section class="card wide">
<h3>روند درصد انجام</h3>
<UiChart :option="trendOpt" height="260px" :loading="loading" />
</section>
<section class="card">
<h3>وضعیت کارها</h3>
<UiChart :option="donutOpt" height="260px" :loading="loading" />
</section>
<section class="card full">
<h3>انجام به تفکیک دسته‌بندی</h3>
<UiChart v-if="d?.byCategory.length" :option="catOpt" height="240px" :loading="loading" />
<p v-else class="muted">داده‌ای برای نمایش نیست.</p>
</section>
</div>
</template>
<WeeklyReport v-else-if="section === 'weekly'" view="me" :filters="filters" :refresh-key="shell.refreshTick.value" />
<MonthlyReport v-else-if="section === 'monthly'" view="me" :filters="filters" :refresh-key="shell.refreshTick.value" />
<ActivityHistoryList
v-else
:user-id="userId || null"
:refresh-key="shell.refreshTick.value"
@open="shell.openTask"
/>
</div>
</template>
<style scoped>
.perf {
display: grid;
gap: var(--sp-4);
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-3);
}
.tools {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
}
.who {
width: 200px;
}
.range {
margin: 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.kpis {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: var(--sp-3);
transition: opacity var(--transition-fast);
}
.dim {
opacity: 0.55;
}
.charts {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
gap: var(--sp-4);
}
.card {
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
display: grid;
gap: var(--sp-3);
align-content: start;
min-width: 0;
}
.card.full {
grid-column: 1 / -1;
}
.card h3 {
margin: 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
}
.muted {
margin: 0;
color: var(--muted);
font-size: var(--fs-sm);
text-align: center;
padding: var(--sp-6);
}
.error {
margin: 0;
}
@media (max-width: 1200px) {
.kpis {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-2);
}
.charts {
grid-template-columns: 1fr;
}
.who {
width: 100%;
}
.toolbar > :first-child {
overflow-x: auto;
max-width: 100%;
}
}
</style>
@@ -0,0 +1,319 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import type { RoutineTaskDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import { UiBadge, UiButton, UiEmptyState, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINES_CTX,
apiError,
entityRoute,
formatDayNumeric,
formatDelay,
formatTime,
hasExplicitTime,
} from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
const shell = inject(ROUTINES_CTX)!;
const items = ref<RoutineTaskDto[]>([]);
const loading = ref(true);
const error = ref('');
const who = ref('');
const isManager = computed(() => !!shell.context.value?.isManager);
const users = computed(() => shell.context.value?.users ?? []);
async function load(silent = false) {
if (!silent) loading.value = true;
error.value = '';
try {
const { data } = await api.get<RoutineTaskDto[]>('/routine-tasks/overdue', {
params: who.value ? { userId: who.value } : {},
});
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری کارهای عقب‌افتاده ممکن نشد.');
} finally {
loading.value = false;
}
}
onMounted(() => load());
watch(who, () => load());
watch(shell.refreshTick, () => load(true));
const actions = useRoutineTaskActions((updated) => {
if (updated.status === 'overdue') {
const i = items.value.findIndex((t) => t.id === updated.id);
if (i >= 0) items.value[i] = updated;
} else {
items.value = items.value.filter((t) => t.id !== updated.id);
}
shell.notifyChanged();
});
function onAction(kind: RoutineTaskRowAction, task: RoutineTaskDto) {
if (kind === 'complete-note') actions.openComplete(task);
else if (kind === 'reschedule') actions.openReschedule(task);
else if (kind === 'move-today') void actions.moveToday(task);
else if (kind === 'skip') actions.openSkip(task);
else if (kind === 'cancel') actions.openCancel(task);
}
const showAssignee = computed(() => isManager.value && who.value !== '');
</script>
<template>
<div class="overdue">
<div class="toolbar">
<p class="lead">
<UiIcon name="warning" :size="16" />
<template v-if="items.length">{{ toFaDigits(items.length) }} کار از مهلتش گذشته است.</template>
<template v-else>کار عقب‌افتاده‌ای ندارید.</template>
</p>
<UiSelect v-if="isManager" v-model="who" size="sm" class="who">
<option value="">کارهای خودم</option>
<option value="all">همه اعضای تیم</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</UiSelect>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<UiEmptyState
v-else-if="!items.length"
icon="check"
title="همه چیز به‌روز است"
description="هیچ کاری از مهلتش عقب نیفتاده."
/>
<template v-else>
<!-- دسکتاپ: جدول -->
<div class="crm-table-container desk">
<table class="crm-table">
<thead>
<tr>
<th>کار</th>
<th>مهلت</th>
<th>تأخیر</th>
<th v-if="showAssignee">مسئول</th>
<th>اولویت</th>
<th>پرونده</th>
<th />
</tr>
</thead>
<tbody>
<tr v-for="t in items" :key="t.id" class="clickable" @click="shell.openTask(t.id)">
<td class="title-cell">
<strong>{{ t.title }}</strong>
<small v-if="t.category" class="cat"><i :style="{ background: t.category.color }" />{{ t.category.name }}</small>
</td>
<td class="nowrap">
{{ formatDayNumeric(t.scheduledFor) }}
<small v-if="hasExplicitTime(t)" class="muted">· {{ formatTime(t.dueAt) }}</small>
</td>
<td class="nowrap late">{{ formatDelay(t.dueAt) }}</td>
<td v-if="showAssignee">
<span v-if="t.assignee" class="who-cell">
<UserAvatar :name="t.assignee.fullName" :user-id="t.assignee.id" :size="24" />
{{ t.assignee.fullName }}
</span>
</td>
<td>
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[t.priority]">{{ PRIORITY_LABELS[t.priority] }}</UiBadge>
</td>
<td>
<RouterLink
v-for="ent in t.entities"
:key="ent.id"
:to="entityRoute(ent)"
class="ent"
@click.stop
>
{{ ENTITY_TYPE_LABELS[ent.type] }}: {{ ent.label }}
</RouterLink>
<span v-if="!t.entities.length" class="muted">—</span>
</td>
<td class="actions-cell" @click.stop>
<div class="crm-table-actions">
<UiButton
size="sm"
variant="success"
:loading="actions.busyId.value === t.id"
@click="actions.toggle(t)"
>
انجام شد
</UiButton>
<UiButton size="sm" variant="ghost" @click="actions.moveToday(t)">به امروز</UiButton>
<UiButton size="sm" variant="ghost" @click="actions.openReschedule(t)">تغییر زمان</UiButton>
<UiButton size="sm" variant="ghost" class="danger-text" @click="actions.openCancel(t)">لغو</UiButton>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- موبایل: کارت -->
<div class="mobile rows">
<RoutineTaskRow
v-for="t in items"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
show-delay
:show-assignee="showAssignee"
@toggle="actions.toggle"
@open="shell.openTask(t.id)"
@action="onAction"
/>
</div>
</template>
<RoutineTaskModals :actions="actions" />
</div>
</template>
<style scoped>
.overdue {
display: grid;
gap: var(--sp-4);
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-3);
}
.lead {
margin: 0;
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--ink-secondary);
font-size: var(--fs-sm);
}
.lead :deep(svg) {
color: var(--danger);
}
.who {
width: 220px;
}
.state {
padding: var(--sp-8);
text-align: center;
color: var(--muted);
}
.clickable {
cursor: pointer;
}
.title-cell {
display: grid;
gap: 2px;
min-width: 200px;
}
.title-cell strong {
font-weight: var(--fw-semibold);
}
.cat {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--muted);
}
.cat i {
width: 8px;
height: 8px;
border-radius: 50%;
}
.nowrap {
white-space: nowrap;
}
.late {
color: var(--danger);
font-weight: var(--fw-semibold);
}
.muted {
color: var(--muted);
}
.who-cell {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.ent {
display: block;
color: var(--accent-deep);
font-size: var(--fs-xs);
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.ent:hover {
text-decoration: underline;
}
.danger-text {
color: var(--danger) !important;
}
.rows {
display: grid;
gap: var(--sp-2);
}
.mobile {
display: none;
}
.error {
margin: 0;
}
@media (max-width: 900px) {
.desk {
display: none;
}
.mobile {
display: grid;
}
.who {
width: 100%;
}
}
</style>
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import type { RoutineUserSettingsDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiButton, UiCard, UiField, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError } from '@/lib/routines';
const form = reactive({
remind: '30',
endOfDaySummary: true,
summaryHour: '16',
overdueAlert: true,
});
const loading = ref(true);
const saving = ref(false);
const error = ref('');
const saved = ref(false);
const remindOptions = [
{ value: '', label: 'خاموش' },
{ value: '0', label: 'سر ساعت مهلت' },
{ value: '10', label: '۱۰ دقیقه قبل' },
{ value: '15', label: '۱۵ دقیقه قبل' },
{ value: '30', label: '۳۰ دقیقه قبل' },
{ value: '60', label: '۱ ساعت قبل' },
{ value: '120', label: '۲ ساعت قبل' },
];
const hours = Array.from({ length: 12 }, (_, i) => i + 12);
onMounted(async () => {
try {
const { data } = await api.get<RoutineUserSettingsDto>('/routine-tasks/settings');
Object.assign(form, {
remind: data.remindBeforeMinutes == null ? '' : String(data.remindBeforeMinutes),
endOfDaySummary: data.endOfDaySummary,
summaryHour: String(data.summaryHour),
overdueAlert: data.overdueAlert,
});
} catch (e) {
error.value = apiError(e, 'بارگذاری تنظیمات ممکن نشد.');
} finally {
loading.value = false;
}
});
async function save() {
saving.value = true;
error.value = '';
saved.value = false;
try {
await api.put('/routine-tasks/settings', {
remindBeforeMinutes: form.remind === '' ? null : Number(form.remind),
endOfDaySummary: form.endOfDaySummary,
summaryHour: Number(form.summaryHour),
overdueAlert: form.overdueAlert,
});
saved.value = true;
} catch (e) {
error.value = apiError(e, 'ذخیره تنظیمات ممکن نشد.');
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="settings">
<UiCard>
<header class="head">
<span class="ico"><UiIcon name="bell" :size="18" /></span>
<div>
<h3>یادآوری‌ها و اعلان‌های کارهای روتین</h3>
<p>این تنظیمات فقط برای حساب شما اعمال می‌شود. اعلان‌ها در زنگوله بالای صفحه نمایش داده می‌شوند.</p>
</div>
</header>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<form v-else class="form" @submit.prevent="save">
<UiField label="یادآوری قبل از مهلت" hint="برای کارهایی که ساعت مشخص دارند. روتین‌ها ممکن است یادآوری اختصاصی داشته باشند.">
<UiSelect v-model="form.remind">
<option v-for="o in remindOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
</UiSelect>
</UiField>
<label class="tg">
<input v-model="form.overdueAlert" type="checkbox" />
<span>
<strong>هشدار کار اجباری انجام‌نشده</strong>
<small>وقتی مهلت یک کار اجباری بگذرد و هنوز انجام نشده باشد.</small>
</span>
</label>
<label class="tg">
<input v-model="form.endOfDaySummary" type="checkbox" />
<span>
<strong>خلاصه پایان روز</strong>
<small>اگر کاری باقی مانده باشد، یک اعلان خلاصه دریافت می‌کنید؛ مثل «۳ کار تا پایان امروز باقی مانده است».</small>
</span>
</label>
<UiField v-if="form.endOfDaySummary" label="ساعت ارسال خلاصه" class="nested">
<UiSelect v-model="form.summaryHour">
<option v-for="h in hours" :key="h" :value="String(h)">ساعت {{ toFaDigits(h) }}:۰۰</option>
</UiSelect>
</UiField>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="saved" class="ok">تنظیمات ذخیره شد.</p>
<div class="actions">
<UiButton type="submit" :loading="saving">ذخیره تنظیمات</UiButton>
</div>
</form>
</UiCard>
</div>
</template>
<style scoped>
.settings {
max-width: 680px;
}
.head {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
margin-bottom: var(--sp-5);
}
.ico {
width: 38px;
height: 38px;
border-radius: 10px;
display: grid;
place-items: center;
background: var(--accent-subtle);
color: var(--accent-deep);
flex-shrink: 0;
}
.head h3 {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.head p {
margin: 4px 0 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: 1.7;
}
.state {
padding: var(--sp-6);
text-align: center;
color: var(--muted);
}
.form {
display: grid;
gap: var(--sp-4);
}
.tg {
display: flex;
align-items: flex-start;
gap: var(--sp-2);
cursor: pointer;
}
.tg input {
width: 18px;
height: 18px;
margin-top: 2px;
accent-color: var(--accent);
flex-shrink: 0;
}
.tg span {
display: grid;
gap: 2px;
}
.tg strong {
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--ink);
}
.tg small {
font-size: var(--fs-xs);
color: var(--muted);
line-height: 1.6;
}
.nested {
margin-inline-start: 26px;
max-width: 260px;
}
.ok {
margin: 0;
color: var(--success-deep);
font-size: var(--fs-sm);
}
.error {
margin: 0;
}
.actions {
display: flex;
}
</style>
@@ -0,0 +1,231 @@
<script setup lang="ts">
import { computed, onMounted, provide, ref, watch } from 'vue';
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
import type { RoutineContextDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import RoutineTaskDrawer from '@/components/routines/RoutineTaskDrawer.vue';
import QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
import { UiButton, UiPageHeader } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { ROUTINES_CTX, apiError } from '@/lib/routines';
const route = useRoute();
const router = useRouter();
const context = ref<RoutineContextDto | null>(null);
const contextError = ref('');
const refreshTick = ref(0);
const overdueCount = ref(0);
const quickOpen = ref(false);
async function loadContext() {
try {
const { data } = await api.get<RoutineContextDto>('/routines/context');
context.value = data;
contextError.value = '';
} catch (e) {
contextError.value = apiError(e, 'بارگذاری اطلاعات کارهای روتین ممکن نشد.');
}
}
async function loadBadge() {
try {
const { data } = await api.get<{ remaining: number; overdue: number }>('/routine-tasks/badge');
overdueCount.value = data.overdue;
} catch {
overdueCount.value = 0;
}
}
function notifyChanged() {
refreshTick.value += 1;
void loadBadge();
}
function openTask(id: string) {
void router.replace({ query: { ...route.query, task: id } });
}
function closeTask() {
const query = { ...route.query };
delete query.task;
void router.replace({ query });
}
provide(ROUTINES_CTX, { context, refreshTick, notifyChanged, openTask });
onMounted(() => {
void loadContext();
void loadBadge();
});
const taskId = computed(() => (typeof route.query.task === 'string' ? route.query.task : null));
const isManager = computed(() => !!context.value?.isManager);
type Tab = { to: string; name: string; label: string; icon: InstanceType<typeof UiIcon>['$props']['name']; manager?: boolean };
const tabs: Tab[] = [
{ to: '/routines', name: 'routines-today', label: 'کارهای امروز', icon: 'calendar-sun' },
{ to: '/routines/overdue', name: 'routines-overdue', label: 'عقب‌افتاده', icon: 'warning' },
{ to: '/routines/calendar', name: 'routines-calendar', label: 'تقویم', icon: 'calendar' },
{ to: '/routines/list', name: 'routines-list', label: 'روتین‌ها', icon: 'refresh' },
{ to: '/routines/me', name: 'routines-me', label: 'عملکرد من', icon: 'chart' },
{ to: '/routines/team', name: 'routines-team', label: 'عملکرد تیم', icon: 'users', manager: true },
{ to: '/routines/manage', name: 'routines-manage', label: 'مدیریت روتین‌ها', icon: 'sliders', manager: true },
];
const visibleTabs = computed(() => tabs.filter((t) => !t.manager || isManager.value));
const managerOnlyRoutes = new Set(['routines-team', 'routines-manage', 'routine-new', 'routine-edit']);
watch(
[() => route.name, context],
([name, ctx]) => {
if (ctx && !ctx.isManager && managerOnlyRoutes.has(String(name))) {
void router.replace('/routines');
}
},
{ immediate: true },
);
function tabActive(tab: Tab) {
const name = String(route.name ?? '');
if (tab.name === 'routines-manage') return name === 'routines-manage' || name === 'routine-new' || name === 'routine-edit';
return name === tab.name;
}
const hideHeader = computed(() => route.name === 'routine-new' || route.name === 'routine-edit');
</script>
<template>
<section class="crm-page routines-shell">
<template v-if="!hideHeader">
<UiPageHeader title="کارهای روتین" lead="برنامه روزانه، روتین‌های تکرارشونده و پیگیری انجام کارها">
<template #actions>
<RouterLink to="/routines/settings" class="icon-link" title="تنظیمات یادآوری و اعلان‌ها">
<UiIcon name="bell" :size="17" />
</RouterLink>
<UiButton v-if="isManager" variant="outline" @click="router.push('/routines/manage/new')">
<template #icon><UiIcon name="refresh" :size="16" /></template>
روتین جدید
</UiButton>
<UiButton @click="quickOpen = true">
<template #icon><UiIcon name="plus" :size="16" /></template>
کار جدید
</UiButton>
</template>
</UiPageHeader>
<nav class="r-tabs" aria-label="بخش‌های کارهای روتین">
<RouterLink
v-for="tab in visibleTabs"
:key="tab.name"
:to="tab.to"
class="r-tab"
:class="{ on: tabActive(tab) }"
>
<UiIcon :name="tab.icon" :size="15" />
<span>{{ tab.label }}</span>
<em v-if="tab.name === 'routines-overdue' && overdueCount > 0" class="count">
{{ toFaDigits(overdueCount) }}
</em>
</RouterLink>
</nav>
</template>
<p v-if="contextError" class="error">{{ contextError }}</p>
<RouterView v-slot="{ Component }">
<component :is="Component" :key="String(route.name)" />
</RouterView>
<RoutineTaskDrawer :task-id="taskId" @close="closeTask" />
<QuickTaskModal :open="quickOpen" @close="quickOpen = false" />
</section>
</template>
<style scoped>
.routines-shell {
gap: var(--sp-4);
}
.icon-link {
width: var(--control-h);
height: var(--control-h);
display: inline-grid;
place-items: center;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink-secondary);
flex: 0 0 auto !important;
}
.icon-link:hover,
.icon-link.router-link-active {
border-color: var(--accent);
color: var(--accent-deep);
background: var(--accent-subtle);
}
.r-tabs {
display: flex;
gap: var(--sp-5);
border-bottom: 1px solid var(--line);
overflow-x: auto;
scrollbar-width: none;
}
.r-tabs::-webkit-scrollbar {
display: none;
}
.r-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0.6rem 0.15rem;
margin-bottom: -1px;
border-bottom: 2px solid transparent;
color: var(--muted-light);
font-weight: var(--fw-bold);
font-size: var(--fs-sm);
white-space: nowrap;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.r-tab:hover {
color: var(--ink-secondary);
}
.r-tab.on {
color: var(--accent-hover);
border-bottom-color: var(--accent);
}
.count {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--danger);
color: #fff;
font-style: normal;
font-size: 0.68rem;
display: inline-grid;
place-items: center;
}
.error {
margin: 0;
}
@media (max-width: 900px) {
.r-tabs {
gap: var(--sp-4);
margin-inline: calc(-1 * var(--sp-4));
padding-inline: var(--sp-4);
}
}
</style>
@@ -0,0 +1,460 @@
<script setup lang="ts">
import { computed, inject, reactive, ref, watch } from 'vue';
import type {
RoutineExecutionRowDto,
RoutineStatsQuery,
RoutineTaskSource,
RoutineTaskStatus,
RoutineTeamPerformanceDto,
} from '@mento/shared';
import { ROUTINE_TASK_SOURCE_LABELS, ROUTINE_TASK_STATUS_LABELS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import KpiCard from '@/components/routines/KpiCard.vue';
import TeamPerformanceTable from '@/components/routines/TeamPerformanceTable.vue';
import WeeklyReport from '@/components/routines/WeeklyReport.vue';
import MonthlyReport from '@/components/routines/MonthlyReport.vue';
import { UiButton, UiChart, UiSegmented, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { categoryBar, membersBar, statusDonut, trendOption } from '@/lib/routineCharts';
import {
ROUTINES_CTX,
addDaysKey,
apiError,
formatPercent,
percentTone,
todayKey,
} from '@/lib/routines';
const shell = inject(ROUTINES_CTX)!;
type Section = 'overview' | 'weekly' | 'monthly' | 'routines';
const section = ref<Section>('overview');
const sections = [
{ value: 'overview' as const, label: 'نمای کلی تیم' },
{ value: 'weekly' as const, label: 'گزارش هفتگی' },
{ value: 'monthly' as const, label: 'گزارش ماهانه' },
{ value: 'routines' as const, label: 'اجرای روتین‌ها' },
];
const f = reactive({
from: addDaysKey(todayKey(), -29),
to: todayKey(),
userId: '',
teamId: '',
categoryId: '',
source: '' as RoutineTaskSource | '',
status: '' as RoutineTaskStatus | '',
});
const users = computed(() => shell.context.value?.users ?? []);
const teams = computed(() => shell.context.value?.teams ?? []);
const categories = computed(() => shell.context.value?.categories ?? []);
const sources = Object.entries(ROUTINE_TASK_SOURCE_LABELS) as Array<[RoutineTaskSource, string]>;
const statuses = Object.entries(ROUTINE_TASK_STATUS_LABELS) as Array<[RoutineTaskStatus, string]>;
const query = computed<RoutineStatsQuery>(() => {
const q: RoutineStatsQuery = {};
if (f.from) q.from = f.from;
if (f.to) q.to = f.to;
if (f.userId) q.userId = f.userId;
if (f.teamId) q.teamId = f.teamId;
if (f.categoryId) q.categoryId = f.categoryId;
if (f.source) q.source = f.source;
if (f.status) q.status = f.status;
return q;
});
const hasFilters = computed(() => !!(f.userId || f.teamId || f.categoryId || f.source || f.status));
function resetFilters() {
Object.assign(f, { userId: '', teamId: '', categoryId: '', source: '', status: '' });
}
const data = ref<RoutineTeamPerformanceDto | null>(null);
const exec = ref<RoutineExecutionRowDto[]>([]);
const loading = ref(false);
const error = ref('');
async function load() {
if (section.value !== 'overview' && section.value !== 'routines') return;
loading.value = true;
error.value = '';
try {
if (section.value === 'overview') {
const res = await api.get<RoutineTeamPerformanceDto>('/routines/stats/team', { params: query.value });
data.value = res.data;
} else {
const res = await api.get<RoutineExecutionRowDto[]>('/routines/stats/routines', { params: query.value });
exec.value = res.data;
}
} catch (e) {
error.value = apiError(e, 'بارگذاری گزارش تیم ممکن نشد.');
} finally {
loading.value = false;
}
}
watch([section, () => JSON.stringify(query.value), shell.refreshTick], load, { immediate: true });
const d = computed(() => data.value);
const activeMembers = computed(() => (d.value?.members ?? []).filter((m) => m.total > 0).length);
const trendOpt = computed(() => trendOption(d.value?.trend ?? []));
const donutOpt = computed(() =>
statusDonut(d.value?.status ?? { completed: 0, notDone: 0, overdue: 0, skipped: 0, cancelled: 0 }),
);
const membersOpt = computed(() => membersBar(d.value?.members ?? []));
const catOpt = computed(() => categoryBar(d.value?.byCategory ?? []));
const membersChartHeight = computed(() => `${Math.max(200, activeMembers.value * 34 + 40)}px`);
const execSort = ref<'percent' | 'occurrences'>('percent');
const execRows = computed(() =>
[...exec.value].sort((a, b) =>
execSort.value === 'percent' ? (a.percent ?? -1) - (b.percent ?? -1) : b.occurrences - a.occurrences,
),
);
</script>
<template>
<div class="team">
<UiSegmented v-model="section" :options="sections" class="sections" />
<!-- فیلترها -->
<section class="filters">
<div v-if="section === 'overview' || section === 'routines'" class="f-item date">
<label>از</label>
<JalaliDateInput v-model="f.from" mode="date" :clearable="false" />
</div>
<div v-if="section === 'overview' || section === 'routines'" class="f-item date">
<label>تا</label>
<JalaliDateInput v-model="f.to" mode="date" :clearable="false" />
</div>
<div class="f-item">
<label>کاربر</label>
<UiSelect v-model="f.userId" size="sm">
<option value="">همه کاربران</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</UiSelect>
</div>
<div v-if="teams.length" class="f-item">
<label>تیم</label>
<UiSelect v-model="f.teamId" size="sm">
<option value="">همه تیم‌ها</option>
<option v-for="t in teams" :key="t.id" :value="t.id">{{ t.name }}</option>
</UiSelect>
</div>
<div class="f-item">
<label>دسته‌بندی</label>
<UiSelect v-model="f.categoryId" size="sm">
<option value="">همه دسته‌ها</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</UiSelect>
</div>
<div v-if="section !== 'routines'" class="f-item">
<label>نوع کار</label>
<UiSelect v-model="f.source" size="sm">
<option value="">همه</option>
<option v-for="[k, label] in sources" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</div>
<div v-if="section === 'overview'" class="f-item">
<label>وضعیت</label>
<UiSelect v-model="f.status" size="sm">
<option value="">همه</option>
<option v-for="[k, label] in statuses" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</div>
<UiButton v-if="hasFilters" variant="ghost" size="sm" class="reset" @click="resetFilters">
<template #icon><UiIcon name="x" :size="14" /></template>
حذف فیلترها
</UiButton>
</section>
<p v-if="error" class="error">{{ error }}</p>
<!-- نمای کلی -->
<template v-if="section === 'overview'">
<div class="kpis" :class="{ dim: loading }">
<KpiCard label="درصد انجام تیم" :value="formatPercent(d?.percent)" icon="target" tone="accent" />
<KpiCard
label="انجام‌شده"
:value="toFaDigits(d?.completed ?? 0)"
:hint="`از ${toFaDigits(d?.total ?? 0)} کار`"
icon="check"
tone="success"
/>
<KpiCard label="عقب‌افتاده" :value="toFaDigits(d?.overdue ?? 0)" icon="warning" tone="danger" />
<KpiCard
label="اعضای فعال"
:value="toFaDigits(activeMembers)"
:hint="`از ${toFaDigits(d?.members.length ?? 0)} نفر`"
icon="users"
tone="info"
/>
</div>
<div class="charts">
<section class="card wide">
<h3>روند درصد انجام تیم</h3>
<UiChart :option="trendOpt" height="260px" :loading="loading" />
</section>
<section class="card">
<h3>وضعیت کارها</h3>
<UiChart :option="donutOpt" height="260px" :loading="loading" />
</section>
<section class="card">
<h3>مقایسه اعضا</h3>
<UiChart v-if="activeMembers" :option="membersOpt" :height="membersChartHeight" :loading="loading" />
<p v-else class="muted">داده‌ای برای نمایش نیست.</p>
</section>
<section class="card wide">
<h3>انجام به تفکیک دسته‌بندی</h3>
<UiChart v-if="d?.byCategory.length" :option="catOpt" height="240px" :loading="loading" />
<p v-else class="muted">داده‌ای برای نمایش نیست.</p>
</section>
</div>
<section class="card">
<h3>عملکرد اعضا</h3>
<TeamPerformanceTable :members="d?.members ?? []" @select="(id) => (f.userId = id)" />
</section>
</template>
<WeeklyReport
v-else-if="section === 'weekly'"
view="team"
:filters="query"
:refresh-key="shell.refreshTick.value"
/>
<MonthlyReport
v-else-if="section === 'monthly'"
view="team"
:filters="query"
:refresh-key="shell.refreshTick.value"
/>
<!-- گزارش اجرای روتین‌ها -->
<section v-else class="card">
<div class="exec-head">
<h3>گزارش اجرای روتین‌ها</h3>
<UiSelect v-model="execSort" size="sm" class="exec-sort">
<option value="percent">مرتب‌سازی: کمترین اجرا</option>
<option value="occurrences">مرتب‌سازی: بیشترین تکرار</option>
</UiSelect>
</div>
<div class="crm-table-container" :class="{ dim: loading }">
<table class="crm-table">
<thead>
<tr>
<th>روتین</th>
<th>مسئول</th>
<th>تکرار</th>
<th>دفعات</th>
<th>انجام شده</th>
<th>انجام نشده</th>
<th>رد شده</th>
<th>درصد اجرا</th>
</tr>
</thead>
<tbody>
<tr v-for="r in execRows" :key="r.routineId">
<td><strong>{{ r.title }}</strong></td>
<td class="nowrap">{{ r.assigneeLabel }}</td>
<td class="nowrap">{{ r.recurrenceLabel }}</td>
<td>{{ toFaDigits(r.occurrences) }}</td>
<td class="ok">{{ toFaDigits(r.completed) }}</td>
<td :class="{ bad: r.notDone > 0 }">{{ toFaDigits(r.notDone) }}</td>
<td>{{ toFaDigits(r.skipped) }}</td>
<td>
<span class="pct" :class="`tone-${percentTone(r.percent)}`">
<strong>{{ formatPercent(r.percent) }}</strong>
<i><b :style="{ width: `${r.percent ?? 0}%` }" /></i>
</span>
</td>
</tr>
<tr v-if="!execRows.length && !loading">
<td colspan="8" class="empty">در این بازه روتینی اجرا نشده است.</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>
<style scoped>
.team {
display: grid;
gap: var(--sp-4);
}
.sections {
justify-self: start;
max-width: 100%;
overflow-x: auto;
}
.filters {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.f-item {
display: grid;
gap: 4px;
min-width: 150px;
}
.f-item.date {
min-width: 160px;
}
.f-item label {
font-size: var(--fs-xs);
color: var(--muted);
}
.reset {
margin-inline-start: auto;
}
.kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--sp-3);
transition: opacity var(--transition-fast);
}
.dim {
opacity: 0.55;
}
.charts {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
gap: var(--sp-4);
}
.card {
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
display: grid;
gap: var(--sp-3);
align-content: start;
min-width: 0;
}
.card h3 {
margin: 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
}
.muted {
margin: 0;
color: var(--muted);
font-size: var(--fs-sm);
text-align: center;
padding: var(--sp-6);
}
.exec-head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-2);
}
.exec-sort {
width: 220px;
}
.nowrap {
white-space: nowrap;
}
.ok {
color: var(--success-deep);
}
.bad {
color: var(--danger);
font-weight: var(--fw-semibold);
}
.pct {
display: grid;
grid-template-columns: 44px 80px;
align-items: center;
gap: 8px;
}
.pct i {
height: 6px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.pct b {
display: block;
height: 100%;
background: var(--line-strong);
}
.tone-good b {
background: var(--success);
}
.tone-mid b {
background: var(--warning);
}
.tone-low b {
background: var(--danger);
}
.empty {
text-align: center;
color: var(--muted);
padding: var(--sp-6);
}
.error {
margin: 0;
}
@media (max-width: 1000px) {
.charts {
grid-template-columns: 1fr;
}
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-2);
}
}
@media (max-width: 700px) {
.f-item,
.f-item.date {
min-width: calc(50% - var(--sp-3));
flex: 1 1 calc(50% - var(--sp-3));
}
.exec-sort {
width: 100%;
}
}
</style>
@@ -0,0 +1,529 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import type { RoutineCalendarDto, RoutineTaskDto, RoutineTodayDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import KpiCard from '@/components/routines/KpiCard.vue';
import DailyScoreCard from '@/components/routines/DailyScoreCard.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import { UiEmptyState, UiSegmented, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
ROUTINES_CTX,
addDaysKey,
apiError,
formatDayLong,
formatPercent,
isOpenStatus,
todayKey,
} from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
import { useAuthStore } from '@/stores/auth';
const shell = inject(ROUTINES_CTX)!;
const auth = useAuthStore();
const data = ref<RoutineTodayDto | null>(null);
const recent = ref<RoutineCalendarDto['days']>([]);
const loading = ref(true);
const error = ref('');
const userId = ref('');
const filter = ref<'all' | 'open' | 'done'>('all');
const viewingSelf = computed(() => !userId.value || userId.value === auth.user?.id);
async function load(silent = false) {
if (!silent) loading.value = true;
error.value = '';
const params = userId.value ? { userId: userId.value } : {};
try {
const today = todayKey();
const [todayRes, calRes] = await Promise.all([
api.get<RoutineTodayDto>('/routine-tasks/today', { params }),
api.get<RoutineCalendarDto>('/routine-tasks/calendar', {
params: { ...params, from: addDaysKey(today, -13), to: today },
}),
]);
data.value = todayRes.data;
recent.value = calRes.data.days;
} catch (e) {
error.value = apiError(e, 'بارگذاری کارهای امروز ممکن نشد.');
} finally {
loading.value = false;
}
}
onMounted(() => load());
watch(userId, () => load());
watch(shell.refreshTick, () => load(true));
function applyLocal(updated: RoutineTaskDto) {
const d = data.value;
if (!d) return;
for (const list of [d.tasks, d.overdue]) {
const i = list.findIndex((t) => t.id === updated.id);
if (i >= 0) list[i] = updated;
}
}
const actions = useRoutineTaskActions((updated) => {
applyLocal(updated);
shell.notifyChanged();
});
function onAction(kind: RoutineTaskRowAction, task: RoutineTaskDto) {
if (kind === 'start') void actions.start(task);
else if (kind === 'complete-note') actions.openComplete(task);
else if (kind === 'reschedule') actions.openReschedule(task);
else if (kind === 'move-today') void actions.moveToday(task);
else if (kind === 'skip') actions.openSkip(task);
else actions.openCancel(task);
}
const stats = computed(() => data.value?.stats ?? { total: 0, completed: 0, remaining: 0, overdue: 0, percent: null });
const tasks = computed(() => {
const list = data.value?.tasks ?? [];
if (filter.value === 'open') return list.filter((t) => isOpenStatus(t.status));
if (filter.value === 'done') return list.filter((t) => !isOpenStatus(t.status));
return list;
});
const openTasks = computed(() => tasks.value.filter((t) => isOpenStatus(t.status)));
const closedTasks = computed(() => tasks.value.filter((t) => !isOpenStatus(t.status)));
const progressPct = computed(() => stats.value.percent ?? 0);
const filterOptions = computed(() => [
{ value: 'all' as const, label: `همه (${toFaDigits(data.value?.tasks.length ?? 0)})` },
{ value: 'open' as const, label: 'باقی‌مانده' },
{ value: 'done' as const, label: 'انجام‌شده' },
]);
const users = computed(() => shell.context.value?.users ?? []);
const showUserPicker = computed(() => !!shell.context.value?.isManager && users.value.length > 1);
const greeting = computed(() => {
if (!viewingSelf.value) {
const u = users.value.find((x) => x.id === userId.value);
return u ? `کارهای امروز ${u.fullName}` : 'کارهای امروز';
}
return 'امروز چه کارهایی داری؟';
});
</script>
<template>
<div class="today">
<!-- سربرگ روز -->
<section class="day-hero">
<div class="hero-copy">
<h2>کارهای امروز</h2>
<p class="sub">{{ greeting }}</p>
<p class="date">
<UiIcon name="calendar" :size="14" />
{{ formatDayLong(data?.date ?? todayKey()) }}
</p>
</div>
<div class="hero-progress" :aria-label="`${stats.completed} از ${stats.total} کار انجام شده`">
<div class="ring" :style="{ '--p': progressPct }">
<span>{{ formatPercent(stats.percent) }}</span>
</div>
<div class="progress-copy">
<strong>{{ toFaDigits(stats.completed) }} از {{ toFaDigits(stats.total) }}</strong>
<span>کار امروز انجام شده</span>
<i class="pbar"><b :style="{ width: `${progressPct}%` }" /></i>
</div>
</div>
<div v-if="showUserPicker" class="hero-user">
<UiSelect v-model="userId" size="sm">
<option value="">کارهای خودم</option>
<option v-for="u in users.filter((x) => x.id !== auth.user?.id)" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</UiSelect>
</div>
</section>
<!-- کارت‌های آماری -->
<section class="stat-grid">
<KpiCard label="انجام شده" :value="toFaDigits(stats.completed)" icon="check" tone="success" />
<KpiCard label="باقی‌مانده" :value="toFaDigits(stats.remaining)" icon="clock" tone="info" />
<KpiCard
label="عقب‌افتاده"
:value="toFaDigits(stats.overdue)"
icon="warning"
:tone="stats.overdue ? 'danger' : 'neutral'"
/>
<KpiCard label="درصد تکمیل" :value="formatPercent(stats.percent)" icon="target" tone="accent" />
</section>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
<div class="layout">
<section class="main">
<div class="list-head">
<UiSegmented v-model="filter" :options="filterOptions" />
</div>
<div v-if="loading" class="skeleton">
<span v-for="i in 4" :key="i" />
</div>
<UiEmptyState
v-else-if="!data?.tasks.length"
icon="calendar-sun"
title="برای امروز کاری ثبت نشده"
description="روتین‌های فعال به‌صورت خودکار هر روز کار می‌سازند. می‌توانید یک کار دستی هم اضافه کنید."
/>
<template v-else>
<div v-if="openTasks.length" class="group">
<h3 class="group-title">باقی‌مانده <em>{{ toFaDigits(openTasks.length) }}</em></h3>
<TransitionGroup name="rt-list" tag="div" class="rows">
<RoutineTaskRow
v-for="t in openTasks"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-delay
@toggle="actions.toggle"
@open="shell.openTask(t.id)"
@action="onAction"
/>
</TransitionGroup>
</div>
<div v-else-if="filter !== 'done'" class="all-done">
<UiIcon name="check" :size="20" />
همه کارهای امروز انجام شد.
</div>
<div v-if="closedTasks.length" class="group">
<h3 class="group-title">انجام‌شده / بسته <em>{{ toFaDigits(closedTasks.length) }}</em></h3>
<TransitionGroup name="rt-list" tag="div" class="rows">
<RoutineTaskRow
v-for="t in closedTasks"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
@toggle="actions.toggle"
@open="shell.openTask(t.id)"
@action="onAction"
/>
</TransitionGroup>
</div>
</template>
</section>
<aside class="side">
<DailyScoreCard :score="data?.score ?? null" :days="recent" />
<section v-if="data?.overdue.length" class="overdue-card">
<header>
<h3>
<UiIcon name="warning" :size="15" />
عقب‌افتاده از روزهای قبل
</h3>
<RouterLink to="/routines/overdue">همه ({{ toFaDigits(data.overdue.length) }})</RouterLink>
</header>
<div class="rows">
<RoutineTaskRow
v-for="t in data.overdue.slice(0, 4)"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
@toggle="actions.toggle"
@open="shell.openTask(t.id)"
@action="onAction"
/>
</div>
</section>
</aside>
</div>
<RoutineTaskModals :actions="actions" />
</div>
</template>
<style scoped>
.today {
display: grid;
gap: var(--sp-4);
}
.day-hero {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-4);
padding: var(--sp-5);
border-radius: var(--radius);
border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--line));
background: linear-gradient(135deg, var(--accent-subtle), var(--surface) 70%);
}
.hero-copy h2 {
margin: 0;
font-size: var(--fs-xl);
font-weight: var(--fw-bold);
color: var(--ink);
}
.sub {
margin: 4px 0 0;
color: var(--ink-secondary);
font-size: var(--fs-base);
}
.date {
margin: 8px 0 0;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-sm);
color: var(--accent-deep);
font-weight: var(--fw-semibold);
}
.hero-progress {
display: flex;
align-items: center;
gap: var(--sp-4);
}
.ring {
--p: 0;
width: 76px;
height: 76px;
border-radius: 50%;
display: grid;
place-items: center;
background: conic-gradient(var(--accent) calc(var(--p) * 1%), var(--line-light) 0);
flex-shrink: 0;
}
.ring span {
width: 60px;
height: 60px;
border-radius: 50%;
background: var(--surface);
display: grid;
place-items: center;
font-weight: var(--fw-bold);
font-size: var(--fs-md);
color: var(--accent-deep);
}
.progress-copy {
display: grid;
gap: 4px;
min-width: 160px;
}
.progress-copy strong {
font-size: var(--fs-xl);
color: var(--ink);
}
.progress-copy span {
font-size: var(--fs-sm);
color: var(--muted);
}
.pbar {
height: 6px;
border-radius: 99px;
background: var(--line-light);
overflow: hidden;
}
.pbar b {
display: block;
height: 100%;
background: var(--accent);
border-radius: inherit;
transition: width 0.4s ease;
}
.hero-user {
width: 200px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--sp-3);
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: var(--sp-4);
align-items: start;
}
.main {
display: grid;
gap: var(--sp-4);
min-width: 0;
}
.list-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
.group {
display: grid;
gap: var(--sp-2);
}
.group-title {
margin: 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.group-title em {
font-style: normal;
color: var(--muted);
font-weight: var(--fw-normal);
}
.rows {
position: relative;
display: grid;
gap: var(--sp-2);
}
.all-done {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-4);
border-radius: var(--radius);
background: var(--success-soft);
color: var(--success-deep);
font-weight: var(--fw-semibold);
}
.side {
display: grid;
gap: var(--sp-4);
position: sticky;
top: var(--sp-4);
}
.overdue-card {
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid color-mix(in srgb, var(--danger) 20%, var(--line));
border-radius: var(--radius);
background: var(--surface);
}
.overdue-card header {
display: flex;
align-items: center;
justify-content: space-between;
}
.overdue-card h3 {
margin: 0;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-sm);
color: var(--danger);
}
.overdue-card a {
font-size: var(--fs-xs);
color: var(--accent-deep);
font-weight: var(--fw-semibold);
}
.skeleton {
display: grid;
gap: var(--sp-2);
}
.skeleton span {
height: 72px;
border-radius: var(--radius);
background: linear-gradient(90deg, var(--surface-subtle), var(--line-light), var(--surface-subtle));
background-size: 200% 100%;
animation: sk 1.2s ease-in-out infinite;
}
@keyframes sk {
to {
background-position: -200% 0;
}
}
.rt-list-move,
.rt-list-enter-active,
.rt-list-leave-active {
transition: all 0.25s ease;
}
.rt-list-enter-from,
.rt-list-leave-to {
opacity: 0;
transform: translateY(6px);
}
.rt-list-leave-active {
position: absolute;
width: 100%;
}
.error {
margin: 0;
}
@media (max-width: 1100px) {
.layout {
grid-template-columns: 1fr;
}
.side {
position: static;
}
}
@media (max-width: 900px) {
.day-hero {
padding: var(--sp-4);
}
.hero-progress {
width: 100%;
}
.progress-copy {
flex: 1 1 auto;
}
.hero-user {
width: 100%;
}
.stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-2);
}
}
</style>
+484 -6
View File
@@ -50,6 +50,7 @@ export type BillingPlanCode =
| "quarterly"
| "semiannual"
| "yearly"
| "lifetime"
| "custom";
export type BillingAccountStatus = "active" | "expired" | "suspended";
@@ -60,27 +61,36 @@ export const BILLING_PLAN_CODES: BillingPlanCode[] = [
"quarterly",
"semiannual",
"yearly",
"lifetime",
"custom",
];
export const BILLING_PLAN_LABELS: Record<BillingPlanCode, string> = {
trial: "دمو ۱۵ روزه",
trial: "دمو ۷ روزه",
monthly: "ماهانه",
quarterly: "سه‌ماهه",
semiannual: "شش‌ماهه",
yearly: "یکساله",
lifetime: "دائمی",
custom: "سفارشی",
};
/** مدت پیش‌فرض پلن‌ها (روز) — custom ندارد */
export const BILLING_TRIAL_DAYS = 7;
/** مدت پیش‌فرض پلن‌ها (روز) — custom ندارد؛ lifetime عملاً بدون انقضا (۱۰۰ سال) */
export const BILLING_PLAN_DAYS: Record<Exclude<BillingPlanCode, "custom">, number> = {
trial: 15,
trial: BILLING_TRIAL_DAYS,
monthly: 30,
quarterly: 90,
semiannual: 180,
yearly: 365,
lifetime: 36500,
};
export function isPermanentBillingPlan(code: string | null | undefined): boolean {
return code === "lifetime";
}
export const BILLING_PLAN_DEFAULT_MAX_ORGS: Record<
Exclude<BillingPlanCode, "custom">,
number
@@ -90,6 +100,7 @@ export const BILLING_PLAN_DEFAULT_MAX_ORGS: Record<
quarterly: 5,
semiannual: 10,
yearly: 20,
lifetime: 20,
};
export type OrgModuleKey =
@@ -126,7 +137,8 @@ export type OrgModuleKey =
| "voip"
| "reservations"
| "leads"
| "conversations";
| "conversations"
| "routines";
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"customers",
@@ -163,6 +175,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"voip",
"reservations",
"conversations",
"routines",
];
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
@@ -200,6 +213,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
voip: "تلفن سازمانی",
reservations: "رزرو و نوبت‌دهی",
conversations: "گفتگو",
routines: "کارهای روتین",
};
export const ORG_MODULE_GROUPS: Array<{
@@ -219,7 +233,7 @@ export const ORG_MODULE_GROUPS: Array<{
"sales_forecast",
],
},
{ title: "فعالیت‌ها", keys: ["activities", "notes"] },
{ title: "فعالیت‌ها", keys: ["activities", "routines", "notes"] },
{ title: "پشتیبانی", keys: ["tickets", "faq", "conversations"] },
{
title: "اتوماسیون",
@@ -341,7 +355,8 @@ export type DataScope =
| "finance"
| "tickets"
| "notes"
| "projects";
| "projects"
| "routines";
export const DATA_SCOPES: DataScope[] = [
"contacts",
@@ -352,6 +367,7 @@ export const DATA_SCOPES: DataScope[] = [
"tickets",
"notes",
"projects",
"routines",
];
export const DATA_SCOPE_LABELS: Record<DataScope, string> = {
@@ -363,6 +379,7 @@ export const DATA_SCOPE_LABELS: Record<DataScope, string> = {
tickets: "تیکت‌ها",
notes: "یادداشت‌ها",
projects: "پروژه‌ها",
routines: "کارهای روتین",
};
export type DataVisibilityScopes = Partial<Record<DataScope, DataVisibility>>;
@@ -1658,6 +1675,10 @@ export type NotificationType =
| "cheque_due"
| "ticket_assigned"
| "automation"
| "routine_task_reminder"
| "routine_task_overdue"
| "routine_day_summary"
| "routine_task_assigned"
| "system";
export interface NotificationDto {
@@ -3381,6 +3402,8 @@ export interface PlatformOrganizationDto {
personCount: number;
dealCount: number;
enabledModules?: OrgModuleKey[];
/** اشتراک مالک اصلی محیط */
subscription?: SubscriptionSummaryDto | null;
members?: PlatformOrgMemberDto[];
}
@@ -4340,3 +4363,458 @@ export interface PlatformConversationStatsDto {
totalConfiguredOrgs: number;
totalRoomsTracked: number;
}
/* ───────────── کارهای روتین ───────────── */
export type RoutineTaskStatus =
| "todo"
| "in_progress"
| "completed"
| "overdue"
| "skipped"
| "cancelled";
export const ROUTINE_TASK_STATUSES: RoutineTaskStatus[] = [
"todo",
"in_progress",
"completed",
"overdue",
"skipped",
"cancelled",
];
export const ROUTINE_TASK_STATUS_LABELS: Record<RoutineTaskStatus, string> = {
todo: "در انتظار",
in_progress: "در حال انجام",
completed: "انجام شد",
overdue: "عقب‌افتاده",
skipped: "رد شده",
cancelled: "لغو شده",
};
export type RoutineRecurrence =
| "daily"
| "weekdays"
| "weekly"
| "biweekly"
| "monthly"
| "every_n_days"
| "specific_dates";
export const ROUTINE_RECURRENCES: RoutineRecurrence[] = [
"daily",
"weekdays",
"weekly",
"biweekly",
"monthly",
"every_n_days",
"specific_dates",
];
export const ROUTINE_RECURRENCE_LABELS: Record<RoutineRecurrence, string> = {
daily: "هر روز",
weekdays: "روزهای مشخص هفته",
weekly: "هر هفته",
biweekly: "هر دو هفته",
monthly: "هر ماه",
every_n_days: "هر چند روز یک‌بار",
specific_dates: "تاریخ‌های مشخص",
};
/** ۰ = شنبه … ۶ = جمعه */
export const ROUTINE_WEEKDAY_LABELS = [
"شنبه",
"یکشنبه",
"دوشنبه",
"سه‌شنبه",
"چهارشنبه",
"پنجشنبه",
"جمعه",
];
export type RoutineTaskSource =
| "routine"
| "manual"
| "follow_up"
| "lead"
| "customer"
| "deal";
export const ROUTINE_TASK_SOURCE_LABELS: Record<RoutineTaskSource, string> = {
routine: "روتین",
manual: "دستی",
follow_up: "پیگیری",
lead: "لید",
customer: "مشتری",
deal: "معامله",
};
/** اتصال Task به پرونده: none = ندارد | optional = اختیاری | lead/customer = الزامی */
export type RoutineEntityLink = "none" | "optional" | "lead" | "customer";
export const ROUTINE_ENTITY_LINK_LABELS: Record<RoutineEntityLink, string> = {
none: "بدون اتصال",
optional: "اختیاری",
lead: "الزامی به لید",
customer: "الزامی به مشتری",
};
/** وضعیت یک روز: کامل | ناقص | از دست رفته | بدون Task */
export type RoutineDayState = "complete" | "partial" | "missed" | "empty";
export interface RoutineCategoryDto {
id: string;
name: string;
color: string;
sortOrder: number;
}
export interface SaveRoutineCategoryRequest {
name: string;
color?: string;
sortOrder?: number;
}
export type RoutineEntityType =
| "lead"
| "person"
| "company"
| "deal"
| "invoice"
| "activity";
export interface RoutineEntityRefDto {
type: RoutineEntityType;
id: string;
label: string;
}
export interface RoutineTaskRulesDto {
allowSkip: boolean;
skipReasonRequired: boolean;
reportRequired: boolean;
allowAfterDeadline: boolean;
entityLink: RoutineEntityLink;
}
export interface RoutineTaskDto {
id: string;
routineId: string | null;
title: string;
description: string | null;
status: RoutineTaskStatus;
priority: ActivityPriority;
category: RoutineCategoryDto | null;
/** روز برنامه (YYYY-MM-DD به وقت تهران) */
scheduledFor: string;
dueAt: string | null;
startedAt: string | null;
completedAt: string | null;
resultNote: string | null;
skipReason: string | null;
source: RoutineTaskSource;
isMandatory: boolean;
rules: RoutineTaskRulesDto;
assignee: UserSummaryDto | null;
createdBy: UserSummaryDto | null;
entities: RoutineEntityRefDto[];
createdAt: string;
updatedAt: string;
}
export interface RoutineTaskEventDto {
id: string;
type: "created" | "status_changed" | "rescheduled" | "note" | "reassigned" | "updated";
fromStatus: RoutineTaskStatus | null;
toStatus: RoutineTaskStatus | null;
note: string | null;
actor: UserSummaryDto | null;
createdAt: string;
}
export interface RoutineTaskDetailDto extends RoutineTaskDto {
events: RoutineTaskEventDto[];
}
export interface RoutineDayStatsDto {
total: number;
completed: number;
remaining: number;
overdue: number;
/** null وقتی Task ندارد */
percent: number | null;
}
export interface RoutineScoreDto {
today: number | null;
yesterday: number | null;
week: number | null;
month: number | null;
/** روزهای متوالی با انجام همه Taskهای اجباری */
streak: number;
}
export interface RoutineTodayDto {
date: string;
tasks: RoutineTaskDto[];
overdue: RoutineTaskDto[];
stats: RoutineDayStatsDto;
score: RoutineScoreDto;
}
export interface CreateRoutineTaskRequest {
title: string;
description?: string | null;
/** ISO؛ اگر خالی باشد scheduledFor یا امروز */
dueAt?: string | null;
scheduledFor?: string | null;
priority?: ActivityPriority;
categoryId?: string | null;
assigneeId?: string | null;
isMandatory?: boolean;
source?: RoutineTaskSource;
leadId?: string | null;
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
invoiceId?: string | null;
activityId?: string | null;
}
export type UpdateRoutineTaskRequest = Partial<
Omit<CreateRoutineTaskRequest, "source" | "activityId">
>;
export interface CompleteRoutineTaskRequest {
note?: string | null;
}
export interface SkipRoutineTaskRequest {
reason?: string | null;
}
export interface RescheduleRoutineTaskRequest {
dueAt: string;
}
export interface RoutineDto {
id: string;
title: string;
description: string | null;
assignee: UserSummaryDto | null;
team: { id: string; name: string } | null;
category: RoutineCategoryDto | null;
priority: ActivityPriority;
/** HH:mm */
timeOfDay: string;
durationMinutes: number | null;
startDate: string;
endDate: string | null;
recurrence: RoutineRecurrence;
weekdays: number[];
intervalDays: number | null;
monthDay: number | null;
specificDates: string[];
isMandatory: boolean;
allowSkip: boolean;
skipReasonRequired: boolean;
reportRequired: boolean;
allowAfterDeadline: boolean;
carryOverOverdue: boolean;
entityLink: RoutineEntityLink;
reminderMinutesBefore: number | null;
isActive: boolean;
recurrenceLabel: string;
/** درصد اجرا در ۳۰ روز اخیر */
executionRate: number | null;
executionTotal: number;
executionDone: number;
createdAt: string;
updatedAt: string;
}
export interface SaveRoutineRequest {
title: string;
description?: string | null;
assigneeId?: string | null;
teamId?: string | null;
categoryId?: string | null;
priority?: ActivityPriority;
timeOfDay: string;
durationMinutes?: number | null;
startDate: string;
endDate?: string | null;
recurrence: RoutineRecurrence;
weekdays?: number[];
intervalDays?: number | null;
monthDay?: number | null;
specificDates?: string[];
isMandatory?: boolean;
allowSkip?: boolean;
skipReasonRequired?: boolean;
reportRequired?: boolean;
allowAfterDeadline?: boolean;
carryOverOverdue?: boolean;
entityLink?: RoutineEntityLink;
reminderMinutesBefore?: number | null;
isActive?: boolean;
}
export interface RoutineContextDto {
isManager: boolean;
users: UserSummaryDto[];
teams: Array<{ id: string; name: string }>;
categories: RoutineCategoryDto[];
}
export interface RoutineCalendarDayDto {
date: string;
total: number;
completed: number;
overdue: number;
percent: number | null;
state: RoutineDayState;
}
export interface RoutineCalendarDto {
days: RoutineCalendarDayDto[];
tasks: RoutineTaskDto[];
}
export interface RoutineTrendPointDto {
date: string;
total: number;
completed: number;
percent: number | null;
}
export interface RoutineCategoryStatDto {
categoryId: string | null;
name: string;
color: string;
total: number;
completed: number;
percent: number | null;
}
export interface RoutineStatusBreakdownDto {
completed: number;
notDone: number;
overdue: number;
skipped: number;
cancelled: number;
}
export interface RoutinePerformanceDto {
from: string;
to: string;
total: number;
completed: number;
notDone: number;
overdue: number;
percent: number | null;
avgCompletionMinutes: number | null;
completeDays: number;
partialDays: number;
missedDays: number;
streak: number;
status: RoutineStatusBreakdownDto;
trend: RoutineTrendPointDto[];
byCategory: RoutineCategoryStatDto[];
}
export interface RoutineMemberStatDto {
user: UserSummaryDto;
total: number;
completed: number;
overdue: number;
skipped: number;
percent: number | null;
streak: number;
avgCompletionMinutes: number | null;
}
export interface RoutineTeamPerformanceDto {
from: string;
to: string;
total: number;
completed: number;
overdue: number;
percent: number | null;
status: RoutineStatusBreakdownDto;
trend: RoutineTrendPointDto[];
byCategory: RoutineCategoryStatDto[];
members: RoutineMemberStatDto[];
}
export interface RoutinePeriodTotalsDto {
total: number;
completed: number;
notDone: number;
overdue: number;
percent: number | null;
completeDays: number;
partialDays: number;
missedDays: number;
avgPercent: number | null;
}
export interface RoutineWeeklyReportDto {
from: string;
to: string;
days: RoutineCalendarDayDto[];
totals: RoutinePeriodTotalsDto;
previous: RoutinePeriodTotalsDto;
}
export interface RoutineMonthlyReportDto {
from: string;
to: string;
totals: RoutinePeriodTotalsDto;
previous: RoutinePeriodTotalsDto;
status: RoutineStatusBreakdownDto;
days: RoutineCalendarDayDto[];
byUser: RoutineMemberStatDto[];
}
export interface RoutineExecutionRowDto {
routineId: string;
title: string;
assigneeLabel: string;
recurrenceLabel: string;
occurrences: number;
completed: number;
notDone: number;
skipped: number;
percent: number | null;
}
export interface RoutineHistoryDayDto {
date: string;
total: number;
completed: number;
overdue: number;
skipped: number;
cancelled: number;
percent: number | null;
state: RoutineDayState;
}
export interface RoutineStatsQuery {
from?: string;
to?: string;
userId?: string;
teamId?: string;
categoryId?: string;
source?: RoutineTaskSource;
status?: RoutineTaskStatus;
}
export interface RoutineUserSettingsDto {
/** چند دقیقه قبل از Deadline یادآوری شود؛ null = خاموش */
remindBeforeMinutes: number | null;
endOfDaySummary: boolean;
overdueAlert: boolean;
/** ساعت ارسال خلاصه پایان روز (۰ تا ۲۳) */
summaryHour: number;
}