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
@@ -5,10 +5,10 @@ import axios from 'axios';
import type {
ActivityDto,
ActivityStageDto,
ActivityType,
PaginatedResponse,
} from '@mento/shared';
import { api } from '@/lib/api';
import { activityTypeView } from '@/lib/activity-types';
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
@@ -59,13 +59,6 @@ const moving = ref(false);
const stageBusy = ref(false);
const localOrder = ref<string[] | null>(null);
const typeLabel: Record<ActivityType, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'وظیفه',
note: 'پیام',
};
const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = {
open: undefined,
done: 'انجام‌شده',
@@ -109,7 +102,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
return {
id: item.id,
title: item.title,
description: [typeLabel[item.type], party].filter(Boolean).join(' · '),
description: [activityTypeView(item).name, party].filter(Boolean).join(' · '),
amount: null,
date: dateText(item),
initials: initialsFromTitle(party || item.title),
@@ -0,0 +1,674 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import type {
ActivityChecklistItem,
ActivityFieldDef,
ActivityFieldValue,
ActivityFieldValues,
ActivityParticipantsValue,
OrgUserDto,
PaginatedResponse,
PersonDto,
} from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import UserMultiSelect from '@/components/UserMultiSelect.vue';
import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{
fields: ActivityFieldDef[];
users: OrgUserDto[];
}>();
const model = defineModel<ActivityFieldValues>({ required: true });
function get<T extends ActivityFieldValue>(key: string, fallback: T): T {
const v = model.value[key];
return (v ?? fallback) as T;
}
function set(key: string, value: ActivityFieldValue) {
model.value = { ...model.value, [key]: value };
}
function str(key: string): string {
const v = model.value[key];
return typeof v === 'string' || typeof v === 'number' ? String(v) : '';
}
function multi(key: string): string[] {
const v = model.value[key];
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : [];
}
function toggleMulti(key: string, opt: string) {
const list = multi(key);
set(key, list.includes(opt) ? list.filter((x) => x !== opt) : [...list, opt]);
}
/* ---------- چک‌لیست ---------- */
const checklistDraft = ref<Record<string, string>>({});
function checklist(key: string): ActivityChecklistItem[] {
const v = model.value[key];
return Array.isArray(v) ? (v as ActivityChecklistItem[]).filter((x) => x && typeof x === 'object') : [];
}
function addChecklistItem(key: string) {
const text = (checklistDraft.value[key] ?? '').trim();
if (!text) return;
const id = `c_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
set(key, [...checklist(key), { id, text, done: false }]);
checklistDraft.value = { ...checklistDraft.value, [key]: '' };
}
function updateChecklistItem(key: string, id: string, patch: Partial<ActivityChecklistItem>) {
set(
key,
checklist(key).map((it) => (it.id === id ? { ...it, ...patch } : it)),
);
}
function removeChecklistItem(key: string, id: string) {
set(
key,
checklist(key).filter((it) => it.id !== id),
);
}
function moveChecklistItem(key: string, index: number, dir: -1 | 1) {
const list = [...checklist(key)];
const to = index + dir;
if (to < 0 || to >= list.length) return;
const [it] = list.splice(index, 1);
list.splice(to, 0, it!);
set(key, list);
}
/* ---------- شرکت‌کنندگان ---------- */
function participants(key: string): ActivityParticipantsValue {
const v = model.value[key] as ActivityParticipantsValue | null | undefined;
return {
userIds: v?.userIds ?? [],
people: v?.people ?? [],
guests: v?.guests ?? [],
};
}
function patchParticipants(key: string, patch: Partial<ActivityParticipantsValue>) {
set(key, { ...participants(key), ...patch });
}
const guestDraft = ref<Record<string, string>>({});
function addGuest(key: string) {
const name = (guestDraft.value[key] ?? '').trim();
if (!name) return;
const p = participants(key);
if (!p.guests.includes(name)) patchParticipants(key, { guests: [...p.guests, name] });
guestDraft.value = { ...guestDraft.value, [key]: '' };
}
const personQuery = ref<Record<string, string>>({});
const personResults = ref<Record<string, PersonDto[]>>({});
const personOpenKey = ref('');
let personTimer: ReturnType<typeof setTimeout> | undefined;
let personSeq = 0;
function searchPeople(key: string) {
personOpenKey.value = key;
if (personTimer) clearTimeout(personTimer);
const q = (personQuery.value[key] ?? '').trim();
if (!q) {
personResults.value = { ...personResults.value, [key]: [] };
return;
}
const seq = ++personSeq;
personTimer = setTimeout(async () => {
try {
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
params: { q, limit: 8 },
});
if (seq === personSeq) personResults.value = { ...personResults.value, [key]: data.items };
} catch {
if (seq === personSeq) personResults.value = { ...personResults.value, [key]: [] };
}
}, 280);
}
function addPerson(key: string, p: PersonDto) {
const cur = participants(key);
if (!cur.people.some((x) => x.id === p.id)) {
patchParticipants(key, { people: [...cur.people, { id: p.id, name: p.fullName }] });
}
personQuery.value = { ...personQuery.value, [key]: '' };
personResults.value = { ...personResults.value, [key]: [] };
personOpenKey.value = '';
}
watch(
() => props.fields.map((f) => f.key).join('|'),
() => {
checklistDraft.value = {};
guestDraft.value = {};
personQuery.value = {};
personResults.value = {};
},
);
</script>
<template>
<div class="fields-grid">
<div
v-for="f in fields"
:key="f.key"
class="cf"
:class="{ wide: ['textarea', 'checklist', 'participants', 'multiselect'].includes(f.type) }"
>
<label :for="`cf-${f.key}`">
{{ f.label }}
<i v-if="f.required">*</i>
</label>
<input
v-if="f.type === 'text' || f.type === 'url'"
:id="`cf-${f.key}`"
:type="f.type === 'url' ? 'url' : 'text'"
:dir="f.type === 'url' ? 'ltr' : undefined"
:value="str(f.key)"
:placeholder="f.placeholder || ''"
maxlength="1000"
@input="set(f.key, ($event.target as HTMLInputElement).value)"
/>
<textarea
v-else-if="f.type === 'textarea'"
:id="`cf-${f.key}`"
:value="str(f.key)"
:placeholder="f.placeholder || ''"
rows="3"
@input="set(f.key, ($event.target as HTMLTextAreaElement).value)"
/>
<input
v-else-if="f.type === 'number'"
:id="`cf-${f.key}`"
type="number"
inputmode="decimal"
dir="ltr"
:value="str(f.key)"
:placeholder="f.placeholder || ''"
@input="
set(
f.key,
($event.target as HTMLInputElement).value === ''
? null
: Number(($event.target as HTMLInputElement).value),
)
"
/>
<JalaliDateInput
v-else-if="f.type === 'date'"
:id="`cf-${f.key}`"
:model-value="str(f.key)"
@update:model-value="(v: string) => set(f.key, v || null)"
/>
<select
v-else-if="f.type === 'select'"
:id="`cf-${f.key}`"
:value="str(f.key)"
@change="set(f.key, ($event.target as HTMLSelectElement).value || null)"
>
<option value="">— انتخاب کنید —</option>
<option v-for="opt in f.options ?? []" :key="opt" :value="opt">{{ opt }}</option>
</select>
<div v-else-if="f.type === 'multiselect'" class="chips">
<button
v-for="opt in f.options ?? []"
:key="opt"
type="button"
class="chip-opt"
:class="{ on: multi(f.key).includes(opt) }"
@click="toggleMulti(f.key, opt)"
>
<UiIcon v-if="multi(f.key).includes(opt)" name="check-plain" :size="12" />
{{ opt }}
</button>
</div>
<label v-else-if="f.type === 'checkbox'" class="switch">
<input
:id="`cf-${f.key}`"
type="checkbox"
:checked="get<boolean>(f.key, false) === true"
@change="set(f.key, ($event.target as HTMLInputElement).checked)"
/>
<i />
<span>{{ get<boolean>(f.key, false) === true ? 'بله' : 'خیر' }}</span>
</label>
<div v-else-if="f.type === 'checklist'" class="checklist">
<p v-if="checklist(f.key).length" class="progress">
{{ toFaDigits(checklist(f.key).filter((x) => x.done).length) }} از
{{ toFaDigits(checklist(f.key).length) }} انجام شده
</p>
<ul>
<li v-for="(it, idx) in checklist(f.key)" :key="it.id" :class="{ done: it.done }">
<input
type="checkbox"
:checked="it.done"
aria-label="انجام شد"
@change="updateChecklistItem(f.key, it.id, { done: ($event.target as HTMLInputElement).checked })"
/>
<input
class="item-text"
:value="it.text"
maxlength="300"
@input="updateChecklistItem(f.key, it.id, { text: ($event.target as HTMLInputElement).value })"
/>
<button type="button" class="mini" :disabled="idx === 0" aria-label="بالا" @click="moveChecklistItem(f.key, idx, -1)">▲</button>
<button
type="button"
class="mini"
:disabled="idx === checklist(f.key).length - 1"
aria-label="پایین"
@click="moveChecklistItem(f.key, idx, 1)"
>
▼
</button>
<button type="button" class="mini danger" aria-label="حذف" @click="removeChecklistItem(f.key, it.id)">
<UiIcon name="x" :size="13" />
</button>
</li>
</ul>
<div class="add-row">
<input
v-model="checklistDraft[f.key]"
maxlength="300"
:placeholder="f.placeholder || 'مورد جدید را بنویسید و Enter بزنید'"
@keydown.enter.prevent="addChecklistItem(f.key)"
/>
<button type="button" class="add-btn" @click="addChecklistItem(f.key)">
<UiIcon name="plus" :size="14" />
افزودن
</button>
</div>
</div>
<div v-else-if="f.type === 'participants'" class="participants">
<div class="p-block">
<small>همکاران (فعالیت در لیست آن‌ها هم نمایش داده می‌شود)</small>
<UserMultiSelect
:model-value="participants(f.key).userIds"
:users="users"
add-label="افزودن همکار"
empty-label="همکاری انتخاب نشده"
@update:model-value="(ids: string[]) => patchParticipants(f.key, { userIds: ids })"
/>
</div>
<div class="p-block">
<small>اشخاص / مشتریان</small>
<div class="tags">
<span v-for="p in participants(f.key).people" :key="p.id" class="tag">
<UiIcon name="user" :size="12" />
{{ p.name }}
<button
type="button"
aria-label="حذف"
@click="patchParticipants(f.key, { people: participants(f.key).people.filter((x) => x.id !== p.id) })"
>
×
</button>
</span>
</div>
<div class="person-search">
<input
v-model="personQuery[f.key]"
type="search"
placeholder="جستجوی نام یا موبایل شخص…"
@input="searchPeople(f.key)"
@focus="personOpenKey = f.key"
/>
<ul v-if="personOpenKey === f.key && (personResults[f.key]?.length ?? 0) > 0" class="results">
<li v-for="p in personResults[f.key]" :key="p.id">
<button type="button" @click="addPerson(f.key, p)">
<b>{{ p.fullName }}</b>
<small v-if="p.company?.name">{{ p.company.name }}</small>
</button>
</li>
</ul>
</div>
</div>
<div class="p-block">
<small>مهمانان دیگر (نام آزاد)</small>
<div class="tags">
<span v-for="g in participants(f.key).guests" :key="g" class="tag guest">
{{ g }}
<button
type="button"
aria-label="حذف"
@click="patchParticipants(f.key, { guests: participants(f.key).guests.filter((x) => x !== g) })"
>
×
</button>
</span>
</div>
<div class="add-row">
<input
v-model="guestDraft[f.key]"
maxlength="120"
placeholder="نام مهمان و Enter"
@keydown.enter.prevent="addGuest(f.key)"
/>
<button type="button" class="add-btn" @click="addGuest(f.key)">
<UiIcon name="plus" :size="14" />
افزودن
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.fields-grid {
display: grid;
gap: 0.85rem;
}
.cf {
display: grid;
gap: 0.35rem;
min-width: 0;
}
.cf > label {
margin: 0;
color: var(--color-text-muted, #64748b);
font-size: var(--fs-xs, 0.78rem);
font-weight: 600;
}
.cf > label i {
color: var(--color-danger, #dc2626);
font-style: normal;
}
.cf input:not([type='checkbox']),
.cf select,
.cf textarea {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
min-height: var(--control-h);
padding: 0 0.85rem;
font: inherit;
background: #fff;
box-sizing: border-box;
}
.cf textarea {
min-height: 5rem;
padding: 0.6rem 0.85rem;
resize: vertical;
}
.chips,
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip-opt {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.3rem 0.75rem;
font: inherit;
font-size: 0.82rem;
cursor: pointer;
}
.chip-opt.on {
border-color: #5eead4;
background: #f0fdfa;
color: #0f766e;
font-weight: 600;
}
.switch {
display: inline-flex;
align-items: center;
gap: 0.6rem;
cursor: pointer;
}
.switch input {
display: none;
}
.switch i {
width: 44px;
height: 26px;
border-radius: 999px;
background: #e2e8f0;
position: relative;
}
.switch i::after {
content: '';
position: absolute;
inset-inline-start: 3px;
top: 3px;
width: 20px;
height: 20px;
border-radius: 999px;
background: #fff;
transition: 0.18s;
}
.switch input:checked + i {
background: #14b8a6;
}
.switch input:checked + i::after {
inset-inline-start: 21px;
}
.checklist ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.checklist li {
display: flex;
align-items: center;
gap: 0.4rem;
}
.checklist li input[type='checkbox'] {
width: 18px;
height: 18px;
accent-color: #14b8a6;
flex-shrink: 0;
}
.checklist .item-text {
flex: 1;
min-height: 2.2rem !important;
}
.checklist li.done .item-text {
text-decoration: line-through;
color: #94a3b8;
}
.progress {
margin: 0;
font-size: 0.78rem;
color: #0f766e;
font-weight: 600;
}
.mini {
width: 1.9rem;
height: 1.9rem;
flex-shrink: 0;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 0.65rem;
color: #64748b;
cursor: pointer;
}
.mini:disabled {
opacity: 0.35;
cursor: default;
}
.mini.danger {
color: #dc2626;
}
.add-row {
display: flex;
gap: 0.4rem;
margin-top: 0.35rem;
}
.add-row input {
flex: 1;
}
.add-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px dashed #5eead4;
background: #f0fdfa;
color: #0f766e;
border-radius: var(--radius);
padding: 0 0.8rem;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.participants {
display: grid;
gap: 0.75rem;
padding: 0.75rem;
border: 1px dashed #e2e8f0;
border-radius: var(--radius);
}
.p-block {
display: grid;
gap: 0.35rem;
}
.p-block > small {
color: #94a3b8;
font-size: 0.74rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: #f0fdfa;
color: #0f766e;
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-size: 0.78rem;
font-weight: 600;
}
.tag.guest {
background: #f1f5f9;
color: #334155;
}
.tag button {
border: 0;
background: transparent;
color: #64748b;
cursor: pointer;
font: inherit;
line-height: 1;
padding: 0 0.1rem;
}
.person-search {
position: relative;
}
.results {
position: absolute;
inset-inline: 0;
top: calc(100% + 4px);
z-index: 20;
list-style: none;
margin: 0;
padding: 0.3rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
max-height: 14rem;
overflow: auto;
}
.results button {
width: 100%;
display: grid;
gap: 0.1rem;
text-align: start;
border: 0;
background: none;
padding: 0.45rem 0.55rem;
border-radius: 8px;
font: inherit;
cursor: pointer;
}
.results button:hover {
background: #f8fafc;
}
.results b {
font-size: 0.86rem;
font-weight: 600;
}
.results small {
color: #94a3b8;
font-size: 0.74rem;
}
@media (min-width: 960px) {
.fields-grid {
grid-template-columns: 1fr 1fr;
}
.cf.wide {
grid-column: 1 / -1;
}
}
</style>
@@ -0,0 +1,263 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import type {
ActivityChecklistItem,
ActivityFieldDef,
ActivityFieldValues,
ActivityParticipantsValue,
UserSummaryDto,
} from '@mento/shared';
import { formatJalaliDay, toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{
fields: ActivityFieldDef[];
values: ActivityFieldValues;
participantUsers?: UserSummaryDto[];
/** تیک چک‌لیست قابل تغییر باشد */
editable?: boolean;
busy?: boolean;
}>();
const emit = defineEmits<{
toggleItem: [key: string, itemId: string, done: boolean];
}>();
const userName = computed(
() => new Map((props.participantUsers ?? []).map((u) => [u.id, u.fullName])),
);
const filled = computed(() =>
props.fields.filter((f) => {
const v = props.values[f.key];
if (v == null || v === '') return false;
if (Array.isArray(v)) return v.length > 0;
return true;
}),
);
function text(f: ActivityFieldDef): string {
const v = props.values[f.key];
if (f.type === 'checkbox') return v === true ? 'بله' : 'خیر';
if (f.type === 'date' && typeof v === 'string') {
const d = new Date(v);
return Number.isNaN(d.getTime()) ? v : formatJalaliDay(d);
}
if (f.type === 'number' && typeof v === 'number') return toFaDigits(v.toLocaleString('en-US'));
if (Array.isArray(v)) return (v as string[]).join('، ');
return typeof v === 'string' ? v : '';
}
function checklist(key: string): ActivityChecklistItem[] {
const v = props.values[key];
return Array.isArray(v) ? (v as ActivityChecklistItem[]) : [];
}
function participants(key: string): ActivityParticipantsValue {
const v = props.values[key] as ActivityParticipantsValue | undefined;
return { userIds: v?.userIds ?? [], people: v?.people ?? [], guests: v?.guests ?? [] };
}
function safeUrl(raw: string): string | null {
try {
const u = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null;
} catch {
return null;
}
}
</script>
<template>
<div v-if="filled.length" class="fv">
<div
v-for="f in filled"
:key="f.key"
class="row"
:class="{ block: ['textarea', 'checklist', 'participants'].includes(f.type) }"
>
<span class="label">{{ f.label }}</span>
<div v-if="f.type === 'checklist'" class="checklist">
<p class="progress">
{{ toFaDigits(checklist(f.key).filter((x) => x.done).length) }} از
{{ toFaDigits(checklist(f.key).length) }} انجام شده
<i
class="bar"
:style="{
'--p': `${(checklist(f.key).filter((x) => x.done).length / Math.max(1, checklist(f.key).length)) * 100}%`,
}"
/>
</p>
<label v-for="it in checklist(f.key)" :key="it.id" class="item" :class="{ done: it.done }">
<input
type="checkbox"
:checked="it.done"
:disabled="!editable || busy"
@change="emit('toggleItem', f.key, it.id, ($event.target as HTMLInputElement).checked)"
/>
<span>{{ it.text }}</span>
</label>
</div>
<div v-else-if="f.type === 'participants'" class="people">
<span v-for="uid in participants(f.key).userIds" :key="uid" class="tag user">
<UiIcon name="user" :size="12" />
{{ userName.get(uid) || 'همکار' }}
</span>
<RouterLink
v-for="p in participants(f.key).people"
:key="p.id"
class="tag person"
:to="{ name: 'person-detail', params: { id: p.id } }"
>
<UiIcon name="users" :size="12" />
{{ p.name }}
</RouterLink>
<span v-for="g in participants(f.key).guests" :key="g" class="tag guest">{{ g }}</span>
</div>
<a
v-else-if="f.type === 'url' && safeUrl(text(f))"
class="value link"
:href="safeUrl(text(f))!"
target="_blank"
rel="noopener noreferrer"
dir="ltr"
>
{{ text(f) }}
</a>
<p v-else class="value" :class="{ multiline: f.type === 'textarea' }">{{ text(f) }}</p>
</div>
</div>
<p v-else class="empty">اطلاعاتی برای این نوع فعالیت ثبت نشده است.</p>
</template>
<style scoped>
.fv {
display: grid;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
padding: 0.72rem 0;
border-top: 1px solid #f1f5f9;
}
.row.block {
display: grid;
gap: 0.45rem;
justify-content: stretch;
}
.label {
color: #64748b;
font-size: 0.86rem;
}
.value {
margin: 0;
font-weight: 700;
text-align: start;
word-break: break-word;
}
.value.multiline {
font-weight: 500;
white-space: pre-wrap;
line-height: 1.9;
color: #334155;
}
.value.link {
color: #0f766e;
text-decoration: none;
}
.checklist {
display: grid;
gap: 0.35rem;
}
.progress {
margin: 0 0 0.2rem;
display: grid;
gap: 0.3rem;
font-size: 0.78rem;
color: #0f766e;
font-weight: 600;
}
.bar {
display: block;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, #14b8a6 var(--p), #e2e8f0 var(--p));
}
.item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.55rem;
border-radius: 8px;
background: #f8fafc;
cursor: pointer;
font-size: 0.9rem;
}
.item input {
width: 18px;
height: 18px;
accent-color: #14b8a6;
flex-shrink: 0;
}
.item.done span {
text-decoration: line-through;
color: #94a3b8;
}
.people {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 999px;
padding: 0.22rem 0.65rem;
font-size: 0.8rem;
font-weight: 600;
text-decoration: none;
}
.tag.user {
background: #f0fdfa;
color: #0f766e;
}
.tag.person {
background: #eff6ff;
color: #1d4ed8;
}
.tag.guest {
background: #f1f5f9;
color: #334155;
}
.empty {
margin: 0.4rem 0 0;
color: #94a3b8;
font-size: 0.86rem;
}
</style>
@@ -0,0 +1,821 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import axios from 'axios';
import type {
ActivityFieldDef,
ActivityFieldType,
ActivityStageColor,
ActivityType,
ActivityTypeDefDto,
ActivityTypeIcon,
} from '@mento/shared';
import {
ACTIVITY_FIELD_TYPE_LABELS,
ACTIVITY_FIELD_TYPES,
ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
ACTIVITY_STAGE_COLOR_LABELS,
ACTIVITY_STAGE_COLORS,
ACTIVITY_TYPE_ICONS,
ACTIVITY_TYPE_LABELS,
} from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
ACTIVITY_TYPE_ICON_LABELS,
newFieldKey,
typeColorStyle,
useActivityTypes,
} from '@/lib/activity-types';
import { UiButton, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: []; changed: [] }>();
const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
type FieldDraft = ActivityFieldDef & { optionsText: string };
type Draft = {
id: string | null;
name: string;
baseType: ActivityType;
icon: ActivityTypeIcon;
color: ActivityStageColor;
isActive: boolean;
isSystem: boolean;
fields: FieldDraft[];
};
const { types, load } = useActivityTypes();
const selectedId = ref<string | 'new' | null>(null);
const draft = ref<Draft | null>(null);
const savedSnapshot = ref('');
const busy = ref('');
const error = ref('');
const notice = ref('');
function toDraft(t: ActivityTypeDefDto | null): Draft {
if (!t) {
return {
id: null,
name: '',
baseType: 'task',
icon: 'check',
color: 'blue',
isActive: true,
isSystem: false,
fields: [],
};
}
return {
id: t.id,
name: t.name,
baseType: t.baseType,
icon: t.icon,
color: t.color,
isActive: t.isActive,
isSystem: t.isSystem,
fields: t.fields.map((f) => ({ ...f, optionsText: (f.options ?? []).join('\n') })),
};
}
function snapshot(d: Draft | null) {
return JSON.stringify(d);
}
const dirty = computed(() => snapshot(draft.value) !== savedSnapshot.value);
function select(id: string | 'new') {
if (dirty.value && draft.value && !window.confirm('تغییرات ذخیره نشده از بین می‌رود. ادامه می‌دهید؟')) {
return;
}
selectedId.value = id;
draft.value = toDraft(id === 'new' ? null : (types.value.find((t) => t.id === id) ?? null));
savedSnapshot.value = snapshot(draft.value);
error.value = '';
notice.value = '';
}
watch(
() => props.open,
async (open) => {
if (!open) return;
error.value = '';
notice.value = '';
await load(true);
const first = types.value[0];
selectedId.value = null;
draft.value = null;
if (first) select(first.id);
},
{ immediate: true },
);
function apiMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
if (Array.isArray(msg)) return msg[0] ?? fallback;
if (typeof msg === 'string' && msg) return msg;
}
return fallback;
}
function addField() {
if (!draft.value) return;
draft.value.fields.push({
key: newFieldKey(),
label: '',
type: 'text',
required: false,
placeholder: '',
optionsText: '',
});
}
function moveField(index: number, dir: -1 | 1) {
const list = draft.value?.fields;
if (!list) return;
const to = index + dir;
if (to < 0 || to >= list.length) return;
const [f] = list.splice(index, 1);
list.splice(to, 0, f!);
}
function removeField(index: number) {
const f = draft.value?.fields[index];
if (!f) return;
if (
f.label.trim() &&
!window.confirm(
`فیلد «${f.label}» حذف شود؟ مقادیر ثبت‌شدهٔ آن در فعالیت‌های قبلی دیگر نمایش داده نمی‌شود.`,
)
) {
return;
}
draft.value!.fields.splice(index, 1);
}
function needsOptions(type: ActivityFieldType) {
return ACTIVITY_FIELD_TYPES_WITH_OPTIONS.includes(type);
}
function payloadFields(d: Draft): ActivityFieldDef[] {
return d.fields.map((f) => {
const out: ActivityFieldDef = { key: f.key, label: f.label.trim(), type: f.type };
if (needsOptions(f.type)) {
out.options = f.optionsText
.split(/\n|،|,/)
.map((o) => o.trim())
.filter(Boolean);
}
if (f.required) out.required = true;
if (f.placeholder?.trim()) out.placeholder = f.placeholder.trim();
return out;
});
}
function validate(d: Draft): string {
if (!d.name.trim()) return 'نام نوع فعالیت را بنویسید.';
const empty = d.fields.findIndex((f) => !f.label.trim());
if (empty >= 0) return `عنوان فیلد ${toFaDigits(empty + 1)} را بنویسید.`;
const noOpts = d.fields.find(
(f) => needsOptions(f.type) && !f.optionsText.split(/\n|،|,/).some((o) => o.trim()),
);
if (noOpts) return `برای فیلد «${noOpts.label}» حداقل یک گزینه بنویسید.`;
return '';
}
async function save() {
const d = draft.value;
if (!d) return;
const msg = validate(d);
if (msg) {
error.value = msg;
return;
}
busy.value = 'save';
error.value = '';
notice.value = '';
const body = {
name: d.name.trim(),
icon: d.icon,
color: d.color,
fields: payloadFields(d),
...(d.isSystem ? {} : { baseType: d.baseType, ...(d.id ? { isActive: d.isActive } : {}) }),
};
try {
if (d.id) {
const prev = types.value.find((t) => t.id === d.id);
if (
prev &&
!d.isSystem &&
prev.baseType !== d.baseType &&
!window.confirm(
'با تغییر نوع پایه، فعالیت‌های قبلی این نوع هم در گزارش‌ها با نوع پایهٔ جدید شمرده می‌شوند. ادامه می‌دهید؟',
)
) {
busy.value = '';
return;
}
await api.patch(`/activities/types/${d.id}`, body);
} else {
const { data } = await api.post<ActivityTypeDefDto>('/activities/types', body);
d.id = data.id;
}
await load(true);
const savedId = d.id!;
selectedId.value = savedId;
draft.value = toDraft(types.value.find((t) => t.id === savedId) ?? null);
savedSnapshot.value = snapshot(draft.value);
notice.value = 'ذخیره شد.';
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'ذخیرهٔ نوع فعالیت ناموفق بود.');
} finally {
busy.value = '';
}
}
async function remove() {
const d = draft.value;
if (!d?.id || d.isSystem) return;
if (
!window.confirm(
`نوع «${d.name}» حذف شود؟ فعالیت‌های این نوع به نوع اصلی «${ACTIVITY_TYPE_LABELS[d.baseType]}» برمی‌گردند. برای نگه داشتن سوابق، می‌توانید به‌جای حذف آن را غیرفعال کنید.`,
)
) {
return;
}
busy.value = 'remove';
error.value = '';
try {
await api.delete(`/activities/types/${d.id}`);
await load(true);
savedSnapshot.value = snapshot(null);
draft.value = null;
const first = types.value[0];
if (first) select(first.id);
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'حذف نوع فعالیت ناموفق بود.');
} finally {
busy.value = '';
}
}
async function moveType(index: number, dir: -1 | 1) {
const ids = types.value.map((t) => t.id);
const to = index + dir;
if (to < 0 || to >= ids.length) return;
const [moved] = ids.splice(index, 1);
ids.splice(to, 0, moved!);
busy.value = 'order';
error.value = '';
try {
await api.patch('/activities/types/reorder', { typeIds: ids });
await load(true);
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'جابه‌جایی ناموفق بود.');
} finally {
busy.value = '';
}
}
function close() {
if (dirty.value && draft.value && !window.confirm('تغییرات ذخیره نشده از بین می‌رود. بستن؟')) return;
emit('close');
}
</script>
<template>
<UiModal :open="open" title="انواع فعالیت و فیلدهای اختصاصی" @close="close">
<div class="mgr">
<p class="hint">
برای هر نوع فعالیت فیلدهای مخصوص خودش را تعریف کنید؛ مثلاً «موضوع جلسه» و «شرکت‌کنندگان» برای جلسه
یا «لیست کارها» برای وظیفه. «نوع پایه» مشخص می‌کند فعالیت در گزارش‌ها جزو تماس، جلسه، وظیفه یا پیام
شمرده شود.
</p>
<div class="layout">
<aside class="list">
<div
v-for="(t, index) in types"
:key="t.id"
class="type-row"
:class="{ on: selectedId === t.id, off: !t.isActive }"
>
<button type="button" class="pick" @click="select(t.id)">
<span class="ico" :style="typeColorStyle(t.color)">
<UiIcon :name="t.icon" :size="14" />
</span>
<span class="txt">
<b>{{ t.name }}</b>
<small>
{{ ACTIVITY_TYPE_LABELS[t.baseType] }} · {{ toFaDigits(t.fields.length) }} فیلد
<template v-if="!t.isActive"> · غیرفعال</template>
</small>
</span>
</button>
<div class="order">
<button
type="button"
:disabled="index === 0 || Boolean(busy)"
aria-label="انتقال به بالا"
@click="moveType(index, -1)"
>
▲
</button>
<button
type="button"
:disabled="index === types.length - 1 || Boolean(busy)"
aria-label="انتقال به پایین"
@click="moveType(index, 1)"
>
▼
</button>
</div>
</div>
<button type="button" class="add-type" :class="{ on: selectedId === 'new' }" @click="select('new')">
<UiIcon name="plus" :size="15" />
نوع فعالیت جدید
</button>
</aside>
<section v-if="draft" class="editor">
<div class="grid">
<label class="f">
<span>نام نوع</span>
<input v-model="draft.name" maxlength="60" placeholder="مثلاً: بازدید حضوری" />
</label>
<label class="f">
<span>نوع پایه (برای گزارش‌ها)</span>
<select v-model="draft.baseType" :disabled="draft.isSystem">
<option v-for="b in BASE_TYPES" :key="b" :value="b">{{ ACTIVITY_TYPE_LABELS[b] }}</option>
</select>
</label>
<label class="f">
<span>رنگ</span>
<select v-model="draft.color">
<option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
</option>
</select>
</label>
<label v-if="!draft.isSystem && draft.id" class="f toggle">
<input v-model="draft.isActive" type="checkbox" />
<span>فعال (در فرم فعالیت قابل انتخاب باشد)</span>
</label>
</div>
<div class="f">
<span>آیکون</span>
<div class="icons">
<button
v-for="ic in ACTIVITY_TYPE_ICONS"
:key="ic"
type="button"
:class="{ on: draft.icon === ic }"
:style="draft.icon === ic ? typeColorStyle(draft.color) : undefined"
:title="ACTIVITY_TYPE_ICON_LABELS[ic]"
:aria-label="ACTIVITY_TYPE_ICON_LABELS[ic]"
@click="draft.icon = ic"
>
<UiIcon :name="ic" :size="16" />
</button>
</div>
</div>
<p v-if="draft.isSystem" class="sys-note">
این نوع اصلی است: حذف و غیرفعال نمی‌شود، ولی نام، ظاهر و فیلدهایش قابل تغییر است.
</p>
<div class="fields-head">
<strong>فیلدهای اختصاصی</strong>
<small>{{ toFaDigits(draft.fields.length) }} فیلد</small>
</div>
<p v-if="!draft.fields.length" class="empty">
هنوز فیلدی تعریف نشده؛ فعالیت‌های این نوع فقط فیلدهای عمومی (عنوان، زمان، مسئول، مشتری…) را دارند.
</p>
<div v-for="(f, index) in draft.fields" :key="f.key" class="field-card">
<div class="field-top">
<span class="num">{{ toFaDigits(index + 1) }}</span>
<input v-model="f.label" class="label-input" maxlength="80" placeholder="عنوان فیلد (مثلاً: موضوع جلسه)" />
<select v-model="f.type" aria-label="نوع فیلد">
<option v-for="ft in ACTIVITY_FIELD_TYPES" :key="ft" :value="ft">
{{ ACTIVITY_FIELD_TYPE_LABELS[ft] }}
</option>
</select>
<div class="field-actions">
<button type="button" :disabled="index === 0" aria-label="بالا" @click="moveField(index, -1)">▲</button>
<button
type="button"
:disabled="index === draft.fields.length - 1"
aria-label="پایین"
@click="moveField(index, 1)"
>
▼
</button>
<button type="button" class="danger" aria-label="حذف فیلد" @click="removeField(index)">
<UiIcon name="trash" :size="14" />
</button>
</div>
</div>
<div class="field-extra">
<textarea
v-if="needsOptions(f.type)"
v-model="f.optionsText"
rows="3"
placeholder="گزینه‌ها — هر گزینه در یک خط"
/>
<input
v-if="!['checkbox', 'participants', 'select', 'multiselect', 'date'].includes(f.type)"
v-model="f.placeholder"
maxlength="200"
placeholder="متن راهنما داخل فیلد (اختیاری)"
/>
<label v-if="f.type !== 'checkbox'" class="req">
<input v-model="f.required" type="checkbox" />
اجباری
</label>
<small v-if="f.type === 'participants'" class="tip">
همکارانی که به‌عنوان شرکت‌کننده انتخاب شوند، این فعالیت را در لیست خودشان هم می‌بینند و اعلان
دریافت می‌کنند.
</small>
</div>
</div>
<button type="button" class="add-field" @click="addField">
<UiIcon name="plus" :size="15" />
افزودن فیلد
</button>
<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="!dirty || Boolean(busy)"
:loading="busy === 'save'"
@click="save"
>
{{ draft.id ? 'ذخیرهٔ تغییرات' : 'ساخت نوع فعالیت' }}
</UiButton>
<UiButton
v-if="draft.id && !draft.isSystem"
variant="ghost"
type="button"
:disabled="Boolean(busy)"
:loading="busy === 'remove'"
@click="remove"
>
<UiIcon name="trash" :size="15" />
حذف نوع
</UiButton>
</div>
</section>
<p v-else class="empty">یک نوع فعالیت را انتخاب کنید.</p>
</div>
</div>
</UiModal>
</template>
<style scoped>
.mgr {
display: grid;
gap: 0.9rem;
}
.hint {
margin: 0;
color: #64748b;
font-size: 0.84rem;
line-height: 1.8;
}
.layout {
display: grid;
gap: 1rem;
}
.list {
display: grid;
gap: 0.4rem;
align-content: start;
}
.type-row {
display: flex;
align-items: center;
gap: 0.3rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.type-row.on {
border-color: #5eead4;
background: #f0fdfa;
}
.type-row.off {
opacity: 0.6;
}
.pick {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.55rem;
border: 0;
background: none;
padding: 0.5rem 0.6rem;
font: inherit;
text-align: start;
cursor: pointer;
}
.ico {
width: 30px;
height: 30px;
border-radius: 999px;
display: grid;
place-items: center;
flex-shrink: 0;
}
.txt {
display: grid;
min-width: 0;
}
.txt b {
font-size: 0.88rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.txt small {
color: #94a3b8;
font-size: 0.72rem;
}
.order {
display: grid;
padding-inline-end: 0.3rem;
}
.order button,
.field-actions button {
border: 0;
background: none;
color: #94a3b8;
font-size: 0.62rem;
cursor: pointer;
padding: 0.1rem 0.3rem;
}
.order button:disabled,
.field-actions button:disabled {
opacity: 0.3;
cursor: default;
}
.add-type,
.add-field {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: var(--control-h);
border: 1px dashed #5eead4;
background: #f0fdfa;
color: #0f766e;
border-radius: var(--radius);
font: inherit;
font-weight: 700;
font-size: 0.86rem;
cursor: pointer;
}
.add-type.on {
border-style: solid;
}
.editor {
display: grid;
gap: 0.8rem;
align-content: start;
min-width: 0;
}
.grid {
display: grid;
gap: 0.65rem;
}
.f {
display: grid;
gap: 0.3rem;
min-width: 0;
}
.f > span {
color: #64748b;
font-size: 0.78rem;
font-weight: 600;
}
.f.toggle {
display: flex;
align-items: center;
gap: 0.45rem;
align-self: end;
min-height: var(--control-h);
}
.f.toggle span {
color: #334155;
font-weight: 500;
}
input:not([type='checkbox']),
select,
textarea {
min-height: var(--control-h);
padding: 0 0.65rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
font: inherit;
font-size: 0.86rem;
min-width: 0;
width: 100%;
box-sizing: border-box;
}
textarea {
padding: 0.5rem 0.65rem;
resize: vertical;
}
select:disabled {
background: #f8fafc;
color: #94a3b8;
}
input[type='checkbox'] {
accent-color: #14b8a6;
width: 16px;
height: 16px;
}
.icons {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.icons button {
width: 2.2rem;
height: 2.2rem;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
color: #64748b;
cursor: pointer;
}
.icons button.on {
border-color: currentColor;
}
.sys-note {
margin: 0;
padding: 0.5rem 0.7rem;
border-radius: var(--radius);
background: #f8fafc;
color: #475569;
font-size: 0.8rem;
}
.fields-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding-top: 0.6rem;
border-top: 1px solid var(--line);
}
.fields-head small {
color: #94a3b8;
}
.empty {
margin: 0;
color: #94a3b8;
font-size: 0.84rem;
}
.field-card {
display: grid;
gap: 0.5rem;
padding: 0.6rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fcfdfe;
}
.field-top {
display: grid;
grid-template-columns: 1.4rem minmax(0, 1fr) 10rem auto;
gap: 0.45rem;
align-items: center;
}
.num {
color: #94a3b8;
font-size: 0.8rem;
text-align: center;
}
.field-actions {
display: flex;
align-items: center;
}
.field-actions .danger {
color: #dc2626;
font-size: inherit;
}
.field-extra {
display: grid;
gap: 0.45rem;
padding-inline-start: 1.85rem;
}
.req {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.82rem;
color: #334155;
}
.tip {
color: #0f766e;
font-size: 0.75rem;
line-height: 1.7;
}
.error {
margin: 0;
color: #b91c1c;
}
.notice {
margin: 0;
color: #0f766e;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
position: sticky;
bottom: 0;
padding: 0.6rem 0 0.2rem;
background: #fff;
}
@media (min-width: 760px) {
.layout {
grid-template-columns: 14rem minmax(0, 1fr);
align-items: start;
}
.grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 560px) {
.field-top {
grid-template-columns: 1.4rem minmax(0, 1fr) auto;
}
.field-top select {
grid-column: 2 / -1;
grid-row: 2;
}
}
</style>
+111
View File
@@ -0,0 +1,111 @@
import { computed, ref } from 'vue';
import type {
ActivityDto,
ActivityStageColor,
ActivityType,
ActivityTypeDefDto,
ActivityTypeIcon,
} from '@mento/shared';
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
import { api } from '@/lib/api';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
export const BASE_TYPE_ICON: Record<ActivityType, ActivityTypeIcon> = {
call: 'phone',
meeting: 'users',
task: 'check',
note: 'chat',
};
const BASE_TYPE_COLOR: Record<ActivityType, ActivityStageColor> = {
call: 'green',
meeting: 'purple',
task: 'orange',
note: 'blue',
};
export const ACTIVITY_TYPE_ICON_LABELS: Record<ActivityTypeIcon, string> = {
phone: 'تلفن',
users: 'جمع',
check: 'تیک',
chat: 'گفتگو',
mail: 'ایمیل',
building: 'ساختمان',
calendar: 'تقویم',
star: 'ستاره',
flag: 'پرچم',
briefcase: 'کیف',
document: 'سند',
target: 'هدف',
truck: 'ارسال',
send: 'ارسال پیام',
megaphone: 'بلندگو',
pin: 'مکان',
bell: 'زنگ',
sparkle: 'درخشش',
};
const types = ref<ActivityTypeDefDto[]>([]);
const loaded = ref(false);
let pending: Promise<void> | null = null;
/** انواع فعالیت سازمان (کش مشترک بین صفحات) */
export function useActivityTypes() {
async function load(force = false): Promise<void> {
if (loaded.value && !force) return;
if (pending && !force) return pending;
pending = api
.get<ActivityTypeDefDto[]>('/activities/types')
.then(({ data }) => {
types.value = data;
loaded.value = true;
})
.catch(() => {
if (!loaded.value) types.value = [];
})
.finally(() => {
pending = null;
});
return pending;
}
const active = computed(() => types.value.filter((t) => t.isActive));
const byId = computed(() => new Map(types.value.map((t) => [t.id, t])));
/** نوع اصلی هر نوع پایه */
const systemOf = computed(() => {
const out: Partial<Record<ActivityType, ActivityTypeDefDto>> = {};
for (const t of types.value) if (t.isSystem && !out[t.baseType]) out[t.baseType] = t;
return out;
});
/** تعریف کامل نوع مؤثر یک فعالیت */
function defOf(a: Pick<ActivityDto, 'type' | 'typeDef'>): ActivityTypeDefDto | null {
if (a.typeDef?.id) return byId.value.get(a.typeDef.id) ?? null;
return systemOf.value[a.type] ?? null;
}
return { types, active, byId, systemOf, loaded, load, defOf };
}
export type TypeView = { name: string; icon: ActivityTypeIcon; color: ActivityStageColor };
/** برچسب/آیکون/رنگ قابل نمایش برای یک فعالیت */
export function activityTypeView(a: Pick<ActivityDto, 'type' | 'typeDef'>): TypeView {
if (a.typeDef) {
return { name: a.typeDef.name, icon: a.typeDef.icon, color: a.typeDef.color };
}
return {
name: ACTIVITY_TYPE_LABELS[a.type],
icon: BASE_TYPE_ICON[a.type],
color: BASE_TYPE_COLOR[a.type],
};
}
export function typeColorStyle(color: ActivityStageColor) {
const c = PIPELINE_ACCENT[color] ?? PIPELINE_ACCENT.green;
return { background: c.soft, color: c.text };
}
export function newFieldKey(): string {
return `f_${Math.random().toString(36).slice(2, 10)}`;
}
+126 -47
View File
@@ -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;
}
+66 -33
View File
@@ -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 {
+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 {