From 7a55b84b816cae19763c2f0288f247bbb86e8565 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 12 Nov 2026 21:39:15 +0330 Subject: [PATCH] Add org-scoped activity priority labels/colors and apply them across forms, boards, and routines. Also fix activity leave-without-save confirm, kanban due-date labels with tone-colored peek chevron, and rename the types manager button. Co-authored-by: Cursor --- .../migration.sql | 2 + apps/api/prisma/schema.prisma | 2 + .../src/activities/activities.controller.ts | 14 ++ .../src/activities/activity-types.service.ts | 42 ++++ apps/api/src/activities/dto.ts | 25 ++ .../activities/ActivitiesKanban.vue | 17 +- .../activities/ActivityTypesManager.vue | 213 +++++++++++++++++- apps/web/src/components/pipeline/DealCard.vue | 106 +++++++-- .../components/routines/QuickTaskModal.vue | 8 +- .../components/routines/RoutineTaskDrawer.vue | 10 +- .../components/routines/RoutineTaskRow.vue | 7 +- apps/web/src/lib/activity-priorities.ts | 67 ++++++ apps/web/src/lib/routines.ts | 1 + apps/web/src/views/ActivitiesView.vue | 8 +- apps/web/src/views/ActivityDetailView.vue | 91 ++++++-- apps/web/src/views/ActivityFormView.vue | 47 ++-- .../src/views/routines/RoutineFormView.vue | 9 +- .../src/views/routines/RoutinesListView.vue | 6 +- .../views/routines/RoutinesOverdueView.vue | 6 +- packages/shared/src/index.ts | 65 ++++++ 20 files changed, 634 insertions(+), 112 deletions(-) create mode 100644 apps/api/prisma/migrations/20261112190000_activity_priority_settings/migration.sql create mode 100644 apps/web/src/lib/activity-priorities.ts diff --git a/apps/api/prisma/migrations/20261112190000_activity_priority_settings/migration.sql b/apps/api/prisma/migrations/20261112190000_activity_priority_settings/migration.sql new file mode 100644 index 0000000..80625ec --- /dev/null +++ b/apps/api/prisma/migrations/20261112190000_activity_priority_settings/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Organization" ADD COLUMN "activityPriorities" JSONB NOT NULL DEFAULT '{}'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 49712c6..1d275fb 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -162,6 +162,8 @@ model Organization { enabledCurrencies String[] @default(["IRR", "USD", "EUR"]) /// ماژول‌های فعال منو برای این محیط enabledModules String[] @default([]) + /// برچسب/رنگ اولویت فعالیت و روتین (کلیدهای enum ثابت؛ فقط ظاهر) + activityPriorities Json @default("{}") /// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخاب‌شده)؛ null = بدون فیلتر priorArchiveEnabledAt DateTime? isActive Boolean @default(true) diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index d8f161f..a71d5ee 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -7,6 +7,7 @@ import { Param, Patch, Post, + Put, Query, Res, StreamableFile, @@ -40,6 +41,7 @@ import { ReorderActivityTypesDto, UpdateActivityBoardDto, UpdateActivityDto, + UpdateActivityPrioritiesDto, UpdateActivityStageDto, UpdateActivityTypeDto, } from './dto'; @@ -139,6 +141,18 @@ export class ActivitiesController { return { ok: true }; } + @Get('priorities') + listPriorities(@CurrentUser() user: Actor) { + return this.typesService.listPriorities(user.organizationId); + } + + @Put('priorities') + @UseGuards(RolesGuard) + @Roles('owner', 'admin') + updatePriorities(@CurrentUser() user: Actor, @Body() dto: UpdateActivityPrioritiesDto) { + return this.typesService.updatePriorities(user.organizationId, dto.items); + } + @Get('types') listTypes(@CurrentUser() user: Actor) { return this.typesService.list(user.organizationId); diff --git a/apps/api/src/activities/activity-types.service.ts b/apps/api/src/activities/activity-types.service.ts index 8762ca0..14f58d8 100644 --- a/apps/api/src/activities/activity-types.service.ts +++ b/apps/api/src/activities/activity-types.service.ts @@ -6,6 +6,8 @@ import type { ActivityFieldValue, ActivityFieldValues, ActivityParticipantsValue, + ActivityPriority, + ActivityPrioritySetting, ActivityStageColor, ActivityType, ActivityTypeDefDto, @@ -17,8 +19,11 @@ import type { import { ACTIVITY_FIELD_TYPES, ACTIVITY_FIELD_TYPES_WITH_OPTIONS, + ACTIVITY_PRIORITY_KEYS, ACTIVITY_STAGE_COLORS, ACTIVITY_TYPE_ICONS, + DEFAULT_ACTIVITY_PRIORITY_SETTINGS, + parseActivityPrioritySettings, } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { randomUUID } from 'node:crypto'; @@ -619,6 +624,43 @@ export class ActivityTypesService { : 'green'; } + async listPriorities(organizationId: string): Promise { + const row = await this.prisma.organization.findUnique({ + where: { id: organizationId }, + select: { activityPriorities: true }, + }); + return parseActivityPrioritySettings(row?.activityPriorities); + } + + async updatePriorities( + organizationId: string, + items: Array<{ key: ActivityPriority; label: string; color: string }>, + ): Promise { + const byKey = new Map(items.map((i) => [i.key, i])); + if (ACTIVITY_PRIORITY_KEYS.some((k) => !byKey.has(k))) { + throw new BadRequestException('هر پنج سطح اولویت باید ارسال شوند'); + } + const hex = /^#[0-9a-fA-F]{6}$/; + const stored: Record = {}; + for (const key of ACTIVITY_PRIORITY_KEYS) { + const item = byKey.get(key)!; + const label = item.label.trim().slice(0, 40); + const color = item.color.trim(); + if (!label) throw new BadRequestException(`عنوان اولویت «${key}» خالی است`); + if (!hex.test(color)) throw new BadRequestException(`رنگ اولویت «${key}» نامعتبر است`); + const def = DEFAULT_ACTIVITY_PRIORITY_SETTINGS.find((d) => d.key === key)!; + // فقط تفاوت با پیش‌فرض را نگه می‌داریم تا JSON جمع‌وجور بماند + if (label !== def.label || color.toLowerCase() !== def.color.toLowerCase()) { + stored[key] = { label, color }; + } + } + await this.prisma.organization.update({ + where: { id: organizationId }, + data: { activityPriorities: stored as Prisma.InputJsonValue }, + }); + return parseActivityPrioritySettings(stored); + } + private mapError(e: unknown) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { return new BadRequestException('نوع فعالیتی با این نام وجود دارد'); diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts index 43027e0..0a683ec 100644 --- a/apps/api/src/activities/dto.ts +++ b/apps/api/src/activities/dto.ts @@ -9,11 +9,13 @@ import { IsObject, IsOptional, IsString, + Matches, Max, MaxLength, Min, MinLength, ValidateIf, + ValidateNested, } from 'class-validator'; import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared'; import { ACTIVITY_TYPE_ICONS } from '@mento/shared'; @@ -501,3 +503,26 @@ export class UpdateActivityDto { @IsIn(['creator', 'assignees']) editPolicy?: 'creator' | 'assignees'; } + +const PRIORITY_KEYS = ['very_low', 'low', 'medium', 'high', 'very_high'] as const; + +export class ActivityPriorityItemDto { + @IsIn(PRIORITY_KEYS) + key!: (typeof PRIORITY_KEYS)[number]; + + @IsString() + @MinLength(1) + @MaxLength(40) + label!: string; + + @IsString() + @Matches(/^#[0-9a-fA-F]{6}$/) + color!: string; +} + +export class UpdateActivityPrioritiesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ActivityPriorityItemDto) + items!: ActivityPriorityItemDto[]; +} diff --git a/apps/web/src/components/activities/ActivitiesKanban.vue b/apps/web/src/components/activities/ActivitiesKanban.vue index f812b70..03e3b61 100644 --- a/apps/web/src/components/activities/ActivitiesKanban.vue +++ b/apps/web/src/components/activities/ActivitiesKanban.vue @@ -9,7 +9,7 @@ import type { } from '@mento/shared'; import { api } from '@/lib/api'; import { activityTypeView } from '@/lib/activity-types'; -import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali'; +import { formatRelativeDayLabel, toFaDigits } from '@/lib/jalali'; import PipelineBoard from '@/components/pipeline/PipelineBoard.vue'; import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue'; import ActivityQuickView from '@/components/activities/ActivityQuickView.vue'; @@ -130,18 +130,6 @@ function apiMessage(e: unknown, fallback: string) { return fallback; } -function dateText(item: ActivityDto) { - if (!item.dueAt) return 'بدون موعد'; - const due = new Date(item.dueAt); - const days = calendarDaysBetween(due, new Date()); - const day = - days === 0 ? 'امروز' : days === 1 ? 'دیروز' : days === -1 ? 'فردا' : formatJalaliDay(due); - const late = - item.status === 'open' && - startOfLocalDay(due).getTime() < startOfLocalDay().getTime(); - return `${late ? 'عقب‌افتاده · ' : ''}${day} · ${formatClockFa(due)}`; -} - function toCard(item: ActivityDto, stageId: string): PipelineDeal { const party = item.company?.name || item.person?.fullName || item.deal?.title || ''; const assigneeNames = item.assignees?.length @@ -154,7 +142,8 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal { title: item.title, description: [activityTypeView(item).name, party].filter(Boolean).join(' · '), amount: null, - date: dateText(item), + date: item.dueAt ? formatRelativeDayLabel(item.dueAt) : 'بدون موعد', + dateIso: item.dueAt ?? undefined, initials: initialsFromTitle(party || item.title), stageId, createdAt: item.createdAt, diff --git a/apps/web/src/components/activities/ActivityTypesManager.vue b/apps/web/src/components/activities/ActivityTypesManager.vue index 3fb07f3..27f4c8a 100644 --- a/apps/web/src/components/activities/ActivityTypesManager.vue +++ b/apps/web/src/components/activities/ActivityTypesManager.vue @@ -4,6 +4,7 @@ import axios from 'axios'; import type { ActivityFieldDef, ActivityFieldType, + ActivityPrioritySetting, ActivityStageColor, ActivityType, ActivityTypeDefDto, @@ -17,9 +18,11 @@ import { ACTIVITY_STAGE_COLORS, ACTIVITY_TYPE_ICONS, ACTIVITY_TYPE_LABELS, + DEFAULT_ACTIVITY_PRIORITY_SETTINGS, } from '@mento/shared'; import { api } from '@/lib/api'; import { toFaDigits } from '@/lib/jalali'; +import { useActivityPriorities } from '@/lib/activity-priorities'; import { ACTIVITY_TYPE_ICON_LABELS, newFieldKey, @@ -32,6 +35,17 @@ import UiIcon from '@/components/UiIcon.vue'; const props = defineProps<{ open: boolean }>(); const emit = defineEmits<{ close: []; changed: [] }>(); +type MgrTab = 'types' | 'priorities'; +const tab = ref('types'); +const prioritiesApi = useActivityPriorities(); +const priorityDraft = ref( + DEFAULT_ACTIVITY_PRIORITY_SETTINGS.map((r) => ({ ...r })), +); +const prioritySaved = ref(''); +const priorityDirty = computed( + () => JSON.stringify(priorityDraft.value) !== prioritySaved.value, +); + const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note']; type FieldDraft = ActivityFieldDef & { optionsText: string }; @@ -96,13 +110,74 @@ function select(id: string | 'new') { notice.value = ''; } +async function loadPrioritiesTab() { + await prioritiesApi.load(true); + priorityDraft.value = prioritiesApi.items.value.map((r) => ({ ...r })); + prioritySaved.value = JSON.stringify(priorityDraft.value); +} + +function switchTab(next: MgrTab) { + if (next === tab.value) return; + if (tab.value === 'types' && dirty.value && draft.value) { + if (!window.confirm('تغییرات ذخیره نشده از بین می‌رود. ادامه می‌دهید؟')) return; + } + if (tab.value === 'priorities' && priorityDirty.value) { + if (!window.confirm('تغییرات اولویت‌ها ذخیره نشده از بین می‌رود. ادامه می‌دهید؟')) return; + } + tab.value = next; + error.value = ''; + notice.value = ''; + if (next === 'priorities') void loadPrioritiesTab(); +} + +async function savePriorities() { + for (const row of priorityDraft.value) { + if (!row.label.trim()) { + error.value = 'عنوان همهٔ سطوح اولویت را بنویسید.'; + return; + } + if (!/^#[0-9a-fA-F]{6}$/.test(row.color)) { + error.value = 'رنگ باید به صورت #RRGGBB باشد.'; + return; + } + } + busy.value = 'prio'; + error.value = ''; + notice.value = ''; + try { + const { data } = await api.put('/activities/priorities', { + items: priorityDraft.value.map((r) => ({ + key: r.key, + label: r.label.trim(), + color: r.color, + })), + }); + prioritiesApi.setLocal(data); + priorityDraft.value = data.map((r) => ({ ...r })); + prioritySaved.value = JSON.stringify(priorityDraft.value); + notice.value = 'اولویت‌ها ذخیره شد.'; + emit('changed'); + } catch (e) { + error.value = apiMessage(e, 'ذخیرهٔ اولویت‌ها ناموفق بود.'); + } finally { + busy.value = ''; + } +} + +function resetPriorities() { + if (!window.confirm('عنوان و رنگ‌ها به پیش‌فرض برگردند؟')) return; + priorityDraft.value = DEFAULT_ACTIVITY_PRIORITY_SETTINGS.map((r) => ({ ...r })); +} + watch( () => props.open, async (open) => { if (!open) return; error.value = ''; notice.value = ''; + tab.value = 'types'; await load(true); + await prioritiesApi.load(true); const first = types.value[0]; selectedId.value = null; draft.value = null; @@ -283,7 +358,12 @@ async function moveType(index: number, dir: -1 | 1) { } function close() { - if (dirty.value && draft.value && !window.confirm('تغییرات ذخیره نشده از بین می‌رود. بستن؟')) return; + if (tab.value === 'types' && dirty.value && draft.value) { + if (!window.confirm('تغییرات ذخیره نشده از بین می‌رود. بستن؟')) return; + } + if (tab.value === 'priorities' && priorityDirty.value) { + if (!window.confirm('تغییرات اولویت‌ها ذخیره نشده از بین می‌رود. بستن؟')) return; + } emit('close'); } @@ -291,6 +371,28 @@ function close() { @@ -489,6 +628,78 @@ function close() { gap: 0.9rem; } +.tabs { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.tabs button { + border: 1px solid var(--line); + background: #fff; + border-radius: 999px; + padding: 0.35rem 0.85rem; + font: inherit; + font-size: 0.84rem; + cursor: pointer; + color: #475569; +} + +.tabs button.on { + border-color: #5eead4; + background: #f0fdfa; + color: #0f766e; + font-weight: 600; +} + +.prio { + display: grid; + gap: 0.85rem; +} + +.prio-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.55rem; +} + +.prio-row { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 0.65rem; + padding: 0.55rem 0.65rem; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; +} + +.swatch { + width: 1.1rem; + height: 1.1rem; + border-radius: 999px; + margin-bottom: 0.55rem; + flex-shrink: 0; + border: 1px solid rgba(15, 23, 42, 0.12); +} + +.prio-row .grow { + flex: 1; + min-width: 10rem; +} + +.prio-row .color input[type='color'] { + width: 3rem; + height: 2.1rem; + padding: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: none; + cursor: pointer; +} + .hint { margin: 0; color: #64748b; diff --git a/apps/web/src/components/pipeline/DealCard.vue b/apps/web/src/components/pipeline/DealCard.vue index 7c1dd57..af9ee84 100644 --- a/apps/web/src/components/pipeline/DealCard.vue +++ b/apps/web/src/components/pipeline/DealCard.vue @@ -1,10 +1,11 @@