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:
@@ -23,7 +23,9 @@ import UiSegmented from '@/components/ui/UiSegmented.vue';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import ActivitiesKanban from '@/components/activities/ActivitiesKanban.vue';
|
||||
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
|
||||
import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue';
|
||||
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
|
||||
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
||||
import {
|
||||
ACTIVITY_EXCEL_ALIASES,
|
||||
ACTIVITY_EXCEL_FIELDS,
|
||||
@@ -56,10 +58,11 @@ import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
|
||||
const ACT_TABLE_KEY = 'activities';
|
||||
const ACT_COLUMNS = [
|
||||
{ key: 'title', label: 'مشتری / عنوان فعالیت', width: 'minmax(0, 2.2fr)' },
|
||||
{ key: 'title', label: 'عنوان فعالیت', width: 'minmax(0, 2fr)' },
|
||||
{ key: 'who', label: 'کارشناس', width: 'minmax(7rem, 0.9fr)' },
|
||||
{ key: 'customer', label: 'مشتری', width: 'minmax(7rem, 1fr)' },
|
||||
{ key: 'type', label: 'نوع فعالیت', width: '7.6rem' },
|
||||
{ key: 'when', label: 'زمان شروع', width: '7.2rem' },
|
||||
{ key: 'who', label: 'کارشناس', width: '8.4rem' },
|
||||
{ key: 'status', label: 'وضعیت', width: '8.6rem' },
|
||||
] as const;
|
||||
type ActColKey = (typeof ACT_COLUMNS)[number]['key'];
|
||||
@@ -80,7 +83,8 @@ const actGridStyle = computed(() => ({
|
||||
].join(' '),
|
||||
}));
|
||||
|
||||
type TypeFilter = 'all' | ActivityType;
|
||||
/** 'all' یا شناسهٔ نوع فعالیت */
|
||||
type TypeFilter = string;
|
||||
type StatusFilter = 'all' | ActivityStatus;
|
||||
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
|
||||
type DeskScope = 'mine' | 'team';
|
||||
@@ -106,14 +110,6 @@ const DESK_TABS: Array<{ key: DeskScope; label: string }> = [
|
||||
{ key: 'team', label: 'همه تیم' },
|
||||
];
|
||||
|
||||
const TYPE_FILTERS: Array<{ key: TypeFilter; label: string }> = [
|
||||
{ key: 'all', label: 'همه انواع' },
|
||||
{ key: 'call', label: 'تماس' },
|
||||
{ key: 'meeting', label: 'جلسه' },
|
||||
{ key: 'task', label: 'وظیفه' },
|
||||
{ key: 'note', label: 'پیام' },
|
||||
];
|
||||
|
||||
const STATUS_FILTERS: Array<{ key: StatusFilter; label: string }> = [
|
||||
{ key: 'all', label: 'همه وضعیتها' },
|
||||
{ key: 'open', label: 'باز / انجامنشده' },
|
||||
@@ -155,6 +151,13 @@ const filter = computed<TimeFilter>(() => {
|
||||
|
||||
const stages = ref<ActivityStageDto[]>([]);
|
||||
const stagesManagerOpen = ref(false);
|
||||
const typesManagerOpen = ref(false);
|
||||
|
||||
async function onTypesChanged() {
|
||||
await activityTypes.load(true);
|
||||
kanbanReload.value += 1;
|
||||
await refreshAll();
|
||||
}
|
||||
const kanbanReload = ref(0);
|
||||
const stageCounts = ref<ActivityStageCountsDto>({ counts: {}, total: 0 });
|
||||
|
||||
@@ -185,9 +188,25 @@ const ideasOpen = ref(false);
|
||||
const ACTIVITY_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
|
||||
const ACTIVITY_STATUSES: ActivityStatus[] = ['open', 'done', 'canceled'];
|
||||
|
||||
const activityTypes = useActivityTypes();
|
||||
const TYPE_FILTERS = computed<Array<{ key: TypeFilter; label: string }>>(() => [
|
||||
{ key: 'all', label: 'همه انواع' },
|
||||
...activityTypes.types.value
|
||||
.filter((t) => t.isActive || t.id === typeFilter.value)
|
||||
.map((t) => ({ key: t.id, label: t.name })),
|
||||
]);
|
||||
|
||||
/** پارامتر قدیمی ?type=call → نوع اصلیِ همان نوع پایه */
|
||||
const legacyBaseType = computed<ActivityType | null>(() => {
|
||||
const value = typeof route.query.type === 'string' ? route.query.type : '';
|
||||
return ACTIVITY_TYPES.includes(value as ActivityType) ? (value as ActivityType) : null;
|
||||
});
|
||||
|
||||
const typeFilter = computed<TypeFilter>(() => {
|
||||
const value = typeof route.query.type === 'string' ? route.query.type : 'all';
|
||||
return ACTIVITY_TYPES.includes(value as ActivityType) ? (value as ActivityType) : 'all';
|
||||
const id = typeof route.query.typeDefId === 'string' ? route.query.typeDefId.trim() : '';
|
||||
if (id) return id;
|
||||
if (legacyBaseType.value) return activityTypes.systemOf.value[legacyBaseType.value]?.id ?? 'all';
|
||||
return 'all';
|
||||
});
|
||||
|
||||
const statusFilter = computed<StatusFilter>(() => {
|
||||
@@ -217,20 +236,6 @@ const counts = ref<Record<TimeFilter, number>>({
|
||||
all: 0,
|
||||
});
|
||||
|
||||
const typeLabel: Record<ActivityType, string> = {
|
||||
call: 'تماس',
|
||||
meeting: 'جلسه',
|
||||
task: 'وظیفه',
|
||||
note: 'پیام',
|
||||
};
|
||||
|
||||
const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
|
||||
call: 'phone',
|
||||
meeting: 'users',
|
||||
task: 'check',
|
||||
note: 'chat',
|
||||
};
|
||||
|
||||
const statusLabel: Record<UiStatus, string> = {
|
||||
done: 'انجام شد',
|
||||
progress: 'در حال انجام',
|
||||
@@ -263,7 +268,13 @@ const sharedParams = computed<Record<string, string | undefined>>(() => ({
|
||||
filter: filter.value,
|
||||
q: typeof route.query.q === 'string' && route.query.q.trim() ? route.query.q.trim() : undefined,
|
||||
desk: showDeskTabs.value ? desk.value : undefined,
|
||||
type: typeFilter.value === 'all' ? undefined : typeFilter.value,
|
||||
typeDefId: typeof route.query.typeDefId === 'string' && route.query.typeDefId.trim()
|
||||
? route.query.typeDefId.trim()
|
||||
: undefined,
|
||||
type:
|
||||
!(typeof route.query.typeDefId === 'string' && route.query.typeDefId.trim()) && legacyBaseType.value
|
||||
? legacyBaseType.value
|
||||
: undefined,
|
||||
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
|
||||
assigneeId: assigneeFilter.value || undefined,
|
||||
}));
|
||||
@@ -524,7 +535,7 @@ function patchFacetQuery(patch: Record<string, string | undefined>) {
|
||||
}
|
||||
|
||||
function setTypeFilter(next: TypeFilter) {
|
||||
patchFacetQuery({ type: next === 'all' ? undefined : next });
|
||||
patchFacetQuery({ typeDefId: next === 'all' ? undefined : next, type: undefined });
|
||||
}
|
||||
|
||||
function setStatusFilter(next: StatusFilter) {
|
||||
@@ -620,6 +631,7 @@ watch(
|
||||
route.query.page,
|
||||
route.query.desk,
|
||||
route.query.type,
|
||||
route.query.typeDefId,
|
||||
route.query.status,
|
||||
route.query.assigneeId,
|
||||
route.query.stage,
|
||||
@@ -657,6 +669,7 @@ onMounted(() => {
|
||||
syncMobile();
|
||||
mobileQuery.addEventListener('change', syncMobile);
|
||||
void loadUsers();
|
||||
void activityTypes.load(true);
|
||||
void loadStages().then(() => {
|
||||
if (migrateLegacyDoneFilter()) return;
|
||||
void load();
|
||||
@@ -745,6 +758,16 @@ onUnmounted(() => {
|
||||
<UiIcon name="settings" :size="15" />
|
||||
مدیریت ستونها
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="canManageStages"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
@click="typesManagerOpen = true"
|
||||
>
|
||||
<UiIcon name="sliders" :size="15" />
|
||||
انواع فعالیت و فیلدها
|
||||
</UiButton>
|
||||
<UiSegmented v-model="viewModel" :options="VIEW_OPTIONS">
|
||||
<template #icon="{ option }">
|
||||
<UiIcon :name="option.icon ?? 'list'" :size="15" />
|
||||
@@ -800,7 +823,7 @@ onUnmounted(() => {
|
||||
<input
|
||||
v-model="q"
|
||||
type="search"
|
||||
placeholder="جستجو در عنوان، مشتری، نوع فعالیت..."
|
||||
placeholder="جستجو در عنوان، مشتری، کارشناس، نوع فعالیت..."
|
||||
/>
|
||||
</form>
|
||||
<div class="filter-wrap" @click.stop>
|
||||
@@ -962,27 +985,41 @@ onUnmounted(() => {
|
||||
<p v-if="subtitle(item)" class="sub">{{ subtitle(item) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="colOn('type')" class="type-cell">
|
||||
<span class="type-pill" :class="item.type">
|
||||
<UiIcon :name="typeIcon[item.type]" :size="13" />
|
||||
{{ typeLabel[item.type] }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="colOn('when')" class="when" :class="{ late: uiStatus(item) === 'overdue' }">
|
||||
<strong>{{ clockText(item) }}</strong>
|
||||
<span>{{ dayText(item) }}</span>
|
||||
</div>
|
||||
<div v-if="colOn('who')" class="who">
|
||||
<strong>{{
|
||||
item.assignees?.length
|
||||
? item.assignees.map((a) => a.fullName).join('، ')
|
||||
: item.assignee?.fullName || '—'
|
||||
}}</strong>
|
||||
<span v-if="item.company || item.person">
|
||||
<UiIcon name="building" :size="12" />
|
||||
{{ item.company?.name || item.person?.fullName }}
|
||||
</div>
|
||||
<div v-if="colOn('customer')" class="customer">
|
||||
<RouterLink
|
||||
v-if="item.company"
|
||||
:to="{ name: 'company-detail', params: { id: item.company.id } }"
|
||||
>
|
||||
<UiIcon name="building" :size="13" />
|
||||
{{ item.company.name }}
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="item.person"
|
||||
:to="{ name: 'person-detail', params: { id: item.person.id } }"
|
||||
:class="{ secondary: Boolean(item.company) }"
|
||||
>
|
||||
<UiIcon name="user" :size="13" />
|
||||
{{ item.person.fullName }}
|
||||
</RouterLink>
|
||||
<span v-if="!item.company && !item.person" class="none">—</span>
|
||||
</div>
|
||||
<div v-if="colOn('type')" class="type-cell">
|
||||
<span class="type-pill" :style="typeColorStyle(activityTypeView(item).color)">
|
||||
<UiIcon :name="activityTypeView(item).icon" :size="13" />
|
||||
{{ activityTypeView(item).name }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="colOn('when')" class="when" :class="{ late: uiStatus(item) === 'overdue' }">
|
||||
<strong>{{ clockText(item) }}</strong>
|
||||
<span>{{ dayText(item) }}</span>
|
||||
</div>
|
||||
<div v-if="colOn('status')" class="status-cell">
|
||||
<span class="status" :class="uiStatus(item)">
|
||||
<UiIcon :name="statusIcon[uiStatus(item)]" :size="13" />
|
||||
@@ -1068,10 +1105,12 @@ onUnmounted(() => {
|
||||
انجام شد
|
||||
</button>
|
||||
</UiDropdownMenu>
|
||||
<span class="type-ico" :class="item.type">
|
||||
<UiIcon :name="typeIcon[item.type]" :size="18" />
|
||||
<span class="type-ico" :style="typeColorStyle(activityTypeView(item).color)">
|
||||
<UiIcon :name="activityTypeView(item).icon" :size="18" />
|
||||
</span>
|
||||
<span class="type-pill" :style="typeColorStyle(activityTypeView(item).color)">
|
||||
{{ activityTypeView(item).name }}
|
||||
</span>
|
||||
<span class="type-pill" :class="item.type">{{ typeLabel[item.type] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="card-foot">
|
||||
@@ -1121,6 +1160,12 @@ onUnmounted(() => {
|
||||
@close="stagesManagerOpen = false"
|
||||
@changed="onStagesChanged(); kanbanReload += 1"
|
||||
/>
|
||||
<ActivityTypesManager
|
||||
v-if="canManageStages"
|
||||
:open="typesManagerOpen"
|
||||
@close="typesManagerOpen = false"
|
||||
@changed="onTypesChanged"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1566,7 +1611,7 @@ h1 {
|
||||
.thead,
|
||||
.trow {
|
||||
display: grid;
|
||||
grid-template-columns: 2.4rem minmax(0, 2.2fr) 7.6rem 7.2rem 8.4rem 8.6rem 2.4rem;
|
||||
grid-template-columns: 2.4rem minmax(0, 2fr) minmax(7rem, 0.9fr) minmax(7rem, 1fr) 7.6rem 7.2rem 8.6rem 2.4rem;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
padding: 0.95rem 1.1rem;
|
||||
@@ -1682,6 +1727,40 @@ time.note {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.customer {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.customer a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
color: var(--ink);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.customer a:hover {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.customer a.secondary {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.customer .none {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.when.late {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,16 @@ import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import UserMultiSelect from '@/components/UserMultiSelect.vue';
|
||||
import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
|
||||
import ActivityPostponeSheet from '@/components/activities/ActivityPostponeSheet.vue';
|
||||
import type { ActivityDto, ActivityPriority, ActivityType, OrgUserDto } from '@mento/shared';
|
||||
import ActivityFieldsView from '@/components/activities/ActivityFieldsView.vue';
|
||||
import type {
|
||||
ActivityChecklistItem,
|
||||
ActivityDto,
|
||||
ActivityPriority,
|
||||
OrgUserDto,
|
||||
} from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canReassignRecords } from '@/lib/roles';
|
||||
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -44,19 +51,33 @@ const assigneeNames = computed(() => {
|
||||
return list.length ? list.join('، ') : '—';
|
||||
});
|
||||
|
||||
const typeLabel: Record<ActivityType, string> = {
|
||||
call: 'تماس',
|
||||
meeting: 'جلسه',
|
||||
task: 'وظیفه',
|
||||
note: 'پیام',
|
||||
};
|
||||
const activityTypes = useActivityTypes();
|
||||
const typeView = computed(() => (activity.value ? activityTypeView(activity.value) : null));
|
||||
const typeDef = computed(() => (activity.value ? activityTypes.defOf(activity.value) : null));
|
||||
const fieldsBusy = ref(false);
|
||||
|
||||
const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
|
||||
call: 'phone',
|
||||
meeting: 'users',
|
||||
task: 'check',
|
||||
note: 'chat',
|
||||
};
|
||||
/** تیک چکلیست بدون باز کردن فرم ویرایش */
|
||||
async function toggleChecklistItem(key: string, itemId: string, done: boolean) {
|
||||
const a = activity.value;
|
||||
if (!a || fieldsBusy.value) return;
|
||||
const list = (a.fields?.[key] as ActivityChecklistItem[] | undefined) ?? [];
|
||||
const nextFields = {
|
||||
...(a.fields ?? {}),
|
||||
[key]: list.map((it) => (it.id === itemId ? { ...it, done } : it)),
|
||||
};
|
||||
activity.value = { ...a, fields: nextFields };
|
||||
fieldsBusy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.patch<ActivityDto>(`/activities/${a.id}`, { fields: nextFields });
|
||||
activity.value = { ...data, participantUsers: a.participantUsers };
|
||||
} catch {
|
||||
activity.value = a;
|
||||
error.value = 'ثبت تغییر چکلیست ناموفق بود.';
|
||||
} finally {
|
||||
fieldsBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const priorityLabel: Record<ActivityPriority, string> = {
|
||||
very_low: 'خیلی کم',
|
||||
@@ -254,6 +275,7 @@ watch(id, () => {
|
||||
onMounted(() => {
|
||||
void load();
|
||||
void loadUsers();
|
||||
void activityTypes.load();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -295,18 +317,18 @@ onMounted(() => {
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="type-ico" :class="activity.type">
|
||||
<UiIcon :name="typeIcon[activity.type]" :size="22" />
|
||||
<span class="type-ico" :style="typeColorStyle(typeView!.color)">
|
||||
<UiIcon :name="typeView!.icon" :size="22" />
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<dl>
|
||||
<div>
|
||||
<dt>
|
||||
<UiIcon :name="typeIcon[activity.type]" :size="15" />
|
||||
<UiIcon :name="typeView!.icon" :size="15" />
|
||||
نوع فعالیت
|
||||
</dt>
|
||||
<dd>{{ typeLabel[activity.type] }}</dd>
|
||||
<dd>{{ typeView!.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
@@ -369,6 +391,23 @@ onMounted(() => {
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article v-if="typeDef?.fields.length" class="card type-card">
|
||||
<h3>
|
||||
<span class="type-ico sm" :style="typeColorStyle(typeView!.color)">
|
||||
<UiIcon :name="typeView!.icon" :size="14" />
|
||||
</span>
|
||||
اطلاعات {{ typeView!.name }}
|
||||
</h3>
|
||||
<ActivityFieldsView
|
||||
:fields="typeDef.fields"
|
||||
:values="activity.fields ?? {}"
|
||||
:participant-users="activity.participantUsers"
|
||||
:busy="fieldsBusy"
|
||||
editable
|
||||
@toggle-item="toggleChecklistItem"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article class="card note">
|
||||
<button class="note-head" type="button" @click="noteOpen = !noteOpen">
|
||||
<strong>
|
||||
@@ -573,24 +612,18 @@ h1 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.type-ico.call {
|
||||
background: #ecfdf5;
|
||||
color: #0f766e;
|
||||
.type-ico.sm {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.type-ico.meeting {
|
||||
background: #f5f3ff;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.type-ico.task {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.type-ico.note {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
.type-card h3 {
|
||||
margin: 0 0 0.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.95rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
dl {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user