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:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Organization" ADD COLUMN "activityPriorities" JSONB NOT NULL DEFAULT '{}';
|
||||||
@@ -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('نوع فعالیتی با این نام وجود دارد');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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: 'کم',
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user