Selection of multiple officials and role sub-categories

This commit is contained in:
2026-10-03 13:16:13 +03:30
parent 9f97371391
commit 3b128bd980
14 changed files with 904 additions and 95 deletions
+156 -14
View File
@@ -77,7 +77,7 @@ const dueTime = ref('10:30');
const durationMinutes = ref(30);
const personId = ref('');
const dealId = ref('');
const assigneeId = ref('');
const assigneeIds = ref<string[]>([]);
const isDone = ref(false);
const needsNote = ref(false);
const reminderAt = ref('');
@@ -109,7 +109,17 @@ const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: str
const typeMeta = computed(() => typeOptions.find((o) => o.value === type.value));
const selectedPerson = computed(() => people.value.find((p) => p.id === personId.value) ?? null);
const selectedAssignee = computed(() => users.value.find((u) => u.id === assigneeId.value) ?? null);
const selectedAssignees = computed(() =>
assigneeIds.value
.map((id) => users.value.find((u) => u.id === id))
.filter((u): u is (typeof users.value)[number] => Boolean(u)),
);
const assigneePickerLabel = computed(() => {
const list = selectedAssignees.value;
if (!list.length) return 'انتخاب مسئول';
if (list.length === 1) return list[0]!.fullName;
return `${list[0]!.fullName} و ${toFaDigits(list.length - 1)} نفر دیگر`;
});
const selectedDeal = computed(() => deals.value.find((d) => d.id === dealId.value) ?? null);
const personDeals = computed(() =>
deals.value.filter((d) => !personId.value || d.person?.id === personId.value),
@@ -161,7 +171,7 @@ function resetForm() {
durationMinutes.value = 30;
personId.value = '';
dealId.value = '';
assigneeId.value = auth.user?.id ?? '';
assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
isDone.value = false;
needsNote.value = false;
reminderAt.value = '';
@@ -213,7 +223,13 @@ async function load() {
reminderNotifySms.value = data.reminderNotifySms ?? false;
personId.value = data.person?.id ?? '';
dealId.value = data.deal?.id ?? '';
assigneeId.value = data.assignee?.id ?? auth.user?.id ?? '';
assigneeIds.value = data.assignees?.length
? data.assignees.map((a) => a.id)
: data.assignee?.id
? [data.assignee.id]
: auth.user?.id
? [auth.user.id]
: [];
isDone.value = data.status === 'done';
} else {
resetForm();
@@ -272,15 +288,26 @@ function payload(): CreateActivityRequest {
personId: personId.value || null,
companyId: selectedPerson.value?.company?.id ?? null,
dealId: dealId.value || null,
assigneeId: assigneeId.value || null,
assigneeIds: [...assigneeIds.value],
assigneeId: assigneeIds.value[0] || null,
};
}
function toggleAssignee(userId: string) {
const idx = assigneeIds.value.indexOf(userId);
if (idx >= 0) {
if (assigneeIds.value.length <= 1) return;
assigneeIds.value = assigneeIds.value.filter((id) => id !== userId);
return;
}
assigneeIds.value = [...assigneeIds.value, userId];
}
function validate() {
if (!type.value) return 'نوع فعالیت را انتخاب کنید.';
if (title.value.trim().length < 2) return 'عنوان فعالیت را بنویسید.';
if (!dueDate.value || !dueTime.value) return 'تاریخ و ساعت را مشخص کنید.';
if (!assigneeId.value) return 'مسئول فعالیت را انتخاب کنید.';
if (!assigneeIds.value.length) return 'حداقل یک مسئول فعالیت را انتخاب کنید.';
if (isNew.value && !personId.value) return 'شخص یا مشتری مرتبط را انتخاب کنید.';
if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.';
if (reminderAt.value && !reminderNotifyApp.value && !reminderNotifySms.value) {
@@ -480,22 +507,56 @@ watch([id, isNew], () => void load(), { immediate: true });
<div class="card">
<div class="field">
<label>مسئول انجام این فعالیت <i>*</i></label>
<label>مسئولان انجام این فعالیت <i>*</i></label>
<button class="picker person-pick" type="button" @click.stop="assigneeOpen = !assigneeOpen">
<UserAvatar :name="selectedAssignee?.fullName || 'مسئول'" :size="32" />
<span :class="{ ghost: !selectedAssignee }">
{{ selectedAssignee?.fullName || 'انتخاب مسئول' }}
<div class="assignee-avatars">
<UserAvatar
v-for="user in selectedAssignees.slice(0, 3)"
:key="user.id"
:name="user.fullName"
:size="28"
/>
<UserAvatar
v-if="!selectedAssignees.length"
name="مسئول"
:size="28"
/>
</div>
<span :class="{ ghost: !selectedAssignees.length }">
{{ assigneePickerLabel }}
</span>
<UiIcon name="chevron" :size="16" />
</button>
<ul v-if="assigneeOpen" class="menu">
<ul v-if="assigneeOpen" class="menu multi-menu" @click.stop>
<li v-for="user in users" :key="user.id">
<button type="button" @click="assigneeId = user.id; assigneeOpen = false">
<button
type="button"
class="multi-option"
:class="{ on: assigneeIds.includes(user.id) }"
@click="toggleAssignee(user.id)"
>
<span class="check" :class="{ on: assigneeIds.includes(user.id) }">
<UiIcon v-if="assigneeIds.includes(user.id)" name="check-plain" :size="12" />
</span>
<UserAvatar :name="user.fullName" :size="28" />
{{ user.fullName }}
</button>
</li>
</ul>
<small class="hint">می‌توانید چند نفر را به‌عنوان مسئول انتخاب کنید.</small>
<div v-if="selectedAssignees.length" class="assignee-chips">
<span v-for="user in selectedAssignees" :key="user.id" class="chip">
{{ user.fullName }}
<button
v-if="assigneeIds.length > 1"
type="button"
aria-label="حذف مسئول"
@click="toggleAssignee(user.id)"
>
×
</button>
</span>
</div>
</div>
</div>
@@ -622,8 +683,14 @@ watch([id, isNew], () => void load(), { immediate: true });
<dd>{{ toFaDigits(durationMinutes) }} دقیقه</dd>
</div>
<div>
<dt>مسئول</dt>
<dd>{{ selectedAssignee?.fullName || '—' }}</dd>
<dt>مسئولان</dt>
<dd>
{{
selectedAssignees.length
? selectedAssignees.map((u) => u.fullName).join('، ')
: '—'
}}
</dd>
</div>
<div>
<dt>مشتری مرتبط</dt>
@@ -825,6 +892,81 @@ watch([id, isNew], () => void load(), { immediate: true });
transform: rotate(90deg);
}
.assignee-avatars {
display: flex;
align-items: center;
}
.assignee-avatars :deep(.avatar),
.assignee-avatars > * {
margin-inline-end: -0.35rem;
box-shadow: 0 0 0 2px #fff;
}
.hint {
display: block;
margin-top: 0.35rem;
color: #94a3b8;
font-size: 0.75rem;
}
.assignee-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.5rem;
}
.assignee-chips .chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: #f0fdfa;
color: #0f766e;
border-radius: 999px;
padding: 0.2rem 0.55rem;
font-size: 0.78rem;
font-weight: 600;
}
.assignee-chips .chip button {
border: 0;
background: transparent;
color: #64748b;
cursor: pointer;
font: inherit;
line-height: 1;
padding: 0 0.1rem;
}
.multi-menu .multi-option {
display: flex;
align-items: center;
gap: 0.55rem;
width: 100%;
}
.multi-menu .check {
width: 18px;
height: 18px;
border: 1.5px solid #cbd5e1;
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
}
.multi-menu .check.on {
background: #14b8a6;
border-color: #14b8a6;
}
.multi-menu .multi-option.on {
background: #f0fdfa;
}
.type-ico {
width: 32px;
height: 32px;