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"]) enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
/// ماژول‌های فعال منو برای این محیط /// ماژول‌های فعال منو برای این محیط
enabledModules String[] @default([]) enabledModules String[] @default([])
/// برچسب/رنگ اولویت فعالیت و روتین (کلیدهای enum ثابت؛ فقط ظاهر)
activityPriorities Json @default("{}")
/// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخاب‌شده)؛ null = بدون فیلتر /// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخاب‌شده)؛ null = بدون فیلتر
priorArchiveEnabledAt DateTime? priorArchiveEnabledAt DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)
@@ -7,6 +7,7 @@ import {
Param, Param,
Patch, Patch,
Post, Post,
Put,
Query, Query,
Res, Res,
StreamableFile, StreamableFile,
@@ -40,6 +41,7 @@ import {
ReorderActivityTypesDto, ReorderActivityTypesDto,
UpdateActivityBoardDto, UpdateActivityBoardDto,
UpdateActivityDto, UpdateActivityDto,
UpdateActivityPrioritiesDto,
UpdateActivityStageDto, UpdateActivityStageDto,
UpdateActivityTypeDto, UpdateActivityTypeDto,
} from './dto'; } from './dto';
@@ -139,6 +141,18 @@ export class ActivitiesController {
return { ok: true }; 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') @Get('types')
listTypes(@CurrentUser() user: Actor) { listTypes(@CurrentUser() user: Actor) {
return this.typesService.list(user.organizationId); return this.typesService.list(user.organizationId);
@@ -6,6 +6,8 @@ import type {
ActivityFieldValue, ActivityFieldValue,
ActivityFieldValues, ActivityFieldValues,
ActivityParticipantsValue, ActivityParticipantsValue,
ActivityPriority,
ActivityPrioritySetting,
ActivityStageColor, ActivityStageColor,
ActivityType, ActivityType,
ActivityTypeDefDto, ActivityTypeDefDto,
@@ -17,8 +19,11 @@ import type {
import { import {
ACTIVITY_FIELD_TYPES, ACTIVITY_FIELD_TYPES,
ACTIVITY_FIELD_TYPES_WITH_OPTIONS, ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
ACTIVITY_PRIORITY_KEYS,
ACTIVITY_STAGE_COLORS, ACTIVITY_STAGE_COLORS,
ACTIVITY_TYPE_ICONS, ACTIVITY_TYPE_ICONS,
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
parseActivityPrioritySettings,
} from '@mento/shared'; } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@@ -619,6 +624,43 @@ export class ActivityTypesService {
: 'green'; : '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) { private mapError(e: unknown) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return new BadRequestException('نوع فعالیتی با این نام وجود دارد'); return new BadRequestException('نوع فعالیتی با این نام وجود دارد');
+25
View File
@@ -9,11 +9,13 @@ import {
IsObject, IsObject,
IsOptional, IsOptional,
IsString, IsString,
Matches,
Max, Max,
MaxLength, MaxLength,
Min, Min,
MinLength, MinLength,
ValidateIf, ValidateIf,
ValidateNested,
} from 'class-validator'; } from 'class-validator';
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared'; import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
import { ACTIVITY_TYPE_ICONS } from '@mento/shared'; import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
@@ -501,3 +503,26 @@ export class UpdateActivityDto {
@IsIn(['creator', 'assignees']) @IsIn(['creator', 'assignees'])
editPolicy?: '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'; } from '@mento/shared';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { activityTypeView } from '@/lib/activity-types'; 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 PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue'; import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
import ActivityQuickView from '@/components/activities/ActivityQuickView.vue'; import ActivityQuickView from '@/components/activities/ActivityQuickView.vue';
@@ -130,18 +130,6 @@ function apiMessage(e: unknown, fallback: string) {
return fallback; 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 { function toCard(item: ActivityDto, stageId: string): PipelineDeal {
const party = item.company?.name || item.person?.fullName || item.deal?.title || ''; const party = item.company?.name || item.person?.fullName || item.deal?.title || '';
const assigneeNames = item.assignees?.length const assigneeNames = item.assignees?.length
@@ -154,7 +142,8 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
title: item.title, title: item.title,
description: [activityTypeView(item).name, party].filter(Boolean).join(' · '), description: [activityTypeView(item).name, party].filter(Boolean).join(' · '),
amount: null, amount: null,
date: dateText(item), date: item.dueAt ? formatRelativeDayLabel(item.dueAt) : 'بدون موعد',
dateIso: item.dueAt ?? undefined,
initials: initialsFromTitle(party || item.title), initials: initialsFromTitle(party || item.title),
stageId, stageId,
createdAt: item.createdAt, createdAt: item.createdAt,
@@ -4,6 +4,7 @@ import axios from 'axios';
import type { import type {
ActivityFieldDef, ActivityFieldDef,
ActivityFieldType, ActivityFieldType,
ActivityPrioritySetting,
ActivityStageColor, ActivityStageColor,
ActivityType, ActivityType,
ActivityTypeDefDto, ActivityTypeDefDto,
@@ -17,9 +18,11 @@ import {
ACTIVITY_STAGE_COLORS, ACTIVITY_STAGE_COLORS,
ACTIVITY_TYPE_ICONS, ACTIVITY_TYPE_ICONS,
ACTIVITY_TYPE_LABELS, ACTIVITY_TYPE_LABELS,
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
} from '@mento/shared'; } from '@mento/shared';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { import {
ACTIVITY_TYPE_ICON_LABELS, ACTIVITY_TYPE_ICON_LABELS,
newFieldKey, newFieldKey,
@@ -32,6 +35,17 @@ import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{ open: boolean }>(); const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: []; changed: [] }>(); 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']; const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
type FieldDraft = ActivityFieldDef & { optionsText: string }; type FieldDraft = ActivityFieldDef & { optionsText: string };
@@ -96,13 +110,74 @@ function select(id: string | 'new') {
notice.value = ''; 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( watch(
() => props.open, () => props.open,
async (open) => { async (open) => {
if (!open) return; if (!open) return;
error.value = ''; error.value = '';
notice.value = ''; notice.value = '';
tab.value = 'types';
await load(true); await load(true);
await prioritiesApi.load(true);
const first = types.value[0]; const first = types.value[0];
selectedId.value = null; selectedId.value = null;
draft.value = null; draft.value = null;
@@ -283,7 +358,12 @@ async function moveType(index: number, dir: -1 | 1) {
} }
function close() { 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'); emit('close');
} }
</script> </script>
@@ -291,6 +371,28 @@ function close() {
<template> <template>
<UiModal :open="open" title="انواع فعالیت و فیلدهای اختصاصی" @close="close"> <UiModal :open="open" title="انواع فعالیت و فیلدهای اختصاصی" @close="close">
<div class="mgr"> <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"> <p class="hint">
برای هر نوع فعالیت فیلدهای مخصوص خودش را تعریف کنید؛ مثلاً «موضوع جلسه» و «شرکت‌کنندگان» برای جلسه برای هر نوع فعالیت فیلدهای مخصوص خودش را تعریف کنید؛ مثلاً «موضوع جلسه» و «شرکت‌کنندگان» برای جلسه
یا «لیست کارها» برای وظیفه. «نوع پایه» مشخص می‌کند فعالیت در گزارش‌ها جزو تماس، جلسه، وظیفه یا پیام یا «لیست کارها» برای وظیفه. «نوع پایه» مشخص می‌کند فعالیت در گزارش‌ها جزو تماس، جلسه، وظیفه یا پیام
@@ -479,6 +581,43 @@ function close() {
</section> </section>
<p v-else class="empty">یک نوع فعالیت را انتخاب کنید.</p> <p v-else class="empty">یک نوع فعالیت را انتخاب کنید.</p>
</div> </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> </div>
</UiModal> </UiModal>
</template> </template>
@@ -489,6 +628,78 @@ function close() {
gap: 0.9rem; 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 { .hint {
margin: 0; margin: 0;
color: #64748b; color: #64748b;
+81 -25
View File
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { import {
CalendarOutlined, CalendarOutlined,
CheckOutlined, CheckOutlined,
DeleteOutlined, DeleteOutlined,
FlagFilled, FlagFilled,
LeftOutlined,
MoreOutlined, MoreOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { import {
@@ -19,11 +20,11 @@ import {
theme, theme,
} from 'ant-design-vue'; } from 'ant-design-vue';
import DealQuickActivityPopover from '@/components/pipeline/DealQuickActivityPopover.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 { userAvatarSrc } from '@/lib/userAvatars';
import type { ActivityPriority } from '@mento/shared';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board'; import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board'; import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board';
import { useActivityPriorities } from '@/lib/activity-priorities';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -80,16 +81,18 @@ const dateHint = computed(() => {
if (Number.isNaN(d.getTime())) return props.deal.date; if (Number.isNaN(d.getTime())) return props.deal.date;
return formatJalaliNumeric(d); return formatJalaliNumeric(d);
}); });
const PRIORITY_FLAG: Record<ActivityPriority, { label: string; color: string }> = { const activityPriorities = useActivityPriorities();
very_high: { label: 'خیلی زیاد', color: '#dc2626' }, onMounted(() => {
high: { label: 'زیاد', color: '#ea580c' }, void activityPriorities.load();
medium: { label: 'متوسط', color: '#eab308' }, });
low: { label: 'کم', color: '#16a34a' }, const priorityFlag = computed(() => {
very_low: { label: 'خیلی کم', color: '#94a3b8' }, const key = props.deal.priority;
}; if (!key) return null;
const priorityFlag = computed(() => return {
props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null, label: activityPriorities.labelOf(key),
); color: activityPriorities.colorOf(key),
};
});
function onTitleClick() { function onTitleClick() {
if (props.selectMode) { if (props.selectMode) {
@@ -100,13 +103,49 @@ function onTitleClick() {
else emit('open', props.deal.id); else emit('open', props.deal.id);
} }
const chevronStyle = computed(() => ({ /** موعد: عقب‌افتاده قرمز · امروز سبز · آینده خاکستری */
display: 'inline-block', const dueTone = computed<'overdue' | 'today' | 'upcoming' | 'none'>(() => {
width: '0.42rem', if (!props.copy.quickView || !props.deal.dateIso) return 'none';
height: '0.42rem', const due = new Date(props.deal.dateIso);
borderRight: '1.6px solid currentColor', if (Number.isNaN(due.getTime())) return 'none';
borderBottom: '1.6px solid currentColor', const days = calendarDaysBetween(new Date(), due);
transform: props.previewing ? 'rotate(-45deg)' : 'rotate(135deg)', 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', transition: 'transform 0.15s ease',
})); }));
@@ -262,16 +301,17 @@ const cardStyle = computed(() => ({
{{ money.unit }} {{ money.unit }}
</a-typography-text> </a-typography-text>
</a-typography-text> </a-typography-text>
<a-button <button
v-if="copy.quickView && !selectMode" v-if="copy.quickView && !selectMode"
type="text" type="button"
class="deal-card-peek"
:aria-label="previewing ? 'بستن پیش‌نمایش' : 'پیش‌نمایش فعالیت'" :aria-label="previewing ? 'بستن پیش‌نمایش' : 'پیش‌نمایش فعالیت'"
:aria-expanded="previewing" :aria-expanded="previewing"
:style="{ color: token.colorTextSecondary }" :style="chevronBtnStyle"
@click.stop="emit('open', deal.id)" @click.stop="emit('open', deal.id)"
> >
<template #icon><i class="chev" aria-hidden="true" :style="chevronStyle" /></template> <LeftOutlined :style="chevronIconStyle" />
</a-button> </button>
<a-tooltip v-else-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`"> <a-tooltip v-else-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }"> <span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" /> <FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
@@ -292,3 +332,19 @@ const cardStyle = computed(() => ({
</a-flex> </a-flex>
</a-card> </a-card>
</template> </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'; } from 'ant-design-vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { import {
ENTITY_TYPE_LABELS, ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
ROUTINES_CTX, ROUTINES_CTX,
apiError, apiError,
localInputToIso, localInputToIso,
@@ -68,10 +68,10 @@ const form = reactive({
const saving = ref(false); const saving = ref(false);
const error = ref(''); const error = ref('');
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const priorityOptions = computed(() => const priorityOptions = computed(() =>
(Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>).map( activityPriorities.items.value.map((row) => ({ value: row.key, label: row.label })),
([value, label]) => ({ value, label }),
),
); );
const categoryOptions = computed(() => [ const categoryOptions = computed(() => [
@@ -25,9 +25,9 @@ import { api } from '@/lib/api';
import { canAssignToOthers } from '@/lib/assign-access'; import { canAssignToOthers } from '@/lib/assign-access';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { formatJalaliDayClock } from '@/lib/jalali'; import { formatJalaliDayClock } from '@/lib/jalali';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { import {
ENTITY_TYPE_LABELS, ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS, PRIORITY_VARIANTS,
ROUTINES_CTX, ROUTINES_CTX,
ROUTINE_ENTITY_LINK_LABELS, ROUTINE_ENTITY_LINK_LABELS,
@@ -108,7 +108,11 @@ const done = computed(() => task.value?.status === 'completed');
const auth = useAuthStore(); const auth = useAuthStore();
const canManage = computed(() => !!shell?.context.value?.isManager); const canManage = computed(() => !!shell?.context.value?.isManager);
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'routines')); 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() { function startEdit() {
const t = task.value; const t = task.value;
@@ -193,7 +197,7 @@ function eventText(ev: RoutineTaskEventDto): string {
<div class="badges"> <div class="badges">
<UiBadge :variant="STATUS_VARIANTS[task.status]">{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}</UiBadge> <UiBadge :variant="STATUS_VARIANTS[task.status]">{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}</UiBadge>
<UiBadge :variant="PRIORITY_VARIANTS[task.priority]" size="sm"> <UiBadge :variant="PRIORITY_VARIANTS[task.priority]" size="sm">
اولویت {{ PRIORITY_LABELS[task.priority] }} اولویت {{ activityPriorities.labelOf(task.priority) }}
</UiBadge> </UiBadge>
<UiBadge v-if="task.isMandatory" variant="warning" size="sm">اجباری</UiBadge> <UiBadge v-if="task.isMandatory" variant="warning" size="sm">اجباری</UiBadge>
</div> </div>
@@ -23,9 +23,9 @@ import {
const AMenuItem = AMenu.Item; const AMenuItem = AMenu.Item;
import UserAvatar from '@/components/UserAvatar.vue'; import UserAvatar from '@/components/UserAvatar.vue';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { import {
ENTITY_TYPE_LABELS, ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
ROUTINE_TASK_SOURCE_LABELS, ROUTINE_TASK_SOURCE_LABELS,
ROUTINE_TASK_STATUS_LABELS, ROUTINE_TASK_STATUS_LABELS,
entityRoute, entityRoute,
@@ -64,6 +64,9 @@ const emit = defineEmits<{
action: [kind: RoutineTaskRowAction, task: RoutineTaskDto]; action: [kind: RoutineTaskRowAction, task: RoutineTaskDto];
}>(); }>();
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const done = computed(() => props.task.status === 'completed'); const done = computed(() => props.task.status === 'completed');
const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled'); const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled');
const open = computed(() => isOpenStatus(props.task.status)); const open = computed(() => isOpenStatus(props.task.status));
@@ -150,7 +153,7 @@ function onMenuClick({ key }: { key: string | number }) {
<CheckOutlined /> {{ formatTime(task.completedAt) }} <CheckOutlined /> {{ formatTime(task.completedAt) }}
</a-typography-text> </a-typography-text>
<a-tag v-if="task.category" :bordered="false">{{ task.category.name }}</a-tag> <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"> <a-tag v-if="task.status !== 'todo'" :bordered="false">
{{ ROUTINE_TASK_STATUS_LABELS[task.status] }} {{ ROUTINE_TASK_STATUS_LABELS[task.status] }}
</a-tag> </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'; type BadgeVariant = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info';
/** پیش‌فرض؛ برای نمایش زنده از useActivityPriorities استفاده کنید */
export const PRIORITY_LABELS: Record<ActivityPriority, string> = { export const PRIORITY_LABELS: Record<ActivityPriority, string> = {
very_low: 'خیلی کم', very_low: 'خیلی کم',
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 ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue'; import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board'; import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types'; import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import { import {
ACTIVITY_EXCEL_ALIASES, ACTIVITY_EXCEL_ALIASES,
@@ -180,6 +181,7 @@ const boardDeleteOpen = ref(false);
async function onTypesChanged() { async function onTypesChanged() {
await activityTypes.load(true); await activityTypes.load(true);
await useActivityPriorities().load(true);
kanbanReload.value += 1; kanbanReload.value += 1;
await refreshAll(); await refreshAll();
} }
@@ -1217,11 +1219,11 @@ onUnmounted(() => {
variant="ghost" variant="ghost"
size="sm" size="sm"
type="button" type="button"
title="انواع فعالیت و فیلدها" title="انواع فعالیت و اولویت ها"
@click="typesManagerOpen = true" @click="typesManagerOpen = true"
> >
<UiIcon name="sliders" :size="14" /> <UiIcon name="sliders" :size="14" />
<span class="manage-label">انواع فعالیت و فیلدها</span> <span class="manage-label">انواع فعالیت و اولویت ها</span>
</UiButton> </UiButton>
<UiButton <UiButton
v-if="canManageStages" v-if="canManageStages"
@@ -1380,7 +1382,7 @@ onUnmounted(() => {
<hr class="board-menu-sep" /> <hr class="board-menu-sep" />
<button type="button" class="menu-row" @click="typesManagerOpen = true"> <button type="button" class="menu-row" @click="typesManagerOpen = true">
<UiIcon name="sliders" :size="16" /> <UiIcon name="sliders" :size="16" />
انواع فعالیت و فیلدها انواع فعالیت و اولویت ها
</button> </button>
<button type="button" class="menu-row" @click="stagesManagerOpen = true"> <button type="button" class="menu-row" @click="stagesManagerOpen = true">
<UiIcon name="settings" :size="16" /> <UiIcon name="settings" :size="16" />
+66 -25
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <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 { RouterLink, onBeforeRouteLeave, useRoute, useRouter } from 'vue-router';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { import {
@@ -18,7 +18,7 @@ import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.v
import ActivityPostponeSheet from '@/components/activities/ActivityPostponeSheet.vue'; import ActivityPostponeSheet from '@/components/activities/ActivityPostponeSheet.vue';
import ActivityFieldsView from '@/components/activities/ActivityFieldsView.vue'; import ActivityFieldsView from '@/components/activities/ActivityFieldsView.vue';
import StagePathStrip from '@/components/StagePathStrip.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 { import type {
ActivityChecklistItem, ActivityChecklistItem,
ActivityDto, ActivityDto,
@@ -32,6 +32,7 @@ import { useAuthStore } from '@/stores/auth';
import { canCollaborateOnActivity, canMutateActivity } from '@/lib/activity-access'; import { canCollaborateOnActivity, canMutateActivity } from '@/lib/activity-access';
import { canAssignToOthers } from '@/lib/assign-access'; import { canAssignToOthers } from '@/lib/assign-access';
import { apiError } from '@/lib/routines'; import { apiError } from '@/lib/routines';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types'; import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import { import {
bindThreadMedia, bindThreadMedia,
@@ -631,11 +632,59 @@ async function persistDrafts(): Promise<boolean> {
} }
} }
/** true = مجاز به ترک صفحه · false = بمان روی جزئیات */
async function resolveDirty(): Promise<boolean> { async function resolveDirty(): Promise<boolean> {
if (!isDirty.value) return true; if (!isDirty.value) return true;
if (confirm('تغییرات ثبت شود؟')) return persistDrafts(); return new Promise((resolve) => {
discardDrafts(); const modal = Modal.confirm({
return true; 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) { function onBeforeUnload(event: BeforeUnloadEvent) {
@@ -671,13 +720,9 @@ async function toggleChecklistItem(key: string, itemId: string, done: boolean) {
} }
} }
const priorityLabel: Record<ActivityPriority, string> = { const activityPriorities = useActivityPriorities();
very_low: 'خیلی کم', void activityPriorities.load();
low: 'کم', const priorityLabel = computed(() => activityPriorities.labels.value);
medium: 'متوسط',
high: 'زیاد',
very_high: 'خیلی زیاد',
};
const subtitle = computed(() => { const subtitle = computed(() => {
const a = activity.value; const a = activity.value;
@@ -1039,17 +1084,8 @@ watch(id, () => {
void load(); void load();
}); });
watch(() => route.query.nav, () => void loadNav(), { immediate: true }); watch(() => route.query.nav, () => void loadNav(), { immediate: true });
onBeforeRouteLeave(async (_to, _from, next) => { /** بدون next() — در Vue Router 4 گارد async+next باعث رد شدن بدون پرسش می‌شد */
if (!isDirty.value) { onBeforeRouteLeave(async () => resolveDirty());
next();
return;
}
if (confirm('تغییرات ثبت شود؟')) next(await persistDrafts());
else {
discardDrafts();
next();
}
});
onMounted(() => { onMounted(() => {
window.addEventListener('beforeunload', onBeforeUnload); window.addEventListener('beforeunload', onBeforeUnload);
void load(); void load();
@@ -1150,12 +1186,17 @@ onUnmounted(() => {
</div> </div>
<div> <div>
<dt> <dt>
<UiIcon name="flag" :size="15" class="flag" :class="draftPriority" /> <UiIcon
name="flag"
:size="15"
class="flag"
:style="{ color: activityPriorities.colorOf(draftPriority) }"
/>
اولویت اولویت
</dt> </dt>
<dd> <dd>
<select v-model="draftPriority" class="meta-control" :disabled="editLocked" aria-label="اولویت"> <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> </select>
</dd> </dd>
</div> </div>
+18 -29
View File
@@ -32,6 +32,7 @@ import {
UiTextarea, UiTextarea,
} from '@/components/ui'; } from '@/components/ui';
import ActivityFieldsEditor from '@/components/activities/ActivityFieldsEditor.vue'; import ActivityFieldsEditor from '@/components/activities/ActivityFieldsEditor.vue';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { typeColorStyle, useActivityTypes } from '@/lib/activity-types'; import { typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import type { import type {
ActivityBoardDto, ActivityBoardDto,
@@ -89,6 +90,8 @@ const boardId = ref('');
const defaultBoardId = computed(() => boards.value.find((b) => b.isDefault)?.id ?? ''); const defaultBoardId = computed(() => boards.value.find((b) => b.isDefault)?.id ?? '');
const activityTypes = useActivityTypes(); const activityTypes = useActivityTypes();
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const title = ref(''); const title = ref('');
const type = ref<ActivityType | ''>(''); const type = ref<ActivityType | ''>('');
const typeDefId = ref(''); const typeDefId = ref('');
@@ -271,13 +274,13 @@ function isEmptyValue(v: ActivityFieldValue | undefined): boolean {
return false; return false;
} }
const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: string }> = [ const priorityOptions = computed(() =>
{ value: 'very_high', label: 'خیلی زیاد', tone: 'very-high' }, activityPriorities.items.value.map((row) => ({
{ value: 'high', label: 'زیاد', tone: 'high' }, value: row.key,
{ value: 'medium', label: 'متوسط', tone: 'medium' }, label: row.label,
{ value: 'low', label: 'کم', tone: 'low' }, color: row.color,
{ value: 'very_low', label: 'خیلی کم', tone: 'very-low' }, })),
]; );
const typeMeta = computed(() => activityTypes.byId.value.get(typeDefId.value) ?? null); const typeMeta = computed(() => activityTypes.byId.value.get(typeDefId.value) ?? null);
const typeFields = computed(() => typeMeta.value?.fields ?? []); const typeFields = computed(() => typeMeta.value?.fields ?? []);
@@ -402,7 +405,7 @@ const duePreview = computed(() => {
const statusText = computed(() => (isDone.value ? 'انجام شده' : 'انجام نشده')); const statusText = computed(() => (isDone.value ? 'انجام شده' : 'انجام نشده'));
const priorityText = computed( 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 pageTitle = computed(() => (isNew.value ? 'ایجاد فعالیت جدید' : 'ویرایش فعالیت'));
const summaryReady = computed(() => Boolean(title.value.trim() || type.value || selectedPerson.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"> <div class="card">
<p class="pri-label">اولویت</p> <p class="pri-label">اولویت</p>
<div class="priorities"> <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" /> <input v-model="priority" type="radio" :value="opt.value" />
<UiIcon name="flag" :size="16" /> <UiIcon name="flag" :size="16" />
<span>{{ opt.label }}</span> <span>{{ opt.label }}</span>
@@ -2365,26 +2374,6 @@ watch([id, isNew], () => void load(), { immediate: true });
flex-shrink: 0; 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 { .pri.on span {
font-weight: 600; font-weight: 600;
} }
@@ -17,7 +17,8 @@ import {
} from '@/components/ui'; } from '@/components/ui';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali'; 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'; import { useAuthStore } from '@/stores/auth';
const shell = inject(ROUTINES_CTX)!; const shell = inject(ROUTINES_CTX)!;
@@ -73,7 +74,11 @@ const targetOptions = [
{ value: 'team' as const, label: 'همه اعضای یک تیم' }, { 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 entityLinks = Object.entries(ROUTINE_ENTITY_LINK_LABELS) as Array<[RoutineEntityLink, string]>;
const reminderOptions = [ const reminderOptions = [
{ value: '', label: 'طبق تنظیمات هر کاربر' }, { value: '', label: 'طبق تنظیمات هر کاربر' },
@@ -7,8 +7,8 @@ import { UiBadge, UiEmptyState, UiSearch } from '@/components/ui';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { import {
PRIORITY_LABELS,
PRIORITY_VARIANTS, PRIORITY_VARIANTS,
ROUTINES_CTX, ROUTINES_CTX,
apiError, apiError,
@@ -18,6 +18,8 @@ import {
} from '@/lib/routines'; } from '@/lib/routines';
const shell = inject(ROUTINES_CTX)!; const shell = inject(ROUTINES_CTX)!;
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const auth = useAuthStore(); const auth = useAuthStore();
const meId = computed(() => auth.user?.id ?? ''); const meId = computed(() => auth.user?.id ?? '');
@@ -80,7 +82,7 @@ const filtered = computed(() => {
<p v-if="r.description" class="desc">{{ r.description }}</p> <p v-if="r.description" class="desc">{{ r.description }}</p>
<div class="tags"> <div class="tags">
<UiBadge v-if="r.category" size="sm">{{ r.category.name }}</UiBadge> <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.isMandatory" size="sm" variant="warning">اجباری</UiBadge>
<UiBadge v-if="r.reportRequired" size="sm" variant="info">گزارش الزامی</UiBadge> <UiBadge v-if="r.reportRequired" size="sm" variant="info">گزارش الزامی</UiBadge>
<UiBadge v-if="r.team" size="sm" variant="accent">تیم {{ r.team.name }}</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 { UiBadge, UiButton, UiEmptyState, UiSelect } from '@/components/ui';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { useActivityPriorities } from '@/lib/activity-priorities';
import { import {
ENTITY_TYPE_LABELS, ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS, PRIORITY_VARIANTS,
ROUTINES_CTX, ROUTINES_CTX,
apiError, apiError,
@@ -24,6 +24,8 @@ import {
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions'; import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
const shell = inject(ROUTINES_CTX)!; const shell = inject(ROUTINES_CTX)!;
const activityPriorities = useActivityPriorities();
void activityPriorities.load();
const items = ref<RoutineTaskDto[]>([]); const items = ref<RoutineTaskDto[]>([]);
const loading = ref(true); const loading = ref(true);
@@ -133,7 +135,7 @@ const showAssignee = computed(() => isManager.value && who.value !== '');
</span> </span>
</td> </td>
<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>
<td> <td>
<RouterLink <RouterLink
+65
View File
@@ -1874,6 +1874,71 @@ export interface DealActivitySummaryDto {
cue: Exclude<DealActivityCue, "none">; cue: Exclude<DealActivityCue, "none">;
} }
export type ActivityPriority = "very_low" | "low" | "medium" | "high" | "very_high"; 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 ActivityFilter = "today" | "overdue" | "upcoming" | "done" | "all";
export type ActivityStageColor = "green" | "orange" | "purple" | "blue" | "rose" | "slate"; export type ActivityStageColor = "green" | "orange" | "purple" | "blue" | "rose" | "slate";