feat(activities): split customer column, wider search, custom activity types with type-specific fields

- Table: separate assignee and customer columns; search by assignee and activity type
- ActivityTypeDef model with per-type field definitions (text, select, checklist, participants, ...)
- Default types incl. meeting subject/participants and task checklist; admins manage types
- Participants see activities in their lists and get notified

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 12:29:23 +03:30
co-authored by Cursor
parent aa2f39256a
commit 13668c4ba4
17 changed files with 3371 additions and 147 deletions
+111 -33
View File
@@ -27,10 +27,15 @@ import {
UiPageHeader,
UiTextarea,
} from '@/components/ui';
import ActivityFieldsEditor from '@/components/activities/ActivityFieldsEditor.vue';
import { typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import type {
ActivityDto,
ActivityFieldValue,
ActivityFieldValues,
ActivityPriority,
ActivityType,
ActivityTypeDefDto,
CreateActivityRequest,
DealDto,
OrgUserDto,
@@ -71,8 +76,13 @@ const people = ref<PersonDto[]>([]);
const deals = ref<DealDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const activityTypes = useActivityTypes();
const title = ref('');
const type = ref<ActivityType | ''>('');
const typeDefId = ref('');
const fieldValues = ref<ActivityFieldValues>({});
/** نوع غیرفعالی که فعالیت فعلی دارد — در لیست انتخاب نگه داشته می‌شود */
const keepTypeId = ref('');
const priority = ref<ActivityPriority>('medium');
const description = ref('');
const tagsRaw = ref('');
@@ -185,16 +195,24 @@ watch(personQuery, (raw) => {
}, 300);
});
const typeOptions: Array<{
value: ActivityType;
label: string;
icon: 'phone' | 'users' | 'check' | 'chat';
}> = [
{ value: 'call', label: 'تماس', icon: 'phone' },
{ value: 'meeting', label: 'جلسه', icon: 'users' },
{ value: 'task', label: 'وظیفه', icon: 'check' },
{ value: 'note', label: 'یادداشت', icon: 'chat' },
];
const typeOptions = computed<ActivityTypeDefDto[]>(() =>
activityTypes.types.value.filter((t) => t.isActive || t.id === keepTypeId.value),
);
function pickType(def: ActivityTypeDefDto) {
typeDefId.value = def.id;
type.value = def.baseType;
typeOpen.value = false;
}
function isEmptyValue(v: ActivityFieldValue | undefined): boolean {
if (v == null || v === '') return true;
if (Array.isArray(v)) return v.length === 0;
if (typeof v === 'object') {
return !v.userIds?.length && !v.people?.length && !v.guests?.length;
}
return false;
}
const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: string }> = [
{ value: 'very_high', label: 'خیلی زیاد', tone: 'very-high' },
@@ -204,7 +222,8 @@ const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: str
{ value: 'very_low', label: 'خیلی کم', tone: 'very-low' },
];
const typeMeta = computed(() => typeOptions.find((o) => o.value === type.value));
const typeMeta = computed(() => activityTypes.byId.value.get(typeDefId.value) ?? null);
const typeFields = computed(() => typeMeta.value?.fields ?? []);
const selectedPerson = computed(() => people.value.find((p) => p.id === personId.value) ?? null);
const selectedAssignees = computed(() =>
assigneeIds.value
@@ -261,6 +280,9 @@ function defaultDue() {
function resetForm() {
title.value = '';
type.value = '';
typeDefId.value = '';
fieldValues.value = {};
keepTypeId.value = '';
priority.value = 'medium';
description.value = '';
tagsRaw.value = '';
@@ -300,6 +322,7 @@ async function load() {
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
api.get<PipelineBoardDto>('/pipelines/board'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
activityTypes.load(true),
]);
people.value = peopleRes.data.items;
deals.value = boardRes.data.stages.flatMap((s) => s.deals);
@@ -309,6 +332,10 @@ async function load() {
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
title.value = data.title;
type.value = data.type;
typeDefId.value =
data.typeDef?.id ?? activityTypes.systemOf.value[data.type]?.id ?? '';
keepTypeId.value = typeDefId.value;
fieldValues.value = { ...(data.fields ?? {}) };
priority.value = data.priority ?? 'medium';
description.value = data.description ?? '';
needsNote.value = Boolean(data.description?.trim());
@@ -342,13 +369,17 @@ async function load() {
await ensurePersonLoaded(incomingPerson);
personId.value = incomingPerson;
}
const incomingDef =
typeof route.query.typeDefId === 'string'
? activityTypes.byId.value.get(route.query.typeDefId)
: undefined;
const incomingType = props.initialType || route.query.type;
if (
typeof incomingType === 'string' &&
typeOptions.some((o) => o.value === incomingType)
) {
type.value = incomingType as ActivityType;
}
const baseDef =
typeof incomingType === 'string'
? activityTypes.systemOf.value[incomingType as ActivityType]
: undefined;
const def = incomingDef?.isActive ? incomingDef : baseDef;
if (def) pickType(def);
const incomingTitle =
props.initialTitle ||
(typeof route.query.title === 'string' ? route.query.title : '');
@@ -379,9 +410,14 @@ function payload(): CreateActivityRequest {
const due =
dueDate.value && dueTime.value ? new Date(`${dueDate.value}T${dueTime.value}:00`) : null;
const reminder = parseLocalInput(reminderAt.value);
const keys = new Set(typeFields.value.map((f) => f.key));
return {
title: title.value,
type: type.value || 'task',
typeDefId: typeDefId.value || null,
fields: Object.fromEntries(
Object.entries(fieldValues.value).filter(([k, v]) => keys.has(k) && !isEmptyValue(v)),
),
priority: priority.value,
description: description.value || undefined,
tags: parseTags(tagsRaw.value),
@@ -411,6 +447,10 @@ function toggleAssignee(userId: string) {
function validate() {
if (!type.value) return 'نوع فعالیت را انتخاب کنید.';
if (title.value.trim().length < 2) return 'عنوان فعالیت را بنویسید.';
const missing = typeFields.value.find(
(f) => f.required && f.type !== 'checkbox' && isEmptyValue(fieldValues.value[f.key]),
);
if (missing) return `فیلد «${missing.label}» را تکمیل کنید.`;
if (!dueDate.value || !dueTime.value) return 'تاریخ و ساعت را مشخص کنید.';
if (!assigneeIds.value.length) return 'حداقل یک مسئول فعالیت را انتخاب کنید.';
if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.';
@@ -553,21 +593,28 @@ watch([id, isNew], () => void load(), { immediate: true });
<div class="field">
<label>نوع فعالیت <i>*</i></label>
<button class="picker type-pick" type="button" @click.stop="typeOpen = !typeOpen">
<span class="type-ico" :class="type || 'empty'">
<span
class="type-ico"
:style="typeMeta ? typeColorStyle(typeMeta.color) : undefined"
>
<UiIcon :name="typeMeta?.icon || 'phone'" :size="16" />
</span>
<span :class="{ ghost: !typeMeta }">{{ typeMeta?.label || 'انتخاب کنید' }}</span>
<span :class="{ ghost: !typeMeta }">{{ typeMeta?.name || 'انتخاب کنید' }}</span>
<UiIcon name="chevron" :size="16" />
</button>
<ul v-if="typeOpen" class="menu">
<li v-for="opt in typeOptions" :key="opt.value">
<button type="button" @click="type = opt.value; typeOpen = false">
<span class="type-ico" :class="opt.value">
<li v-for="opt in typeOptions" :key="opt.id">
<button type="button" :class="{ on: opt.id === typeDefId }" @click="pickType(opt)">
<span class="type-ico" :style="typeColorStyle(opt.color)">
<UiIcon :name="opt.icon" :size="14" />
</span>
{{ opt.label }}
{{ opt.name }}
<small v-if="opt.fields.length" class="type-fields-count">
{{ toFaDigits(opt.fields.length) }} فیلد
</small>
</button>
</li>
<li v-if="!typeOptions.length" class="menu-empty">نوع فعالیتی تعریف نشده است.</li>
</ul>
</div>
<div class="field">
@@ -582,6 +629,16 @@ watch([id, isNew], () => void load(), { immediate: true });
</div>
</div>
<div v-if="typeFields.length" class="card type-fields">
<p class="type-fields-title">
<span class="type-ico sm" :style="typeColorStyle(typeMeta!.color)">
<UiIcon :name="typeMeta!.icon" :size="13" />
</span>
اطلاعات {{ typeMeta!.name }}
</p>
<ActivityFieldsEditor v-model="fieldValues" :fields="typeFields" :users="users" />
</div>
<div class="card toggles">
<label class="switch">
<span>این فعالیت انجام شده</span>
@@ -844,7 +901,7 @@ watch([id, isNew], () => void load(), { immediate: true });
<dl>
<div>
<dt>نوع فعالیت</dt>
<dd>{{ typeMeta?.label || '—' }}</dd>
<dd>{{ typeMeta?.name || '—' }}</dd>
</div>
<div>
<dt>تاریخ و ساعت</dt>
@@ -1160,19 +1217,40 @@ watch([id, isNew], () => void load(), { immediate: true });
flex-shrink: 0;
}
.type-ico.meeting {
background: #f5f3ff;
color: #7c3aed;
.type-ico.sm {
width: 26px;
height: 26px;
}
.type-ico.task {
background: #fff7ed;
color: #c2410c;
.menu button.on {
background: #f0fdfa;
}
.type-ico.note {
background: #eff6ff;
color: #2563eb;
.type-fields-count {
margin-inline-start: auto;
color: #94a3b8;
font-size: 0.72rem;
}
.menu-empty {
padding: 0.6rem;
color: #94a3b8;
font-size: 0.82rem;
}
.type-fields {
display: grid;
gap: 0.85rem;
}
.type-fields-title {
margin: 0;
display: flex;
align-items: center;
gap: 0.45rem;
font-weight: 700;
font-size: 0.92rem;
color: #0f172a;
}
.who {