From f884c60cab2c54863117c97141b9f125a929fff0 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 2 Sep 2026 21:29:00 +0330 Subject: [PATCH] Add activity detail, Jalali pickers, and customer/user profiles. Co-authored-by: Cursor --- .gitignore | 1 + .../migration.sql | 14 + .../migration.sql | 7 + apps/api/prisma/schema.prisma | 23 +- apps/api/src/activities/activities.service.ts | 16 + apps/api/src/activities/dto.ts | 33 + apps/api/tsconfig.json | 1 - .../src/components/CustomFieldsSection.vue | 11 +- apps/web/src/components/JalaliDateInput.vue | 575 +++++++ .../src/components/NoteAiSuggestionPanel.vue | 4 +- apps/web/src/components/UiIcon.vue | 132 +- apps/web/src/components/UserAvatar.vue | 59 + .../activities/ActivityCommentSheet.vue | 897 ++++++++++ .../activities/ActivityPostponeSheet.vue | 458 +++++ .../person-profile/ProfileEmptyState.vue | 196 +++ .../person-profile/ProfileMoreMenu.vue | 215 +++ apps/web/src/layouts/AppLayout.vue | 81 +- apps/web/src/lib/jalali.ts | 84 + apps/web/src/lib/money.ts | 17 + apps/web/src/lib/person-insights.ts | 97 ++ apps/web/src/router/index.ts | 19 +- apps/web/src/views/ActivitiesView.vue | 1147 ++++++++++--- apps/web/src/views/ActivityDetailView.vue | 616 +++++++ apps/web/src/views/ActivityFormView.vue | 1118 ++++++++++-- apps/web/src/views/DealFormView.vue | 11 +- apps/web/src/views/InvoiceFormView.vue | 12 +- apps/web/src/views/NotesView.vue | 13 +- apps/web/src/views/PersonFormView.vue | 7 +- apps/web/src/views/PersonProfileView.vue | 1509 +++++++++++++++++ apps/web/src/views/ProfileView.vue | 1229 ++++++++++++++ apps/web/src/views/ProjectCardFormView.vue | 2 + apps/web/src/views/QuoteFormView.vue | 14 +- apps/web/src/views/ReportsView.vue | 18 +- apps/web/src/views/SalesForecastView.vue | 16 +- apps/web/src/views/TicketFormView.vue | 5 + apps/web/tsconfig.json | 3 +- docs/راهنمای-کاربری.md | 20 +- docs/وضعیت-پیشرفت.md | 5 +- packages/shared/src/index.ts | 11 + 39 files changed, 8255 insertions(+), 441 deletions(-) create mode 100644 apps/api/prisma/migrations/20260902160000_activity_priority_creator/migration.sql create mode 100644 apps/api/prisma/migrations/20260902183000_activity_duration_reminder_priority/migration.sql create mode 100644 apps/web/src/components/JalaliDateInput.vue create mode 100644 apps/web/src/components/UserAvatar.vue create mode 100644 apps/web/src/components/activities/ActivityCommentSheet.vue create mode 100644 apps/web/src/components/activities/ActivityPostponeSheet.vue create mode 100644 apps/web/src/components/person-profile/ProfileEmptyState.vue create mode 100644 apps/web/src/components/person-profile/ProfileMoreMenu.vue create mode 100644 apps/web/src/lib/person-insights.ts create mode 100644 apps/web/src/views/ActivityDetailView.vue create mode 100644 apps/web/src/views/PersonProfileView.vue create mode 100644 apps/web/src/views/ProfileView.vue diff --git a/.gitignore b/.gitignore index 84ec3bc..90ab536 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ docs/~$* ~$* .idea/ .vscode/ +*.tsbuildinfo diff --git a/apps/api/prisma/migrations/20260902160000_activity_priority_creator/migration.sql b/apps/api/prisma/migrations/20260902160000_activity_priority_creator/migration.sql new file mode 100644 index 0000000..ca80bfd --- /dev/null +++ b/apps/api/prisma/migrations/20260902160000_activity_priority_creator/migration.sql @@ -0,0 +1,14 @@ +-- CreateEnum +CREATE TYPE "ActivityPriority" AS ENUM ('low', 'medium', 'high'); + +-- AlterTable +ALTER TABLE "Activity" ADD COLUMN "priority" "ActivityPriority" NOT NULL DEFAULT 'medium'; +ALTER TABLE "Activity" ADD COLUMN "createdById" TEXT; + +UPDATE "Activity" SET "createdById" = "assigneeId" WHERE "createdById" IS NULL; + +-- AddForeignKey +ALTER TABLE "Activity" ADD CONSTRAINT "Activity_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- CreateIndex +CREATE INDEX "Activity_organizationId_createdById_idx" ON "Activity"("organizationId", "createdById"); diff --git a/apps/api/prisma/migrations/20260902183000_activity_duration_reminder_priority/migration.sql b/apps/api/prisma/migrations/20260902183000_activity_duration_reminder_priority/migration.sql new file mode 100644 index 0000000..981313c --- /dev/null +++ b/apps/api/prisma/migrations/20260902183000_activity_duration_reminder_priority/migration.sql @@ -0,0 +1,7 @@ +-- AlterEnum +ALTER TYPE "ActivityPriority" ADD VALUE 'very_low'; +ALTER TYPE "ActivityPriority" ADD VALUE 'very_high'; + +-- AlterTable +ALTER TABLE "Activity" ADD COLUMN "durationMinutes" INTEGER; +ALTER TABLE "Activity" ADD COLUMN "reminderAt" TIMESTAMP(3); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a509fe5..3d973d4 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -33,6 +33,14 @@ enum ActivityStatus { canceled } +enum ActivityPriority { + low + medium + high + very_low + very_high +} + enum CustomFieldEntity { person company @@ -165,6 +173,7 @@ model User { ownedCompanies Company[] @relation("CompanyOwner") ownedProjectCards ProjectCard[] @relation("ProjectCardOwner") activities Activity[] @relation("ActivityAssignee") + createdActivities Activity[] @relation("ActivityCreator") notes Note[] @relation("NoteAuthor") noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader") notifications Notification[] @@ -333,10 +342,13 @@ model Activity { organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) type ActivityType @default(task) status ActivityStatus @default(open) - title String - description String? - dueAt DateTime? - completedAt DateTime? + priority ActivityPriority @default(medium) + title String + description String? + dueAt DateTime? + durationMinutes Int? + reminderAt DateTime? + completedAt DateTime? personId String? person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) companyId String? @@ -345,11 +357,14 @@ model Activity { deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) assigneeId String? assignee User? @relation("ActivityAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) + createdById String? + createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([organizationId, status, dueAt]) @@index([organizationId, assigneeId]) + @@index([organizationId, createdById]) @@index([personId]) @@index([companyId]) @@index([dealId]) diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index be7f66b..9fe7481 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -22,9 +22,12 @@ type ActivityRow = { id: string; type: ActivityDto['type']; status: ActivityDto['status']; + priority: ActivityDto['priority']; title: string; description: string | null; dueAt: Date | null; + durationMinutes: number | null; + reminderAt: Date | null; completedAt: Date | null; createdAt: Date; updatedAt: Date; @@ -32,6 +35,7 @@ type ActivityRow = { company: { id: string; name: string } | null; deal: { id: string; title: string } | null; assignee: { id: string; fullName: string } | null; + createdBy: { id: string; fullName: string } | null; }; const include = { @@ -39,6 +43,7 @@ const include = { company: { select: { id: true, name: true } }, deal: { select: { id: true, title: true } }, assignee: { select: { id: true, fullName: true } }, + createdBy: { select: { id: true, fullName: true } }, } satisfies Prisma.ActivityInclude; @Injectable() @@ -173,12 +178,16 @@ export class ActivitiesService { organizationId, title: dto.title.trim(), type: dto.type ?? 'task', + priority: dto.priority ?? 'medium', description: emptyToNull(dto.description), dueAt: dto.dueAt ? new Date(dto.dueAt) : null, + durationMinutes: dto.durationMinutes ?? null, + reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null, personId: emptyToNull(dto.personId ?? null), companyId: emptyToNull(dto.companyId ?? null), dealId: emptyToNull(dto.dealId ?? null), assigneeId, + createdById: userId, }, include, }); @@ -211,8 +220,11 @@ export class ActivitiesService { const data: Prisma.ActivityUncheckedUpdateInput = {}; if (dto.title !== undefined) data.title = dto.title.trim(); if (dto.type !== undefined) data.type = dto.type; + if (dto.priority !== undefined) data.priority = dto.priority; if ('description' in dto) data.description = emptyToNull(dto.description); if ('dueAt' in dto) data.dueAt = dto.dueAt ? new Date(dto.dueAt) : null; + if ('durationMinutes' in dto) data.durationMinutes = dto.durationMinutes ?? null; + if ('reminderAt' in dto) data.reminderAt = dto.reminderAt ? new Date(dto.reminderAt) : null; if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null); if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null); if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); @@ -394,9 +406,12 @@ export class ActivitiesService { id: row.id, type: row.type, status: row.status, + priority: row.priority, title: row.title, description: row.description, dueAt: row.dueAt?.toISOString() ?? null, + durationMinutes: row.durationMinutes, + reminderAt: row.reminderAt?.toISOString() ?? null, completedAt: row.completedAt?.toISOString() ?? null, person: row.person ? { @@ -407,6 +422,7 @@ export class ActivitiesService { company: row.company, deal: row.deal, assignee: row.assignee, + createdBy: row.createdBy, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts index a9842ac..a6edd42 100644 --- a/apps/api/src/activities/dto.ts +++ b/apps/api/src/activities/dto.ts @@ -14,6 +14,7 @@ import { const ACTIVITY_TYPES = ['call', 'meeting', 'task', 'note'] as const; const ACTIVITY_STATUSES = ['open', 'done', 'canceled'] as const; +const ACTIVITY_PRIORITIES = ['very_low', 'low', 'medium', 'high', 'very_high'] as const; const ACTIVITY_FILTERS = ['today', 'overdue', 'upcoming', 'done', 'all'] as const; export class ListActivitiesQueryDto { @@ -71,6 +72,10 @@ export class CreateActivityDto { @IsIn(ACTIVITY_TYPES) type?: (typeof ACTIVITY_TYPES)[number]; + @IsOptional() + @IsIn(ACTIVITY_PRIORITIES) + priority?: (typeof ACTIVITY_PRIORITIES)[number]; + @IsOptional() @IsString() @MaxLength(2000) @@ -81,6 +86,18 @@ export class CreateActivityDto { @IsDateString() dueAt?: string | null; + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(24 * 60) + durationMinutes?: number | null; + + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsDateString() + reminderAt?: string | null; + @IsOptional() @ValidateIf((_, v) => v !== '' && v != null) @IsString() @@ -117,6 +134,10 @@ export class UpdateActivityDto { @IsIn(ACTIVITY_STATUSES) status?: (typeof ACTIVITY_STATUSES)[number]; + @IsOptional() + @IsIn(ACTIVITY_PRIORITIES) + priority?: (typeof ACTIVITY_PRIORITIES)[number]; + @IsOptional() @IsString() @MaxLength(2000) @@ -127,6 +148,18 @@ export class UpdateActivityDto { @IsDateString() dueAt?: string | null; + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(24 * 60) + durationMinutes?: number | null; + + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsDateString() + reminderAt?: string | null; + @IsOptional() @ValidateIf((_, v) => v !== '' && v != null) @IsString() diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 7d214dd..9d85208 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -9,7 +9,6 @@ "target": "ES2021", "sourceMap": true, "outDir": "./dist", - "baseUrl": "./", "incremental": true, "skipLibCheck": true, "strict": true, diff --git a/apps/web/src/components/CustomFieldsSection.vue b/apps/web/src/components/CustomFieldsSection.vue index 0b473f6..0da833d 100644 --- a/apps/web/src/components/CustomFieldsSection.vue +++ b/apps/web/src/components/CustomFieldsSection.vue @@ -1,6 +1,7 @@ + + + + diff --git a/apps/web/src/components/NoteAiSuggestionPanel.vue b/apps/web/src/components/NoteAiSuggestionPanel.vue index 6b315ae..bdd0aa9 100644 --- a/apps/web/src/components/NoteAiSuggestionPanel.vue +++ b/apps/web/src/components/NoteAiSuggestionPanel.vue @@ -2,6 +2,7 @@ import { computed, reactive, ref, watch } from 'vue'; import axios from 'axios'; import { api } from '@/lib/api'; +import JalaliDateInput from '@/components/JalaliDateInput.vue'; import type { NoteAiPayloadDto, NoteAiSuggestionDto } from '@mento/shared'; const props = defineProps<{ @@ -190,7 +191,7 @@ function formatPayloadSummary(value: NoteAiPayloadDto | null) { کار بعدی - +
@@ -275,7 +276,6 @@ header { } input[type='text'], -input[type='datetime-local'], textarea { border: 1px solid var(--line); border-radius: 10px; diff --git a/apps/web/src/components/UiIcon.vue b/apps/web/src/components/UiIcon.vue index 71d29c5..df069a5 100644 --- a/apps/web/src/components/UiIcon.vue +++ b/apps/web/src/components/UiIcon.vue @@ -12,6 +12,7 @@ defineProps<{ | 'sparkle' | 'bell' | 'calendar' + | 'calendar-plus' | 'mail' | 'megaphone' | 'send' @@ -19,7 +20,31 @@ defineProps<{ | 'warning' | 'trend' | 'phone' - | 'trophy'; + | 'trophy' + | 'chat' + | 'dots' + | 'dots-v' + | 'document' + | 'bag' + | 'clock' + | 'user-plus' + | 'plus' + | 'chevron' + | 'star' + | 'filter' + | 'lightbulb' + | 'building' + | 'user' + | 'flag' + | 'refresh' + | 'x' + | 'mic' + | 'trash' + | 'stop' + | 'check-plain' + | 'calendar-sun' + | 'calendar-edit' + | 'minus'; size?: number; }>(); @@ -88,6 +113,22 @@ defineProps<{ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/components/UserAvatar.vue b/apps/web/src/components/UserAvatar.vue new file mode 100644 index 0000000..12a3e73 --- /dev/null +++ b/apps/web/src/components/UserAvatar.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/apps/web/src/components/activities/ActivityCommentSheet.vue b/apps/web/src/components/activities/ActivityCommentSheet.vue new file mode 100644 index 0000000..df65849 --- /dev/null +++ b/apps/web/src/components/activities/ActivityCommentSheet.vue @@ -0,0 +1,897 @@ + + +