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"])
|
||||
/// ماژولهای فعال منو برای این محیط
|
||||
enabledModules String[] @default([])
|
||||
/// برچسب/رنگ اولویت فعالیت و روتین (کلیدهای enum ثابت؛ فقط ظاهر)
|
||||
activityPriorities Json @default("{}")
|
||||
/// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخابشده)؛ null = بدون فیلتر
|
||||
priorArchiveEnabledAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
ReorderActivityTypesDto,
|
||||
UpdateActivityBoardDto,
|
||||
UpdateActivityDto,
|
||||
UpdateActivityPrioritiesDto,
|
||||
UpdateActivityStageDto,
|
||||
UpdateActivityTypeDto,
|
||||
} from './dto';
|
||||
@@ -139,6 +141,18 @@ export class ActivitiesController {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('priorities')
|
||||
listPriorities(@CurrentUser() user: Actor) {
|
||||
return this.typesService.listPriorities(user.organizationId);
|
||||
}
|
||||
|
||||
@Put('priorities')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
updatePriorities(@CurrentUser() user: Actor, @Body() dto: UpdateActivityPrioritiesDto) {
|
||||
return this.typesService.updatePriorities(user.organizationId, dto.items);
|
||||
}
|
||||
|
||||
@Get('types')
|
||||
listTypes(@CurrentUser() user: Actor) {
|
||||
return this.typesService.list(user.organizationId);
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
ActivityFieldValue,
|
||||
ActivityFieldValues,
|
||||
ActivityParticipantsValue,
|
||||
ActivityPriority,
|
||||
ActivityPrioritySetting,
|
||||
ActivityStageColor,
|
||||
ActivityType,
|
||||
ActivityTypeDefDto,
|
||||
@@ -17,8 +19,11 @@ import type {
|
||||
import {
|
||||
ACTIVITY_FIELD_TYPES,
|
||||
ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
|
||||
ACTIVITY_PRIORITY_KEYS,
|
||||
ACTIVITY_STAGE_COLORS,
|
||||
ACTIVITY_TYPE_ICONS,
|
||||
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
|
||||
parseActivityPrioritySettings,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
@@ -619,6 +624,43 @@ export class ActivityTypesService {
|
||||
: 'green';
|
||||
}
|
||||
|
||||
async listPriorities(organizationId: string): Promise<ActivityPrioritySetting[]> {
|
||||
const row = await this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { activityPriorities: true },
|
||||
});
|
||||
return parseActivityPrioritySettings(row?.activityPriorities);
|
||||
}
|
||||
|
||||
async updatePriorities(
|
||||
organizationId: string,
|
||||
items: Array<{ key: ActivityPriority; label: string; color: string }>,
|
||||
): Promise<ActivityPrioritySetting[]> {
|
||||
const byKey = new Map(items.map((i) => [i.key, i]));
|
||||
if (ACTIVITY_PRIORITY_KEYS.some((k) => !byKey.has(k))) {
|
||||
throw new BadRequestException('هر پنج سطح اولویت باید ارسال شوند');
|
||||
}
|
||||
const hex = /^#[0-9a-fA-F]{6}$/;
|
||||
const stored: Record<string, { label: string; color: string }> = {};
|
||||
for (const key of ACTIVITY_PRIORITY_KEYS) {
|
||||
const item = byKey.get(key)!;
|
||||
const label = item.label.trim().slice(0, 40);
|
||||
const color = item.color.trim();
|
||||
if (!label) throw new BadRequestException(`عنوان اولویت «${key}» خالی است`);
|
||||
if (!hex.test(color)) throw new BadRequestException(`رنگ اولویت «${key}» نامعتبر است`);
|
||||
const def = DEFAULT_ACTIVITY_PRIORITY_SETTINGS.find((d) => d.key === key)!;
|
||||
// فقط تفاوت با پیشفرض را نگه میداریم تا JSON جمعوجور بماند
|
||||
if (label !== def.label || color.toLowerCase() !== def.color.toLowerCase()) {
|
||||
stored[key] = { label, color };
|
||||
}
|
||||
}
|
||||
await this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: { activityPriorities: stored as Prisma.InputJsonValue },
|
||||
});
|
||||
return parseActivityPrioritySettings(stored);
|
||||
}
|
||||
|
||||
private mapError(e: unknown) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
return new BadRequestException('نوع فعالیتی با این نام وجود دارد');
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
|
||||
import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
|
||||
@@ -501,3 +503,26 @@ export class UpdateActivityDto {
|
||||
@IsIn(['creator', 'assignees'])
|
||||
editPolicy?: 'creator' | 'assignees';
|
||||
}
|
||||
|
||||
const PRIORITY_KEYS = ['very_low', 'low', 'medium', 'high', 'very_high'] as const;
|
||||
|
||||
export class ActivityPriorityItemDto {
|
||||
@IsIn(PRIORITY_KEYS)
|
||||
key!: (typeof PRIORITY_KEYS)[number];
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(40)
|
||||
label!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^#[0-9a-fA-F]{6}$/)
|
||||
color!: string;
|
||||
}
|
||||
|
||||
export class UpdateActivityPrioritiesDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ActivityPriorityItemDto)
|
||||
items!: ActivityPriorityItemDto[];
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { activityTypeView } from '@/lib/activity-types';
|
||||
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
|
||||
import { formatRelativeDayLabel, toFaDigits } from '@/lib/jalali';
|
||||
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
|
||||
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
|
||||
import ActivityQuickView from '@/components/activities/ActivityQuickView.vue';
|
||||
@@ -130,18 +130,6 @@ function apiMessage(e: unknown, fallback: string) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function dateText(item: ActivityDto) {
|
||||
if (!item.dueAt) return 'بدون موعد';
|
||||
const due = new Date(item.dueAt);
|
||||
const days = calendarDaysBetween(due, new Date());
|
||||
const day =
|
||||
days === 0 ? 'امروز' : days === 1 ? 'دیروز' : days === -1 ? 'فردا' : formatJalaliDay(due);
|
||||
const late =
|
||||
item.status === 'open' &&
|
||||
startOfLocalDay(due).getTime() < startOfLocalDay().getTime();
|
||||
return `${late ? 'عقبافتاده · ' : ''}${day} · ${formatClockFa(due)}`;
|
||||
}
|
||||
|
||||
function toCard(item: ActivityDto, stageId: string): PipelineDeal {
|
||||
const party = item.company?.name || item.person?.fullName || item.deal?.title || '';
|
||||
const assigneeNames = item.assignees?.length
|
||||
@@ -154,7 +142,8 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
|
||||
title: item.title,
|
||||
description: [activityTypeView(item).name, party].filter(Boolean).join(' · '),
|
||||
amount: null,
|
||||
date: dateText(item),
|
||||
date: item.dueAt ? formatRelativeDayLabel(item.dueAt) : 'بدون موعد',
|
||||
dateIso: item.dueAt ?? undefined,
|
||||
initials: initialsFromTitle(party || item.title),
|
||||
stageId,
|
||||
createdAt: item.createdAt,
|
||||
|
||||
@@ -4,6 +4,7 @@ import axios from 'axios';
|
||||
import type {
|
||||
ActivityFieldDef,
|
||||
ActivityFieldType,
|
||||
ActivityPrioritySetting,
|
||||
ActivityStageColor,
|
||||
ActivityType,
|
||||
ActivityTypeDefDto,
|
||||
@@ -17,9 +18,11 @@ import {
|
||||
ACTIVITY_STAGE_COLORS,
|
||||
ACTIVITY_TYPE_ICONS,
|
||||
ACTIVITY_TYPE_LABELS,
|
||||
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
import {
|
||||
ACTIVITY_TYPE_ICON_LABELS,
|
||||
newFieldKey,
|
||||
@@ -32,6 +35,17 @@ import UiIcon from '@/components/UiIcon.vue';
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ close: []; changed: [] }>();
|
||||
|
||||
type MgrTab = 'types' | 'priorities';
|
||||
const tab = ref<MgrTab>('types');
|
||||
const prioritiesApi = useActivityPriorities();
|
||||
const priorityDraft = ref<ActivityPrioritySetting[]>(
|
||||
DEFAULT_ACTIVITY_PRIORITY_SETTINGS.map((r) => ({ ...r })),
|
||||
);
|
||||
const prioritySaved = ref('');
|
||||
const priorityDirty = computed(
|
||||
() => JSON.stringify(priorityDraft.value) !== prioritySaved.value,
|
||||
);
|
||||
|
||||
const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
|
||||
|
||||
type FieldDraft = ActivityFieldDef & { optionsText: string };
|
||||
@@ -96,13 +110,74 @@ function select(id: string | 'new') {
|
||||
notice.value = '';
|
||||
}
|
||||
|
||||
async function loadPrioritiesTab() {
|
||||
await prioritiesApi.load(true);
|
||||
priorityDraft.value = prioritiesApi.items.value.map((r) => ({ ...r }));
|
||||
prioritySaved.value = JSON.stringify(priorityDraft.value);
|
||||
}
|
||||
|
||||
function switchTab(next: MgrTab) {
|
||||
if (next === tab.value) return;
|
||||
if (tab.value === 'types' && dirty.value && draft.value) {
|
||||
if (!window.confirm('تغییرات ذخیره نشده از بین میرود. ادامه میدهید؟')) return;
|
||||
}
|
||||
if (tab.value === 'priorities' && priorityDirty.value) {
|
||||
if (!window.confirm('تغییرات اولویتها ذخیره نشده از بین میرود. ادامه میدهید؟')) return;
|
||||
}
|
||||
tab.value = next;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
if (next === 'priorities') void loadPrioritiesTab();
|
||||
}
|
||||
|
||||
async function savePriorities() {
|
||||
for (const row of priorityDraft.value) {
|
||||
if (!row.label.trim()) {
|
||||
error.value = 'عنوان همهٔ سطوح اولویت را بنویسید.';
|
||||
return;
|
||||
}
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(row.color)) {
|
||||
error.value = 'رنگ باید به صورت #RRGGBB باشد.';
|
||||
return;
|
||||
}
|
||||
}
|
||||
busy.value = 'prio';
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
try {
|
||||
const { data } = await api.put<ActivityPrioritySetting[]>('/activities/priorities', {
|
||||
items: priorityDraft.value.map((r) => ({
|
||||
key: r.key,
|
||||
label: r.label.trim(),
|
||||
color: r.color,
|
||||
})),
|
||||
});
|
||||
prioritiesApi.setLocal(data);
|
||||
priorityDraft.value = data.map((r) => ({ ...r }));
|
||||
prioritySaved.value = JSON.stringify(priorityDraft.value);
|
||||
notice.value = 'اولویتها ذخیره شد.';
|
||||
emit('changed');
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'ذخیرهٔ اولویتها ناموفق بود.');
|
||||
} finally {
|
||||
busy.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function resetPriorities() {
|
||||
if (!window.confirm('عنوان و رنگها به پیشفرض برگردند؟')) return;
|
||||
priorityDraft.value = DEFAULT_ACTIVITY_PRIORITY_SETTINGS.map((r) => ({ ...r }));
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (open) => {
|
||||
if (!open) return;
|
||||
error.value = '';
|
||||
notice.value = '';
|
||||
tab.value = 'types';
|
||||
await load(true);
|
||||
await prioritiesApi.load(true);
|
||||
const first = types.value[0];
|
||||
selectedId.value = null;
|
||||
draft.value = null;
|
||||
@@ -283,7 +358,12 @@ async function moveType(index: number, dir: -1 | 1) {
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (dirty.value && draft.value && !window.confirm('تغییرات ذخیره نشده از بین میرود. بستن؟')) return;
|
||||
if (tab.value === 'types' && dirty.value && draft.value) {
|
||||
if (!window.confirm('تغییرات ذخیره نشده از بین میرود. بستن؟')) return;
|
||||
}
|
||||
if (tab.value === 'priorities' && priorityDirty.value) {
|
||||
if (!window.confirm('تغییرات اولویتها ذخیره نشده از بین میرود. بستن؟')) return;
|
||||
}
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
@@ -291,6 +371,28 @@ function close() {
|
||||
<template>
|
||||
<UiModal :open="open" title="انواع فعالیت و فیلدهای اختصاصی" @close="close">
|
||||
<div class="mgr">
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:class="{ on: tab === 'types' }"
|
||||
:aria-selected="tab === 'types'"
|
||||
@click="switchTab('types')"
|
||||
>
|
||||
انواع و فیلدها
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:class="{ on: tab === 'priorities' }"
|
||||
:aria-selected="tab === 'priorities'"
|
||||
@click="switchTab('priorities')"
|
||||
>
|
||||
اولویتها
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="tab === 'types'">
|
||||
<p class="hint">
|
||||
برای هر نوع فعالیت فیلدهای مخصوص خودش را تعریف کنید؛ مثلاً «موضوع جلسه» و «شرکتکنندگان» برای جلسه
|
||||
یا «لیست کارها» برای وظیفه. «نوع پایه» مشخص میکند فعالیت در گزارشها جزو تماس، جلسه، وظیفه یا پیام
|
||||
@@ -479,6 +581,43 @@ function close() {
|
||||
</section>
|
||||
<p v-else class="empty">یک نوع فعالیت را انتخاب کنید.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="prio">
|
||||
<p class="hint">
|
||||
پنج سطح اولویت ثابت است؛ فقط عنوان و رنگ را عوض کنید. تغییر عنوان روی همهٔ فعالیتها و کارهای
|
||||
روتین قبلی هم اعمال میشود (کلید داخلی عوض نمیشود).
|
||||
</p>
|
||||
<ul class="prio-list">
|
||||
<li v-for="row in priorityDraft" :key="row.key" class="prio-row">
|
||||
<span class="swatch" :style="{ background: row.color }" aria-hidden="true" />
|
||||
<label class="f grow">
|
||||
<span>عنوان</span>
|
||||
<input v-model="row.label" maxlength="40" />
|
||||
</label>
|
||||
<label class="f color">
|
||||
<span>رنگ</span>
|
||||
<input v-model="row.color" type="color" :aria-label="`رنگ ${row.label}`" />
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="notice" class="notice">{{ notice }}</p>
|
||||
<div class="actions">
|
||||
<UiButton
|
||||
variant="primary"
|
||||
type="button"
|
||||
:disabled="!priorityDirty || Boolean(busy)"
|
||||
:loading="busy === 'prio'"
|
||||
@click="savePriorities"
|
||||
>
|
||||
ذخیرهٔ اولویتها
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" :disabled="Boolean(busy)" @click="resetPriorities">
|
||||
بازگشت به پیشفرض
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiModal>
|
||||
</template>
|
||||
@@ -489,6 +628,78 @@ function close() {
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.85rem;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
cursor: pointer;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.tabs button.on {
|
||||
border-color: #5eead4;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prio {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.prio-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.prio-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.65rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 0.55rem;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.prio-row .grow {
|
||||
flex: 1;
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.prio-row .color input[type='color'] {
|
||||
width: 3rem;
|
||||
height: 2.1rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
FlagFilled,
|
||||
LeftOutlined,
|
||||
MoreOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
@@ -19,11 +20,11 @@ import {
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import DealQuickActivityPopover from '@/components/pipeline/DealQuickActivityPopover.vue';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
import { calendarDaysBetween, formatJalaliNumeric } from '@/lib/jalali';
|
||||
import { userAvatarSrc } from '@/lib/userAvatars';
|
||||
import type { ActivityPriority } from '@mento/shared';
|
||||
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
|
||||
import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -80,16 +81,18 @@ const dateHint = computed(() => {
|
||||
if (Number.isNaN(d.getTime())) return props.deal.date;
|
||||
return formatJalaliNumeric(d);
|
||||
});
|
||||
const PRIORITY_FLAG: Record<ActivityPriority, { label: string; color: string }> = {
|
||||
very_high: { label: 'خیلی زیاد', color: '#dc2626' },
|
||||
high: { label: 'زیاد', color: '#ea580c' },
|
||||
medium: { label: 'متوسط', color: '#eab308' },
|
||||
low: { label: 'کم', color: '#16a34a' },
|
||||
very_low: { label: 'خیلی کم', color: '#94a3b8' },
|
||||
};
|
||||
const priorityFlag = computed(() =>
|
||||
props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null,
|
||||
);
|
||||
const activityPriorities = useActivityPriorities();
|
||||
onMounted(() => {
|
||||
void activityPriorities.load();
|
||||
});
|
||||
const priorityFlag = computed(() => {
|
||||
const key = props.deal.priority;
|
||||
if (!key) return null;
|
||||
return {
|
||||
label: activityPriorities.labelOf(key),
|
||||
color: activityPriorities.colorOf(key),
|
||||
};
|
||||
});
|
||||
|
||||
function onTitleClick() {
|
||||
if (props.selectMode) {
|
||||
@@ -100,13 +103,49 @@ function onTitleClick() {
|
||||
else emit('open', props.deal.id);
|
||||
}
|
||||
|
||||
const chevronStyle = computed(() => ({
|
||||
display: 'inline-block',
|
||||
width: '0.42rem',
|
||||
height: '0.42rem',
|
||||
borderRight: '1.6px solid currentColor',
|
||||
borderBottom: '1.6px solid currentColor',
|
||||
transform: props.previewing ? 'rotate(-45deg)' : 'rotate(135deg)',
|
||||
/** موعد: عقبافتاده قرمز · امروز سبز · آینده خاکستری */
|
||||
const dueTone = computed<'overdue' | 'today' | 'upcoming' | 'none'>(() => {
|
||||
if (!props.copy.quickView || !props.deal.dateIso) return 'none';
|
||||
const due = new Date(props.deal.dateIso);
|
||||
if (Number.isNaN(due.getTime())) return 'none';
|
||||
const days = calendarDaysBetween(new Date(), due);
|
||||
if (days < 0) return 'overdue';
|
||||
if (days === 0) return 'today';
|
||||
return 'upcoming';
|
||||
});
|
||||
|
||||
const dueToneColor = computed(() => {
|
||||
if (dueTone.value === 'overdue') return token.value.colorError;
|
||||
if (dueTone.value === 'today') return token.value.colorSuccess;
|
||||
return token.value.colorTextSecondary;
|
||||
});
|
||||
|
||||
const chevronBtnStyle = computed(() => ({
|
||||
boxSizing: 'border-box' as const,
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
border: `1.5px solid ${dueToneColor.value}`,
|
||||
borderRadius: '50%',
|
||||
background: 'transparent',
|
||||
color: dueToneColor.value,
|
||||
display: 'inline-grid',
|
||||
placeItems: 'center',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 0,
|
||||
flexShrink: 0,
|
||||
}));
|
||||
|
||||
const chevronIconStyle = computed(() => ({
|
||||
fontSize: '10px',
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
lineHeight: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transform: props.previewing ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.15s ease',
|
||||
}));
|
||||
|
||||
@@ -262,16 +301,17 @@ const cardStyle = computed(() => ({
|
||||
{{ money.unit }}
|
||||
</a-typography-text>
|
||||
</a-typography-text>
|
||||
<a-button
|
||||
<button
|
||||
v-if="copy.quickView && !selectMode"
|
||||
type="text"
|
||||
type="button"
|
||||
class="deal-card-peek"
|
||||
:aria-label="previewing ? 'بستن پیشنمایش' : 'پیشنمایش فعالیت'"
|
||||
:aria-expanded="previewing"
|
||||
:style="{ color: token.colorTextSecondary }"
|
||||
:style="chevronBtnStyle"
|
||||
@click.stop="emit('open', deal.id)"
|
||||
>
|
||||
<template #icon><i class="chev" aria-hidden="true" :style="chevronStyle" /></template>
|
||||
</a-button>
|
||||
<LeftOutlined :style="chevronIconStyle" />
|
||||
</button>
|
||||
<a-tooltip v-else-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
|
||||
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
|
||||
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
|
||||
@@ -292,3 +332,19 @@ const cardStyle = computed(() => ({
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* وسطچین دقیق آیکون داخل دایره (anticon پیشفرض vertical-align دارد) */
|
||||
.deal-card-peek :deep(.anticon) {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
.deal-card-peek :deep(.anticon > svg) {
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,9 +24,9 @@ import {
|
||||
} from 'ant-design-vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
import {
|
||||
ENTITY_TYPE_LABELS,
|
||||
PRIORITY_LABELS,
|
||||
ROUTINES_CTX,
|
||||
apiError,
|
||||
localInputToIso,
|
||||
@@ -68,10 +68,10 @@ const form = reactive({
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const activityPriorities = useActivityPriorities();
|
||||
void activityPriorities.load();
|
||||
const priorityOptions = computed(() =>
|
||||
(Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>).map(
|
||||
([value, label]) => ({ value, label }),
|
||||
),
|
||||
activityPriorities.items.value.map((row) => ({ value: row.key, label: row.label })),
|
||||
);
|
||||
|
||||
const categoryOptions = computed(() => [
|
||||
|
||||
@@ -25,9 +25,9 @@ import { api } from '@/lib/api';
|
||||
import { canAssignToOthers } from '@/lib/assign-access';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { formatJalaliDayClock } from '@/lib/jalali';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
import {
|
||||
ENTITY_TYPE_LABELS,
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_VARIANTS,
|
||||
ROUTINES_CTX,
|
||||
ROUTINE_ENTITY_LINK_LABELS,
|
||||
@@ -108,7 +108,11 @@ const done = computed(() => task.value?.status === 'completed');
|
||||
const auth = useAuthStore();
|
||||
const canManage = computed(() => !!shell?.context.value?.isManager);
|
||||
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'routines'));
|
||||
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
|
||||
const activityPriorities = useActivityPriorities();
|
||||
void activityPriorities.load();
|
||||
const priorities = computed(() =>
|
||||
activityPriorities.items.value.map((r) => [r.key, r.label] as [ActivityPriority, string]),
|
||||
);
|
||||
|
||||
function startEdit() {
|
||||
const t = task.value;
|
||||
@@ -193,7 +197,7 @@ function eventText(ev: RoutineTaskEventDto): string {
|
||||
<div class="badges">
|
||||
<UiBadge :variant="STATUS_VARIANTS[task.status]">{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}</UiBadge>
|
||||
<UiBadge :variant="PRIORITY_VARIANTS[task.priority]" size="sm">
|
||||
اولویت {{ PRIORITY_LABELS[task.priority] }}
|
||||
اولویت {{ activityPriorities.labelOf(task.priority) }}
|
||||
</UiBadge>
|
||||
<UiBadge v-if="task.isMandatory" variant="warning" size="sm">اجباری</UiBadge>
|
||||
</div>
|
||||
|
||||
@@ -23,9 +23,9 @@ import {
|
||||
|
||||
const AMenuItem = AMenu.Item;
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
import {
|
||||
ENTITY_TYPE_LABELS,
|
||||
PRIORITY_LABELS,
|
||||
ROUTINE_TASK_SOURCE_LABELS,
|
||||
ROUTINE_TASK_STATUS_LABELS,
|
||||
entityRoute,
|
||||
@@ -64,6 +64,9 @@ const emit = defineEmits<{
|
||||
action: [kind: RoutineTaskRowAction, task: RoutineTaskDto];
|
||||
}>();
|
||||
|
||||
const activityPriorities = useActivityPriorities();
|
||||
void activityPriorities.load();
|
||||
|
||||
const done = computed(() => props.task.status === 'completed');
|
||||
const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled');
|
||||
const open = computed(() => isOpenStatus(props.task.status));
|
||||
@@ -150,7 +153,7 @@ function onMenuClick({ key }: { key: string | number }) {
|
||||
<CheckOutlined /> {{ formatTime(task.completedAt) }}
|
||||
</a-typography-text>
|
||||
<a-tag v-if="task.category" :bordered="false">{{ task.category.name }}</a-tag>
|
||||
<a-tag :bordered="false">{{ PRIORITY_LABELS[task.priority] }}</a-tag>
|
||||
<a-tag :bordered="false">{{ activityPriorities.labelOf(task.priority) }}</a-tag>
|
||||
<a-tag v-if="task.status !== 'todo'" :bordered="false">
|
||||
{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}
|
||||
</a-tag>
|
||||
|
||||
@@ -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';
|
||||
|
||||
/** پیشفرض؛ برای نمایش زنده از useActivityPriorities استفاده کنید */
|
||||
export const PRIORITY_LABELS: Record<ActivityPriority, string> = {
|
||||
very_low: 'خیلی کم',
|
||||
low: 'کم',
|
||||
|
||||
@@ -25,6 +25,7 @@ import ActivityDeskFilters from '@/components/activities/ActivityDeskFilters.vue
|
||||
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
|
||||
import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue';
|
||||
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
||||
import {
|
||||
ACTIVITY_EXCEL_ALIASES,
|
||||
@@ -180,6 +181,7 @@ const boardDeleteOpen = ref(false);
|
||||
|
||||
async function onTypesChanged() {
|
||||
await activityTypes.load(true);
|
||||
await useActivityPriorities().load(true);
|
||||
kanbanReload.value += 1;
|
||||
await refreshAll();
|
||||
}
|
||||
@@ -1217,11 +1219,11 @@ onUnmounted(() => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
title="انواع فعالیت و فیلدها"
|
||||
title="انواع فعالیت و اولویت ها"
|
||||
@click="typesManagerOpen = true"
|
||||
>
|
||||
<UiIcon name="sliders" :size="14" />
|
||||
<span class="manage-label">انواع فعالیت و فیلدها</span>
|
||||
<span class="manage-label">انواع فعالیت و اولویت ها</span>
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="canManageStages"
|
||||
@@ -1380,7 +1382,7 @@ onUnmounted(() => {
|
||||
<hr class="board-menu-sep" />
|
||||
<button type="button" class="menu-row" @click="typesManagerOpen = true">
|
||||
<UiIcon name="sliders" :size="16" />
|
||||
انواع فعالیت و فیلدها
|
||||
انواع فعالیت و اولویت ها
|
||||
</button>
|
||||
<button type="button" class="menu-row" @click="stagesManagerOpen = true">
|
||||
<UiIcon name="settings" :size="16" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { computed, h, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { RouterLink, onBeforeRouteLeave, useRoute, useRouter } from 'vue-router';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
@@ -18,7 +18,7 @@ import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.v
|
||||
import ActivityPostponeSheet from '@/components/activities/ActivityPostponeSheet.vue';
|
||||
import ActivityFieldsView from '@/components/activities/ActivityFieldsView.vue';
|
||||
import StagePathStrip from '@/components/StagePathStrip.vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { Button as AButton, message, Modal } from 'ant-design-vue';
|
||||
import type {
|
||||
ActivityChecklistItem,
|
||||
ActivityDto,
|
||||
@@ -32,6 +32,7 @@ import { useAuthStore } from '@/stores/auth';
|
||||
import { canCollaborateOnActivity, canMutateActivity } from '@/lib/activity-access';
|
||||
import { canAssignToOthers } from '@/lib/assign-access';
|
||||
import { apiError } from '@/lib/routines';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
||||
import {
|
||||
bindThreadMedia,
|
||||
@@ -631,11 +632,59 @@ async function persistDrafts(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/** true = مجاز به ترک صفحه · false = بمان روی جزئیات */
|
||||
async function resolveDirty(): Promise<boolean> {
|
||||
if (!isDirty.value) return true;
|
||||
if (confirm('تغییرات ثبت شود؟')) return persistDrafts();
|
||||
discardDrafts();
|
||||
return true;
|
||||
return new Promise((resolve) => {
|
||||
const modal = Modal.confirm({
|
||||
title: 'تغییرات ذخیرهنشده',
|
||||
content: 'تغییراتی در این فعالیت انجام دادهاید، آیا ذخیره شود؟',
|
||||
centered: true,
|
||||
closable: false,
|
||||
maskClosable: false,
|
||||
keyboard: false,
|
||||
icon: null,
|
||||
footer: () =>
|
||||
h('div', { class: 'ant-modal-confirm-btns' }, [
|
||||
h(
|
||||
AButton,
|
||||
{
|
||||
onClick: () => {
|
||||
modal.destroy();
|
||||
resolve(false);
|
||||
},
|
||||
},
|
||||
() => 'ادامهٔ ویرایش',
|
||||
),
|
||||
h(
|
||||
AButton,
|
||||
{
|
||||
onClick: () => {
|
||||
discardDrafts();
|
||||
modal.destroy();
|
||||
resolve(true);
|
||||
},
|
||||
},
|
||||
() => 'ذخیره نکن',
|
||||
),
|
||||
h(
|
||||
AButton,
|
||||
{
|
||||
type: 'primary',
|
||||
onClick: () => {
|
||||
void (async () => {
|
||||
const ok = await persistDrafts();
|
||||
if (!ok) return;
|
||||
modal.destroy();
|
||||
resolve(true);
|
||||
})();
|
||||
},
|
||||
},
|
||||
() => 'ذخیره',
|
||||
),
|
||||
]),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onBeforeUnload(event: BeforeUnloadEvent) {
|
||||
@@ -671,13 +720,9 @@ async function toggleChecklistItem(key: string, itemId: string, done: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
const priorityLabel: Record<ActivityPriority, string> = {
|
||||
very_low: 'خیلی کم',
|
||||
low: 'کم',
|
||||
medium: 'متوسط',
|
||||
high: 'زیاد',
|
||||
very_high: 'خیلی زیاد',
|
||||
};
|
||||
const activityPriorities = useActivityPriorities();
|
||||
void activityPriorities.load();
|
||||
const priorityLabel = computed(() => activityPriorities.labels.value);
|
||||
|
||||
const subtitle = computed(() => {
|
||||
const a = activity.value;
|
||||
@@ -1039,17 +1084,8 @@ watch(id, () => {
|
||||
void load();
|
||||
});
|
||||
watch(() => route.query.nav, () => void loadNav(), { immediate: true });
|
||||
onBeforeRouteLeave(async (_to, _from, next) => {
|
||||
if (!isDirty.value) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (confirm('تغییرات ثبت شود؟')) next(await persistDrafts());
|
||||
else {
|
||||
discardDrafts();
|
||||
next();
|
||||
}
|
||||
});
|
||||
/** بدون next() — در Vue Router 4 گارد async+next باعث رد شدن بدون پرسش میشد */
|
||||
onBeforeRouteLeave(async () => resolveDirty());
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeunload', onBeforeUnload);
|
||||
void load();
|
||||
@@ -1150,12 +1186,17 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
<UiIcon name="flag" :size="15" class="flag" :class="draftPriority" />
|
||||
<UiIcon
|
||||
name="flag"
|
||||
:size="15"
|
||||
class="flag"
|
||||
:style="{ color: activityPriorities.colorOf(draftPriority) }"
|
||||
/>
|
||||
اولویت
|
||||
</dt>
|
||||
<dd>
|
||||
<select v-model="draftPriority" class="meta-control" :disabled="editLocked" aria-label="اولویت">
|
||||
<option v-for="(label, key) in priorityLabel" :key="key" :value="key">{{ label }}</option>
|
||||
<option v-for="(label, key) in priorityLabel" :key="String(key)" :value="key">{{ label }}</option>
|
||||
</select>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
import ActivityFieldsEditor from '@/components/activities/ActivityFieldsEditor.vue';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
import { typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
||||
import type {
|
||||
ActivityBoardDto,
|
||||
@@ -89,6 +90,8 @@ const boardId = ref('');
|
||||
const defaultBoardId = computed(() => boards.value.find((b) => b.isDefault)?.id ?? '');
|
||||
|
||||
const activityTypes = useActivityTypes();
|
||||
const activityPriorities = useActivityPriorities();
|
||||
void activityPriorities.load();
|
||||
const title = ref('');
|
||||
const type = ref<ActivityType | ''>('');
|
||||
const typeDefId = ref('');
|
||||
@@ -271,13 +274,13 @@ function isEmptyValue(v: ActivityFieldValue | undefined): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: string }> = [
|
||||
{ value: 'very_high', label: 'خیلی زیاد', tone: 'very-high' },
|
||||
{ value: 'high', label: 'زیاد', tone: 'high' },
|
||||
{ value: 'medium', label: 'متوسط', tone: 'medium' },
|
||||
{ value: 'low', label: 'کم', tone: 'low' },
|
||||
{ value: 'very_low', label: 'خیلی کم', tone: 'very-low' },
|
||||
];
|
||||
const priorityOptions = computed(() =>
|
||||
activityPriorities.items.value.map((row) => ({
|
||||
value: row.key,
|
||||
label: row.label,
|
||||
color: row.color,
|
||||
})),
|
||||
);
|
||||
|
||||
const typeMeta = computed(() => activityTypes.byId.value.get(typeDefId.value) ?? null);
|
||||
const typeFields = computed(() => typeMeta.value?.fields ?? []);
|
||||
@@ -402,7 +405,7 @@ const duePreview = computed(() => {
|
||||
|
||||
const statusText = computed(() => (isDone.value ? 'انجام شده' : 'انجام نشده'));
|
||||
const priorityText = computed(
|
||||
() => priorityOptions.find((p) => p.value === priority.value)?.label || '—',
|
||||
() => priorityOptions.value.find((p) => p.value === priority.value)?.label || '—',
|
||||
);
|
||||
const pageTitle = computed(() => (isNew.value ? 'ایجاد فعالیت جدید' : 'ویرایش فعالیت'));
|
||||
const summaryReady = computed(() => Boolean(title.value.trim() || type.value || selectedPerson.value));
|
||||
@@ -1442,7 +1445,13 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
<div class="card">
|
||||
<p class="pri-label">اولویت</p>
|
||||
<div class="priorities">
|
||||
<label v-for="opt in priorityOptions" :key="opt.value" :class="['pri', opt.tone, { on: priority === opt.value }]">
|
||||
<label
|
||||
v-for="opt in priorityOptions"
|
||||
:key="opt.value"
|
||||
class="pri"
|
||||
:class="{ on: priority === opt.value }"
|
||||
:style="{ color: opt.color }"
|
||||
>
|
||||
<input v-model="priority" type="radio" :value="opt.value" />
|
||||
<UiIcon name="flag" :size="16" />
|
||||
<span>{{ opt.label }}</span>
|
||||
@@ -2365,26 +2374,6 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pri.very-high {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.pri.high {
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.pri.medium {
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.pri.low {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.pri.very-low {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.pri.on span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
} from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { PRIORITY_LABELS, ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
import { ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
@@ -73,7 +74,11 @@ const targetOptions = [
|
||||
{ value: 'team' as const, label: 'همه اعضای یک تیم' },
|
||||
];
|
||||
|
||||
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
|
||||
const activityPriorities = useActivityPriorities();
|
||||
void activityPriorities.load();
|
||||
const priorities = computed(() =>
|
||||
activityPriorities.items.value.map((r) => [r.key, r.label] as [ActivityPriority, string]),
|
||||
);
|
||||
const entityLinks = Object.entries(ROUTINE_ENTITY_LINK_LABELS) as Array<[RoutineEntityLink, string]>;
|
||||
const reminderOptions = [
|
||||
{ value: '', label: 'طبق تنظیمات هر کاربر' },
|
||||
|
||||
@@ -7,8 +7,8 @@ import { UiBadge, UiEmptyState, UiSearch } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
import {
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_VARIANTS,
|
||||
ROUTINES_CTX,
|
||||
apiError,
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from '@/lib/routines';
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
const activityPriorities = useActivityPriorities();
|
||||
void activityPriorities.load();
|
||||
const auth = useAuthStore();
|
||||
const meId = computed(() => auth.user?.id ?? '');
|
||||
|
||||
@@ -80,7 +82,7 @@ const filtered = computed(() => {
|
||||
<p v-if="r.description" class="desc">{{ r.description }}</p>
|
||||
<div class="tags">
|
||||
<UiBadge v-if="r.category" size="sm">{{ r.category.name }}</UiBadge>
|
||||
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[r.priority]">{{ PRIORITY_LABELS[r.priority] }}</UiBadge>
|
||||
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[r.priority]">{{ activityPriorities.labelOf(r.priority) }}</UiBadge>
|
||||
<UiBadge v-if="r.isMandatory" size="sm" variant="warning">اجباری</UiBadge>
|
||||
<UiBadge v-if="r.reportRequired" size="sm" variant="info">گزارش الزامی</UiBadge>
|
||||
<UiBadge v-if="r.team" size="sm" variant="accent">تیم {{ r.team.name }}</UiBadge>
|
||||
|
||||
@@ -9,9 +9,9 @@ import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
|
||||
import { UiBadge, UiButton, UiEmptyState, UiSelect } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { useActivityPriorities } from '@/lib/activity-priorities';
|
||||
import {
|
||||
ENTITY_TYPE_LABELS,
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_VARIANTS,
|
||||
ROUTINES_CTX,
|
||||
apiError,
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
const activityPriorities = useActivityPriorities();
|
||||
void activityPriorities.load();
|
||||
|
||||
const items = ref<RoutineTaskDto[]>([]);
|
||||
const loading = ref(true);
|
||||
@@ -133,7 +135,7 @@ const showAssignee = computed(() => isManager.value && who.value !== '');
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[t.priority]">{{ PRIORITY_LABELS[t.priority] }}</UiBadge>
|
||||
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[t.priority]">{{ activityPriorities.labelOf(t.priority) }}</UiBadge>
|
||||
</td>
|
||||
<td>
|
||||
<RouterLink
|
||||
|
||||
@@ -1874,6 +1874,71 @@ export interface DealActivitySummaryDto {
|
||||
cue: Exclude<DealActivityCue, "none">;
|
||||
}
|
||||
export type ActivityPriority = "very_low" | "low" | "medium" | "high" | "very_high";
|
||||
|
||||
/** ترتیب نمایش ثابت (تعداد تغییر نمیکند) */
|
||||
export const ACTIVITY_PRIORITY_KEYS: ActivityPriority[] = [
|
||||
"very_high",
|
||||
"high",
|
||||
"medium",
|
||||
"low",
|
||||
"very_low",
|
||||
];
|
||||
|
||||
export type ActivityPrioritySetting = {
|
||||
key: ActivityPriority;
|
||||
label: string;
|
||||
/** رنگ متن/پرچم — #RRGGBB */
|
||||
color: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_ACTIVITY_PRIORITY_SETTINGS: ActivityPrioritySetting[] = [
|
||||
{ key: "very_high", label: "خیلی زیاد", color: "#dc2626" },
|
||||
{ key: "high", label: "زیاد", color: "#ea580c" },
|
||||
{ key: "medium", label: "متوسط", color: "#eab308" },
|
||||
{ key: "low", label: "کم", color: "#16a34a" },
|
||||
{ key: "very_low", label: "خیلی کم", color: "#94a3b8" },
|
||||
];
|
||||
|
||||
const PRIORITY_KEY_SET = new Set<string>(ACTIVITY_PRIORITY_KEYS);
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
/** ادغام تنظیم ذخیرهشده با پیشفرضها؛ همیشه ۵ سطح کامل */
|
||||
export function parseActivityPrioritySettings(raw: unknown): ActivityPrioritySetting[] {
|
||||
const overrides = new Map<ActivityPriority, { label?: string; color?: string }>();
|
||||
const bag =
|
||||
raw && typeof raw === "object" && !Array.isArray(raw)
|
||||
? (raw as Record<string, unknown>)
|
||||
: Array.isArray(raw)
|
||||
? Object.fromEntries(
|
||||
raw
|
||||
.filter((r): r is Record<string, unknown> => !!r && typeof r === "object")
|
||||
.map((r) => [String(r.key ?? ""), r]),
|
||||
)
|
||||
: {};
|
||||
for (const [key, val] of Object.entries(bag)) {
|
||||
if (!PRIORITY_KEY_SET.has(key) || !val || typeof val !== "object" || Array.isArray(val)) continue;
|
||||
const row = val as Record<string, unknown>;
|
||||
const label = typeof row.label === "string" ? row.label.trim() : "";
|
||||
const color = typeof row.color === "string" ? row.color.trim() : "";
|
||||
overrides.set(key as ActivityPriority, {
|
||||
...(label ? { label: label.slice(0, 40) } : {}),
|
||||
...(HEX_COLOR.test(color) ? { color } : {}),
|
||||
});
|
||||
}
|
||||
return DEFAULT_ACTIVITY_PRIORITY_SETTINGS.map((d) => {
|
||||
const o = overrides.get(d.key);
|
||||
return {
|
||||
key: d.key,
|
||||
label: o?.label || d.label,
|
||||
color: o?.color || d.color,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type UpdateActivityPrioritiesRequest = {
|
||||
items: Array<{ key: ActivityPriority; label: string; color: string }>;
|
||||
};
|
||||
|
||||
export type ActivityFilter = "today" | "overdue" | "upcoming" | "done" | "all";
|
||||
|
||||
export type ActivityStageColor = "green" | "orange" | "purple" | "blue" | "rose" | "slate";
|
||||
|
||||
Reference in New Issue
Block a user