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 <cursoragent@cursor.com>
This commit is contained in:
2026-11-12 21:39:15 +03:30
co-authored by Cursor
parent a1ac677b3a
commit 7a55b84b81
20 changed files with 634 additions and 112 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN "activityPriorities" JSONB NOT NULL DEFAULT '{}';
+2
View File
@@ -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)
@@ -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);
@@ -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<ActivityPrioritySetting[]> {
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<ActivityPrioritySetting[]> {
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<string, { label: string; color: string }> = {};
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('نوع فعالیتی با این نام وجود دارد');
+25
View File
@@ -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[];
}
@@ -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,
@@ -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<MgrTab>('types');
const prioritiesApi = useActivityPriorities();
const priorityDraft = ref<ActivityPrioritySetting[]>(
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<ActivityPrioritySetting[]>('/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');
}
</script>
@@ -291,6 +371,28 @@ function close() {
<template>
<UiModal :open="open" title="انواع فعالیت و فیلدهای اختصاصی" @close="close">
<div class="mgr">
<div class="tabs" role="tablist">
<button
type="button"
role="tab"
:class="{ on: tab === 'types' }"
:aria-selected="tab === 'types'"
@click="switchTab('types')"
>
انواع و فیلدها
</button>
<button
type="button"
role="tab"
:class="{ on: tab === 'priorities' }"
:aria-selected="tab === 'priorities'"
@click="switchTab('priorities')"
>
اولویت‌ها
</button>
</div>
<template v-if="tab === 'types'">
<p class="hint">
برای هر نوع فعالیت فیلدهای مخصوص خودش را تعریف کنید؛ مثلاً «موضوع جلسه» و «شرکت‌کنندگان» برای جلسه
یا «لیست کارها» برای وظیفه. «نوع پایه» مشخص می‌کند فعالیت در گزارش‌ها جزو تماس، جلسه، وظیفه یا پیام
@@ -479,6 +581,43 @@ function close() {
</section>
<p v-else class="empty">یک نوع فعالیت را انتخاب کنید.</p>
</div>
</template>
<div v-else class="prio">
<p class="hint">
پنج سطح اولویت ثابت است؛ فقط عنوان و رنگ را عوض کنید. تغییر عنوان روی همهٔ فعالیت‌ها و کارهای
روتین قبلی هم اعمال می‌شود (کلید داخلی عوض نمی‌شود).
</p>
<ul class="prio-list">
<li v-for="row in priorityDraft" :key="row.key" class="prio-row">
<span class="swatch" :style="{ background: row.color }" aria-hidden="true" />
<label class="f grow">
<span>عنوان</span>
<input v-model="row.label" maxlength="40" />
</label>
<label class="f color">
<span>رنگ</span>
<input v-model="row.color" type="color" :aria-label="`رنگ ${row.label}`" />
</label>
</li>
</ul>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="notice" class="notice">{{ notice }}</p>
<div class="actions">
<UiButton
variant="primary"
type="button"
:disabled="!priorityDirty || Boolean(busy)"
:loading="busy === 'prio'"
@click="savePriorities"
>
ذخیرهٔ اولویت‌ها
</UiButton>
<UiButton variant="ghost" type="button" :disabled="Boolean(busy)" @click="resetPriorities">
بازگشت به پیش‌فرض
</UiButton>
</div>
</div>
</div>
</UiModal>
</template>
@@ -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;
+81 -25
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import {
CalendarOutlined,
CheckOutlined,
DeleteOutlined,
FlagFilled,
LeftOutlined,
MoreOutlined,
} from '@ant-design/icons-vue';
import {
@@ -19,11 +20,11 @@ import {
theme,
} from 'ant-design-vue';
import DealQuickActivityPopover from '@/components/pipeline/DealQuickActivityPopover.vue';
import { formatJalaliNumeric } from '@/lib/jalali';
import { calendarDaysBetween, formatJalaliNumeric } from '@/lib/jalali';
import { userAvatarSrc } from '@/lib/userAvatars';
import type { ActivityPriority } from '@mento/shared';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board';
import { useActivityPriorities } from '@/lib/activity-priorities';
const props = withDefaults(
defineProps<{
@@ -80,16 +81,18 @@ const dateHint = computed(() => {
if (Number.isNaN(d.getTime())) return props.deal.date;
return formatJalaliNumeric(d);
});
const PRIORITY_FLAG: Record<ActivityPriority, { label: string; color: string }> = {
very_high: { label: 'خیلی زیاد', color: '#dc2626' },
high: { label: 'زیاد', color: '#ea580c' },
medium: { label: 'متوسط', color: '#eab308' },
low: { label: 'کم', color: '#16a34a' },
very_low: { label: 'خیلی کم', color: '#94a3b8' },
};
const priorityFlag = computed(() =>
props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null,
);
const activityPriorities = useActivityPriorities();
onMounted(() => {
void activityPriorities.load();
});
const priorityFlag = computed(() => {
const key = props.deal.priority;
if (!key) return null;
return {
label: activityPriorities.labelOf(key),
color: activityPriorities.colorOf(key),
};
});
function onTitleClick() {
if (props.selectMode) {
@@ -100,13 +103,49 @@ function onTitleClick() {
else emit('open', props.deal.id);
}
const chevronStyle = computed(() => ({
display: 'inline-block',
width: '0.42rem',
height: '0.42rem',
borderRight: '1.6px solid currentColor',
borderBottom: '1.6px solid currentColor',
transform: props.previewing ? 'rotate(-45deg)' : 'rotate(135deg)',
/** موعد: عقب‌افتاده قرمز · امروز سبز · آینده خاکستری */
const dueTone = computed<'overdue' | 'today' | 'upcoming' | 'none'>(() => {
if (!props.copy.quickView || !props.deal.dateIso) return 'none';
const due = new Date(props.deal.dateIso);
if (Number.isNaN(due.getTime())) return 'none';
const days = calendarDaysBetween(new Date(), due);
if (days < 0) return 'overdue';
if (days === 0) return 'today';
return 'upcoming';
});
const dueToneColor = computed(() => {
if (dueTone.value === 'overdue') return token.value.colorError;
if (dueTone.value === 'today') return token.value.colorSuccess;
return token.value.colorTextSecondary;
});
const chevronBtnStyle = computed(() => ({
boxSizing: 'border-box' as const,
width: '22px',
height: '22px',
margin: 0,
padding: 0,
border: `1.5px solid ${dueToneColor.value}`,
borderRadius: '50%',
background: 'transparent',
color: dueToneColor.value,
display: 'inline-grid',
placeItems: 'center',
cursor: 'pointer',
lineHeight: 0,
flexShrink: 0,
}));
const chevronIconStyle = computed(() => ({
fontSize: '10px',
width: '10px',
height: '10px',
lineHeight: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transform: props.previewing ? 'rotate(180deg)' : 'none',
transition: 'transform 0.15s ease',
}));
@@ -262,16 +301,17 @@ const cardStyle = computed(() => ({
{{ money.unit }}
</a-typography-text>
</a-typography-text>
<a-button
<button
v-if="copy.quickView && !selectMode"
type="text"
type="button"
class="deal-card-peek"
:aria-label="previewing ? 'بستن پیش‌نمایش' : 'پیش‌نمایش فعالیت'"
:aria-expanded="previewing"
:style="{ color: token.colorTextSecondary }"
:style="chevronBtnStyle"
@click.stop="emit('open', deal.id)"
>
<template #icon><i class="chev" aria-hidden="true" :style="chevronStyle" /></template>
</a-button>
<LeftOutlined :style="chevronIconStyle" />
</button>
<a-tooltip v-else-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
@@ -292,3 +332,19 @@ const cardStyle = computed(() => ({
</a-flex>
</a-card>
</template>
<style scoped>
/* وسط‌چین دقیق آیکون داخل دایره (anticon پیش‌فرض vertical-align دارد) */
.deal-card-peek :deep(.anticon) {
display: inline-flex !important;
align-items: center;
justify-content: center;
vertical-align: 0;
line-height: 0;
}
.deal-card-peek :deep(.anticon > svg) {
display: block;
width: 10px;
height: 10px;
}
</style>
@@ -24,9 +24,9 @@ import {
} from 'ant-design-vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { api } from '@/lib/api';
import { useActivityPriorities } from '@/lib/activity-priorities';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
ROUTINES_CTX,
apiError,
localInputToIso,
@@ -68,10 +68,10 @@ const form = reactive({
const saving = ref(false);
const error = ref('');
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const priorityOptions = computed(() =>
(Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>).map(
([value, label]) => ({ value, label }),
),
activityPriorities.items.value.map((row) => ({ value: row.key, label: row.label })),
);
const categoryOptions = computed(() => [
@@ -25,9 +25,9 @@ import { api } from '@/lib/api';
import { canAssignToOthers } from '@/lib/assign-access';
import { useAuthStore } from '@/stores/auth';
import { formatJalaliDayClock } from '@/lib/jalali';
import { useActivityPriorities } from '@/lib/activity-priorities';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINES_CTX,
ROUTINE_ENTITY_LINK_LABELS,
@@ -108,7 +108,11 @@ const done = computed(() => task.value?.status === 'completed');
const auth = useAuthStore();
const canManage = computed(() => !!shell?.context.value?.isManager);
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'routines'));
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const priorities = computed(() =>
activityPriorities.items.value.map((r) => [r.key, r.label] as [ActivityPriority, string]),
);
function startEdit() {
const t = task.value;
@@ -193,7 +197,7 @@ function eventText(ev: RoutineTaskEventDto): string {
<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] }}
اولویت {{ activityPriorities.labelOf(task.priority) }}
</UiBadge>
<UiBadge v-if="task.isMandatory" variant="warning" size="sm">اجباری</UiBadge>
</div>
@@ -23,9 +23,9 @@ import {
const AMenuItem = AMenu.Item;
import UserAvatar from '@/components/UserAvatar.vue';
import { useActivityPriorities } from '@/lib/activity-priorities';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
ROUTINE_TASK_SOURCE_LABELS,
ROUTINE_TASK_STATUS_LABELS,
entityRoute,
@@ -64,6 +64,9 @@ const emit = defineEmits<{
action: [kind: RoutineTaskRowAction, task: RoutineTaskDto];
}>();
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
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));
@@ -150,7 +153,7 @@ function onMenuClick({ key }: { key: string | number }) {
<CheckOutlined /> {{ formatTime(task.completedAt) }}
</a-typography-text>
<a-tag v-if="task.category" :bordered="false">{{ task.category.name }}</a-tag>
<a-tag :bordered="false">{{ PRIORITY_LABELS[task.priority] }}</a-tag>
<a-tag :bordered="false">{{ activityPriorities.labelOf(task.priority) }}</a-tag>
<a-tag v-if="task.status !== 'todo'" :bordered="false">
{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}
</a-tag>
+67
View File
@@ -0,0 +1,67 @@
import { computed, ref } from 'vue';
import type { ActivityPriority, ActivityPrioritySetting } from '@mento/shared';
import {
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
parseActivityPrioritySettings,
} from '@mento/shared';
import { api } from '@/lib/api';
const items = ref<ActivityPrioritySetting[]>([...DEFAULT_ACTIVITY_PRIORITY_SETTINGS]);
const loaded = ref(false);
let pending: Promise<void> | null = null;
/** تنظیمات اولویت فعالیت/روتین (کش مشترک) */
export function useActivityPriorities() {
async function load(force = false): Promise<void> {
if (loaded.value && !force) return;
if (pending && !force) return pending;
pending = api
.get<ActivityPrioritySetting[]>('/activities/priorities')
.then(({ data }) => {
items.value = parseActivityPrioritySettings(data);
loaded.value = true;
})
.catch(() => {
if (!loaded.value) items.value = [...DEFAULT_ACTIVITY_PRIORITY_SETTINGS];
})
.finally(() => {
pending = null;
});
return pending;
}
const byKey = computed(() => {
const map = new Map<ActivityPriority, ActivityPrioritySetting>();
for (const row of items.value) map.set(row.key, row);
return map;
});
const labels = computed(() => {
const out = {} as Record<ActivityPriority, string>;
for (const row of items.value) out[row.key] = row.label;
return out;
});
const colors = computed(() => {
const out = {} as Record<ActivityPriority, string>;
for (const row of items.value) out[row.key] = row.color;
return out;
});
function labelOf(key: ActivityPriority | string | null | undefined): string {
if (!key) return '—';
return byKey.value.get(key as ActivityPriority)?.label ?? String(key);
}
function colorOf(key: ActivityPriority | string | null | undefined): string {
if (!key) return '#94a3b8';
return byKey.value.get(key as ActivityPriority)?.color ?? '#94a3b8';
}
function setLocal(next: ActivityPrioritySetting[]) {
items.value = parseActivityPrioritySettings(next);
loaded.value = true;
}
return { items, byKey, labels, colors, loaded, load, labelOf, colorOf, setLocal };
}
+1
View File
@@ -29,6 +29,7 @@ export {
type BadgeVariant = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info';
/** پیش‌فرض؛ برای نمایش زنده از useActivityPriorities استفاده کنید */
export const PRIORITY_LABELS: Record<ActivityPriority, string> = {
very_low: 'خیلی کم',
low: 'کم',
+5 -3
View File
@@ -25,6 +25,7 @@ import ActivityDeskFilters from '@/components/activities/ActivityDeskFilters.vue
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import {
ACTIVITY_EXCEL_ALIASES,
@@ -180,6 +181,7 @@ const boardDeleteOpen = ref(false);
async function onTypesChanged() {
await activityTypes.load(true);
await useActivityPriorities().load(true);
kanbanReload.value += 1;
await refreshAll();
}
@@ -1217,11 +1219,11 @@ onUnmounted(() => {
variant="ghost"
size="sm"
type="button"
title="انواع فعالیت و فیلدها"
title="انواع فعالیت و اولویت ها"
@click="typesManagerOpen = true"
>
<UiIcon name="sliders" :size="14" />
<span class="manage-label">انواع فعالیت و فیلدها</span>
<span class="manage-label">انواع فعالیت و اولویت ها</span>
</UiButton>
<UiButton
v-if="canManageStages"
@@ -1380,7 +1382,7 @@ onUnmounted(() => {
<hr class="board-menu-sep" />
<button type="button" class="menu-row" @click="typesManagerOpen = true">
<UiIcon name="sliders" :size="16" />
انواع فعالیت و فیلدها
انواع فعالیت و اولویت ها
</button>
<button type="button" class="menu-row" @click="stagesManagerOpen = true">
<UiIcon name="settings" :size="16" />
+66 -25
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { computed, h, onMounted, onUnmounted, ref, watch } from 'vue';
import { RouterLink, onBeforeRouteLeave, useRoute, useRouter } from 'vue-router';
import { api } from '@/lib/api';
import {
@@ -18,7 +18,7 @@ import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.v
import ActivityPostponeSheet from '@/components/activities/ActivityPostponeSheet.vue';
import ActivityFieldsView from '@/components/activities/ActivityFieldsView.vue';
import StagePathStrip from '@/components/StagePathStrip.vue';
import { message, Modal } from 'ant-design-vue';
import { Button as AButton, message, Modal } from 'ant-design-vue';
import type {
ActivityChecklistItem,
ActivityDto,
@@ -32,6 +32,7 @@ import { useAuthStore } from '@/stores/auth';
import { canCollaborateOnActivity, canMutateActivity } from '@/lib/activity-access';
import { canAssignToOthers } from '@/lib/assign-access';
import { apiError } from '@/lib/routines';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import {
bindThreadMedia,
@@ -631,11 +632,59 @@ async function persistDrafts(): Promise<boolean> {
}
}
/** true = مجاز به ترک صفحه · false = بمان روی جزئیات */
async function resolveDirty(): Promise<boolean> {
if (!isDirty.value) return true;
if (confirm('تغییرات ثبت شود؟')) return persistDrafts();
discardDrafts();
return true;
return new Promise((resolve) => {
const modal = Modal.confirm({
title: 'تغییرات ذخیره‌نشده',
content: 'تغییراتی در این فعالیت انجام داده‌اید، آیا ذخیره شود؟',
centered: true,
closable: false,
maskClosable: false,
keyboard: false,
icon: null,
footer: () =>
h('div', { class: 'ant-modal-confirm-btns' }, [
h(
AButton,
{
onClick: () => {
modal.destroy();
resolve(false);
},
},
() => 'ادامهٔ ویرایش',
),
h(
AButton,
{
onClick: () => {
discardDrafts();
modal.destroy();
resolve(true);
},
},
() => 'ذخیره نکن',
),
h(
AButton,
{
type: 'primary',
onClick: () => {
void (async () => {
const ok = await persistDrafts();
if (!ok) return;
modal.destroy();
resolve(true);
})();
},
},
() => 'ذخیره',
),
]),
});
});
}
function onBeforeUnload(event: BeforeUnloadEvent) {
@@ -671,13 +720,9 @@ async function toggleChecklistItem(key: string, itemId: string, done: boolean) {
}
}
const priorityLabel: Record<ActivityPriority, string> = {
very_low: 'خیلی کم',
low: 'کم',
medium: 'متوسط',
high: 'زیاد',
very_high: 'خیلی زیاد',
};
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const priorityLabel = computed(() => activityPriorities.labels.value);
const subtitle = computed(() => {
const a = activity.value;
@@ -1039,17 +1084,8 @@ watch(id, () => {
void load();
});
watch(() => route.query.nav, () => void loadNav(), { immediate: true });
onBeforeRouteLeave(async (_to, _from, next) => {
if (!isDirty.value) {
next();
return;
}
if (confirm('تغییرات ثبت شود؟')) next(await persistDrafts());
else {
discardDrafts();
next();
}
});
/** بدون next() — در Vue Router 4 گارد async+next باعث رد شدن بدون پرسش می‌شد */
onBeforeRouteLeave(async () => resolveDirty());
onMounted(() => {
window.addEventListener('beforeunload', onBeforeUnload);
void load();
@@ -1150,12 +1186,17 @@ onUnmounted(() => {
</div>
<div>
<dt>
<UiIcon name="flag" :size="15" class="flag" :class="draftPriority" />
<UiIcon
name="flag"
:size="15"
class="flag"
:style="{ color: activityPriorities.colorOf(draftPriority) }"
/>
اولویت
</dt>
<dd>
<select v-model="draftPriority" class="meta-control" :disabled="editLocked" aria-label="اولویت">
<option v-for="(label, key) in priorityLabel" :key="key" :value="key">{{ label }}</option>
<option v-for="(label, key) in priorityLabel" :key="String(key)" :value="key">{{ label }}</option>
</select>
</dd>
</div>
+18 -29
View File
@@ -32,6 +32,7 @@ import {
UiTextarea,
} from '@/components/ui';
import ActivityFieldsEditor from '@/components/activities/ActivityFieldsEditor.vue';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import type {
ActivityBoardDto,
@@ -89,6 +90,8 @@ const boardId = ref('');
const defaultBoardId = computed(() => boards.value.find((b) => b.isDefault)?.id ?? '');
const activityTypes = useActivityTypes();
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const title = ref('');
const type = ref<ActivityType | ''>('');
const typeDefId = ref('');
@@ -271,13 +274,13 @@ function isEmptyValue(v: ActivityFieldValue | undefined): boolean {
return false;
}
const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: string }> = [
{ value: 'very_high', label: 'خیلی زیاد', tone: 'very-high' },
{ value: 'high', label: 'زیاد', tone: 'high' },
{ value: 'medium', label: 'متوسط', tone: 'medium' },
{ value: 'low', label: 'کم', tone: 'low' },
{ value: 'very_low', label: 'خیلی کم', tone: 'very-low' },
];
const priorityOptions = computed(() =>
activityPriorities.items.value.map((row) => ({
value: row.key,
label: row.label,
color: row.color,
})),
);
const typeMeta = computed(() => activityTypes.byId.value.get(typeDefId.value) ?? null);
const typeFields = computed(() => typeMeta.value?.fields ?? []);
@@ -402,7 +405,7 @@ const duePreview = computed(() => {
const statusText = computed(() => (isDone.value ? 'انجام شده' : 'انجام نشده'));
const priorityText = computed(
() => priorityOptions.find((p) => p.value === priority.value)?.label || '—',
() => priorityOptions.value.find((p) => p.value === priority.value)?.label || '—',
);
const pageTitle = computed(() => (isNew.value ? 'ایجاد فعالیت جدید' : 'ویرایش فعالیت'));
const summaryReady = computed(() => Boolean(title.value.trim() || type.value || selectedPerson.value));
@@ -1442,7 +1445,13 @@ watch([id, isNew], () => void load(), { immediate: true });
<div class="card">
<p class="pri-label">اولویت</p>
<div class="priorities">
<label v-for="opt in priorityOptions" :key="opt.value" :class="['pri', opt.tone, { on: priority === opt.value }]">
<label
v-for="opt in priorityOptions"
:key="opt.value"
class="pri"
:class="{ on: priority === opt.value }"
:style="{ color: opt.color }"
>
<input v-model="priority" type="radio" :value="opt.value" />
<UiIcon name="flag" :size="16" />
<span>{{ opt.label }}</span>
@@ -2365,26 +2374,6 @@ watch([id, isNew], () => void load(), { immediate: true });
flex-shrink: 0;
}
.pri.very-high {
color: #dc2626;
}
.pri.high {
color: #ea580c;
}
.pri.medium {
color: #eab308;
}
.pri.low {
color: #16a34a;
}
.pri.very-low {
color: #94a3b8;
}
.pri.on span {
font-weight: 600;
}
@@ -17,7 +17,8 @@ import {
} from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { PRIORITY_LABELS, ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
const shell = inject(ROUTINES_CTX)!;
@@ -73,7 +74,11 @@ const targetOptions = [
{ value: 'team' as const, label: 'همه اعضای یک تیم' },
];
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const priorities = computed(() =>
activityPriorities.items.value.map((r) => [r.key, r.label] as [ActivityPriority, string]),
);
const entityLinks = Object.entries(ROUTINE_ENTITY_LINK_LABELS) as Array<[RoutineEntityLink, string]>;
const reminderOptions = [
{ value: '', label: 'طبق تنظیمات هر کاربر' },
@@ -7,8 +7,8 @@ import { UiBadge, UiEmptyState, UiSearch } from '@/components/ui';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { toFaDigits } from '@/lib/jalali';
import { useActivityPriorities } from '@/lib/activity-priorities';
import {
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINES_CTX,
apiError,
@@ -18,6 +18,8 @@ import {
} from '@/lib/routines';
const shell = inject(ROUTINES_CTX)!;
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const auth = useAuthStore();
const meId = computed(() => auth.user?.id ?? '');
@@ -80,7 +82,7 @@ const filtered = computed(() => {
<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 size="sm" :variant="PRIORITY_VARIANTS[r.priority]">{{ activityPriorities.labelOf(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>
@@ -9,9 +9,9 @@ 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 { useActivityPriorities } from '@/lib/activity-priorities';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINES_CTX,
apiError,
@@ -24,6 +24,8 @@ import {
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
const shell = inject(ROUTINES_CTX)!;
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const items = ref<RoutineTaskDto[]>([]);
const loading = ref(true);
@@ -133,7 +135,7 @@ const showAssignee = computed(() => isManager.value && who.value !== '');
</span>
</td>
<td>
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[t.priority]">{{ PRIORITY_LABELS[t.priority] }}</UiBadge>
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[t.priority]">{{ activityPriorities.labelOf(t.priority) }}</UiBadge>
</td>
<td>
<RouterLink
+65
View File
@@ -1874,6 +1874,71 @@ export interface DealActivitySummaryDto {
cue: Exclude<DealActivityCue, "none">;
}
export type ActivityPriority = "very_low" | "low" | "medium" | "high" | "very_high";
/** ترتیب نمایش ثابت (تعداد تغییر نمی‌کند) */
export const ACTIVITY_PRIORITY_KEYS: ActivityPriority[] = [
"very_high",
"high",
"medium",
"low",
"very_low",
];
export type ActivityPrioritySetting = {
key: ActivityPriority;
label: string;
/** رنگ متن/پرچم — #RRGGBB */
color: string;
};
export const DEFAULT_ACTIVITY_PRIORITY_SETTINGS: ActivityPrioritySetting[] = [
{ key: "very_high", label: "خیلی زیاد", color: "#dc2626" },
{ key: "high", label: "زیاد", color: "#ea580c" },
{ key: "medium", label: "متوسط", color: "#eab308" },
{ key: "low", label: "کم", color: "#16a34a" },
{ key: "very_low", label: "خیلی کم", color: "#94a3b8" },
];
const PRIORITY_KEY_SET = new Set<string>(ACTIVITY_PRIORITY_KEYS);
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
/** ادغام تنظیم ذخیره‌شده با پیش‌فرض‌ها؛ همیشه ۵ سطح کامل */
export function parseActivityPrioritySettings(raw: unknown): ActivityPrioritySetting[] {
const overrides = new Map<ActivityPriority, { label?: string; color?: string }>();
const bag =
raw && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: Array.isArray(raw)
? Object.fromEntries(
raw
.filter((r): r is Record<string, unknown> => !!r && typeof r === "object")
.map((r) => [String(r.key ?? ""), r]),
)
: {};
for (const [key, val] of Object.entries(bag)) {
if (!PRIORITY_KEY_SET.has(key) || !val || typeof val !== "object" || Array.isArray(val)) continue;
const row = val as Record<string, unknown>;
const label = typeof row.label === "string" ? row.label.trim() : "";
const color = typeof row.color === "string" ? row.color.trim() : "";
overrides.set(key as ActivityPriority, {
...(label ? { label: label.slice(0, 40) } : {}),
...(HEX_COLOR.test(color) ? { color } : {}),
});
}
return DEFAULT_ACTIVITY_PRIORITY_SETTINGS.map((d) => {
const o = overrides.get(d.key);
return {
key: d.key,
label: o?.label || d.label,
color: o?.color || d.color,
};
});
}
export type UpdateActivityPrioritiesRequest = {
items: Array<{ key: ActivityPriority; label: string; color: string }>;
};
export type ActivityFilter = "today" | "overdue" | "upcoming" | "done" | "all";
export type ActivityStageColor = "green" | "orange" | "purple" | "blue" | "rose" | "slate";