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:
@@ -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"));
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user