feat(web): assign an activity to a whole team
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -46,6 +46,7 @@ import type {
|
|||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
PersonDto,
|
PersonDto,
|
||||||
PipelineBoardDto,
|
PipelineBoardDto,
|
||||||
|
TeamDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -80,6 +81,7 @@ const error = ref('');
|
|||||||
const people = ref<PersonDto[]>([]);
|
const people = ref<PersonDto[]>([]);
|
||||||
const deals = ref<DealDto[]>([]);
|
const deals = ref<DealDto[]>([]);
|
||||||
const users = ref<OrgUserDto[]>([]);
|
const users = ref<OrgUserDto[]>([]);
|
||||||
|
const teams = ref<TeamDto[]>([]);
|
||||||
const boards = ref<ActivityBoardDto[]>([]);
|
const boards = ref<ActivityBoardDto[]>([]);
|
||||||
const boardId = ref('');
|
const boardId = ref('');
|
||||||
const defaultBoardId = computed(() => boards.value.find((b) => b.isDefault)?.id ?? '');
|
const defaultBoardId = computed(() => boards.value.find((b) => b.isDefault)?.id ?? '');
|
||||||
@@ -264,6 +266,7 @@ const selectedAssignees = computed(() =>
|
|||||||
.filter((u): u is (typeof users.value)[number] => Boolean(u)),
|
.filter((u): u is (typeof users.value)[number] => Boolean(u)),
|
||||||
);
|
);
|
||||||
const pickedRoleIds = ref<string[]>([]);
|
const pickedRoleIds = ref<string[]>([]);
|
||||||
|
const pickedTeamIds = ref<string[]>([]);
|
||||||
const roleGroups = computed(() => {
|
const roleGroups = computed(() => {
|
||||||
const map = new Map<string, { id: string; name: string; userIds: string[] }>();
|
const map = new Map<string, { id: string; name: string; userIds: string[] }>();
|
||||||
for (const user of users.value) {
|
for (const user of users.value) {
|
||||||
@@ -278,6 +281,17 @@ const roleGroups = computed(() => {
|
|||||||
}
|
}
|
||||||
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name, 'fa'));
|
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name, 'fa'));
|
||||||
});
|
});
|
||||||
|
const teamGroups = computed(() => {
|
||||||
|
const active = new Set(users.value.filter((user) => user.isActive !== false).map((user) => user.id));
|
||||||
|
return teams.value
|
||||||
|
.map((team) => ({
|
||||||
|
id: team.id,
|
||||||
|
name: team.name,
|
||||||
|
userIds: [...new Set(team.members.map((member) => member.userId).filter((id) => active.has(id)))],
|
||||||
|
}))
|
||||||
|
.filter((team) => team.userIds.length > 0)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, 'fa'));
|
||||||
|
});
|
||||||
function normalizeSearch(value: string) {
|
function normalizeSearch(value: string) {
|
||||||
return value.replace(/ي/g, 'ی').replace(/ك/g, 'ک').replace(/\u200c/g, ' ').trim().toLowerCase();
|
return value.replace(/ي/g, 'ی').replace(/ك/g, 'ک').replace(/\u200c/g, ' ').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -287,6 +301,11 @@ const filteredRoleGroups = computed(() => {
|
|||||||
if (!q) return roleGroups.value;
|
if (!q) return roleGroups.value;
|
||||||
return roleGroups.value.filter((role) => normalizeSearch(role.name).includes(q));
|
return roleGroups.value.filter((role) => normalizeSearch(role.name).includes(q));
|
||||||
});
|
});
|
||||||
|
const filteredTeamGroups = computed(() => {
|
||||||
|
const q = assigneeSearch.value;
|
||||||
|
if (!q) return teamGroups.value;
|
||||||
|
return teamGroups.value.filter((team) => normalizeSearch(team.name).includes(q));
|
||||||
|
});
|
||||||
const filteredUsers = computed(() => {
|
const filteredUsers = computed(() => {
|
||||||
const q = assigneeSearch.value;
|
const q = assigneeSearch.value;
|
||||||
if (!q) return users.value;
|
if (!q) return users.value;
|
||||||
@@ -296,28 +315,41 @@ const filteredUsers = computed(() => {
|
|||||||
normalizeSearch(user.orgRoleName ?? '').includes(q),
|
normalizeSearch(user.orgRoleName ?? '').includes(q),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
function isRoleSelected(userIds: string[]) {
|
function coversAll(userIds: string[]) {
|
||||||
return userIds.length > 0 && userIds.every((id) => assigneeIds.value.includes(id));
|
return userIds.length > 0 && userIds.every((id) => assigneeIds.value.includes(id));
|
||||||
}
|
}
|
||||||
const selectedRoles = computed(() =>
|
const selectedRoles = computed(() =>
|
||||||
roleGroups.value.filter((role) => pickedRoleIds.value.includes(role.id)),
|
roleGroups.value.filter((role) => pickedRoleIds.value.includes(role.id)),
|
||||||
);
|
);
|
||||||
|
const selectedTeams = computed(() =>
|
||||||
|
teamGroups.value.filter((team) => pickedTeamIds.value.includes(team.id)),
|
||||||
|
);
|
||||||
const extraAssignees = computed(() => {
|
const extraAssignees = computed(() => {
|
||||||
const covered = new Set(selectedRoles.value.flatMap((role) => role.userIds));
|
const covered = new Set([
|
||||||
|
...selectedRoles.value.flatMap((role) => role.userIds),
|
||||||
|
...selectedTeams.value.flatMap((team) => team.userIds),
|
||||||
|
]);
|
||||||
return selectedAssignees.value.filter((user) => !covered.has(user.id));
|
return selectedAssignees.value.filter((user) => !covered.has(user.id));
|
||||||
});
|
});
|
||||||
const assigneePickerLabel = computed(() => {
|
const assigneePickerLabel = computed(() => {
|
||||||
const roles = selectedRoles.value;
|
const groups = [...selectedRoles.value, ...selectedTeams.value];
|
||||||
const extras = extraAssignees.value;
|
const extras = extraAssignees.value;
|
||||||
if (!assigneeIds.value.length) return 'انتخاب مسئول';
|
if (!assigneeIds.value.length) return 'انتخاب مسئول';
|
||||||
if (roles.length && !extras.length) {
|
if (groups.length && !extras.length) {
|
||||||
if (roles.length === 1) return `${roles[0]!.name} (${toFaDigits(roles[0]!.userIds.length)} نفر)`;
|
if (groups.length === 1) {
|
||||||
if (roles.length === 2) return `${roles[0]!.name} و ${roles[1]!.name}`;
|
return `${groups[0]!.name} (${toFaDigits(groups[0]!.userIds.length)} نفر)`;
|
||||||
return `${roles[0]!.name} و ${toFaDigits(roles.length - 1)} نقش دیگر`;
|
|
||||||
}
|
}
|
||||||
if (roles.length && extras.length) {
|
if (groups.length === 2) return `${groups[0]!.name} و ${groups[1]!.name}`;
|
||||||
const roleLabel = roles.map((role) => role.name).join('، ');
|
const moreWord =
|
||||||
return `${roleLabel} و ${toFaDigits(extras.length)} نفر`;
|
selectedTeams.value.length && !selectedRoles.value.length
|
||||||
|
? 'تیم'
|
||||||
|
: selectedRoles.value.length && !selectedTeams.value.length
|
||||||
|
? 'نقش'
|
||||||
|
: 'مورد';
|
||||||
|
return `${groups[0]!.name} و ${toFaDigits(groups.length - 1)} ${moreWord} دیگر`;
|
||||||
|
}
|
||||||
|
if (groups.length && extras.length) {
|
||||||
|
return `${groups.map((group) => group.name).join('، ')} و ${toFaDigits(extras.length)} نفر`;
|
||||||
}
|
}
|
||||||
if (selectedAssignees.value.length === 1) return selectedAssignees.value[0]!.fullName;
|
if (selectedAssignees.value.length === 1) return selectedAssignees.value[0]!.fullName;
|
||||||
return `${selectedAssignees.value[0]!.fullName} و ${toFaDigits(selectedAssignees.value.length - 1)} نفر دیگر`;
|
return `${selectedAssignees.value[0]!.fullName} و ${toFaDigits(selectedAssignees.value.length - 1)} نفر دیگر`;
|
||||||
@@ -325,9 +357,8 @@ const assigneePickerLabel = computed(() => {
|
|||||||
const assigneeSummaryLabel = computed(() => {
|
const assigneeSummaryLabel = computed(() => {
|
||||||
if (!assigneeIds.value.length) return '—';
|
if (!assigneeIds.value.length) return '—';
|
||||||
const parts = [
|
const parts = [
|
||||||
...selectedRoles.value.map(
|
...selectedRoles.value.map((role) => `${role.name} (${toFaDigits(role.userIds.length)} نفر)`),
|
||||||
(role) => `${role.name} (${toFaDigits(role.userIds.length)} نفر)`,
|
...selectedTeams.value.map((team) => `${team.name} (${toFaDigits(team.userIds.length)} نفر)`),
|
||||||
),
|
|
||||||
...extraAssignees.value.map((user) => user.fullName),
|
...extraAssignees.value.map((user) => user.fullName),
|
||||||
];
|
];
|
||||||
return parts.join('، ');
|
return parts.join('، ');
|
||||||
@@ -390,6 +421,7 @@ function resetForm() {
|
|||||||
dealId.value = '';
|
dealId.value = '';
|
||||||
assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
|
assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
|
||||||
pickedRoleIds.value = [];
|
pickedRoleIds.value = [];
|
||||||
|
pickedTeamIds.value = [];
|
||||||
isDone.value = false;
|
isDone.value = false;
|
||||||
needsNote.value = false;
|
needsNote.value = false;
|
||||||
reminderAt.value = '';
|
reminderAt.value = '';
|
||||||
@@ -426,18 +458,20 @@ async function load() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
const [peopleRes, boardRes, usersRes, activityBoardsRes] = await Promise.all([
|
const [peopleRes, boardRes, usersRes, activityBoardsRes, teamsRes] = await Promise.all([
|
||||||
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
|
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
|
||||||
api.get<PipelineBoardDto>('/pipelines/board'),
|
api.get<PipelineBoardDto>('/pipelines/board'),
|
||||||
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
|
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
|
||||||
api
|
api
|
||||||
.get<ActivityBoardDto[]>('/activities/boards')
|
.get<ActivityBoardDto[]>('/activities/boards')
|
||||||
.catch(() => ({ data: [] as ActivityBoardDto[] })),
|
.catch(() => ({ data: [] as ActivityBoardDto[] })),
|
||||||
|
api.get<TeamDto[]>('/teams').catch(() => ({ data: [] as TeamDto[] })),
|
||||||
activityTypes.load(true),
|
activityTypes.load(true),
|
||||||
]);
|
]);
|
||||||
people.value = peopleRes.data.items;
|
people.value = peopleRes.data.items;
|
||||||
deals.value = boardRes.data.stages.flatMap((s) => s.deals);
|
deals.value = boardRes.data.stages.flatMap((s) => s.deals);
|
||||||
users.value = usersRes.data;
|
users.value = usersRes.data;
|
||||||
|
teams.value = teamsRes.data;
|
||||||
boards.value = activityBoardsRes.data;
|
boards.value = activityBoardsRes.data;
|
||||||
boardId.value = props.initialBoardId || defaultBoardId.value;
|
boardId.value = props.initialBoardId || defaultBoardId.value;
|
||||||
|
|
||||||
@@ -473,7 +507,7 @@ async function load() {
|
|||||||
: auth.user?.id
|
: auth.user?.id
|
||||||
? [auth.user.id]
|
? [auth.user.id]
|
||||||
: [];
|
: [];
|
||||||
refreshPickedRoles();
|
refreshPickedGroups();
|
||||||
isDone.value = data.status === 'done';
|
isDone.value = data.status === 'done';
|
||||||
await loadSavedPhotos(data);
|
await loadSavedPhotos(data);
|
||||||
} else {
|
} else {
|
||||||
@@ -551,14 +585,32 @@ function payload(): CreateActivityRequest {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshPickedRoles() {
|
function refreshPickedGroups() {
|
||||||
pickedRoleIds.value = roleGroups.value
|
pickedRoleIds.value = roleGroups.value
|
||||||
.filter((role) => {
|
.filter((role) => {
|
||||||
if (!isRoleSelected(role.userIds)) return false;
|
if (!coversAll(role.userIds)) return false;
|
||||||
if (role.userIds.length >= 2) return true;
|
if (role.userIds.length >= 2) return true;
|
||||||
return pickedRoleIds.value.includes(role.id);
|
return pickedRoleIds.value.includes(role.id);
|
||||||
})
|
})
|
||||||
.map((role) => role.id);
|
.map((role) => role.id);
|
||||||
|
pickedTeamIds.value = teamGroups.value
|
||||||
|
.filter((team) => {
|
||||||
|
if (!coversAll(team.userIds)) return false;
|
||||||
|
if (team.userIds.length >= 2) return true;
|
||||||
|
return pickedTeamIds.value.includes(team.id);
|
||||||
|
})
|
||||||
|
.map((team) => team.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function coveredByPickedGroups() {
|
||||||
|
return new Set([
|
||||||
|
...roleGroups.value
|
||||||
|
.filter((role) => pickedRoleIds.value.includes(role.id))
|
||||||
|
.flatMap((role) => role.userIds),
|
||||||
|
...teamGroups.value
|
||||||
|
.filter((team) => pickedTeamIds.value.includes(team.id))
|
||||||
|
.flatMap((team) => team.userIds),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAssignee(userId: string) {
|
function toggleAssignee(userId: string) {
|
||||||
@@ -569,31 +621,38 @@ function toggleAssignee(userId: string) {
|
|||||||
} else {
|
} else {
|
||||||
assigneeIds.value = [...assigneeIds.value, userId];
|
assigneeIds.value = [...assigneeIds.value, userId];
|
||||||
}
|
}
|
||||||
refreshPickedRoles();
|
refreshPickedGroups();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRole(roleId: string, userIds: string[]) {
|
function toggleGroup(kind: 'role' | 'team', groupId: string, userIds: string[]) {
|
||||||
if (pickedRoleIds.value.includes(roleId)) {
|
const picked = kind === 'role' ? pickedRoleIds : pickedTeamIds;
|
||||||
pickedRoleIds.value = pickedRoleIds.value.filter((id) => id !== roleId);
|
if (picked.value.includes(groupId)) {
|
||||||
const still = new Set(
|
picked.value = picked.value.filter((id) => id !== groupId);
|
||||||
roleGroups.value
|
const still = coveredByPickedGroups();
|
||||||
.filter((role) => pickedRoleIds.value.includes(role.id))
|
|
||||||
.flatMap((role) => role.userIds),
|
|
||||||
);
|
|
||||||
assigneeIds.value = assigneeIds.value.filter((id) => !userIds.includes(id) || still.has(id));
|
assigneeIds.value = assigneeIds.value.filter((id) => !userIds.includes(id) || still.has(id));
|
||||||
refreshPickedRoles();
|
if (!assigneeIds.value.length && auth.user?.id) assigneeIds.value = [auth.user.id];
|
||||||
|
refreshPickedGroups();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const onlyDefaultUser =
|
const onlyDefaultUser =
|
||||||
pickedRoleIds.value.length === 0 &&
|
pickedRoleIds.value.length === 0 &&
|
||||||
|
pickedTeamIds.value.length === 0 &&
|
||||||
assigneeIds.value.length === 1 &&
|
assigneeIds.value.length === 1 &&
|
||||||
assigneeIds.value[0] === auth.user?.id &&
|
assigneeIds.value[0] === auth.user?.id &&
|
||||||
!userIds.includes(auth.user?.id ?? '');
|
!userIds.includes(auth.user?.id ?? '');
|
||||||
pickedRoleIds.value = [...pickedRoleIds.value, roleId];
|
picked.value = [...picked.value, groupId];
|
||||||
assigneeIds.value = onlyDefaultUser
|
assigneeIds.value = onlyDefaultUser
|
||||||
? [...userIds]
|
? [...userIds]
|
||||||
: [...new Set([...assigneeIds.value, ...userIds])];
|
: [...new Set([...assigneeIds.value, ...userIds])];
|
||||||
refreshPickedRoles();
|
refreshPickedGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRole(roleId: string, userIds: string[]) {
|
||||||
|
toggleGroup('role', roleId, userIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTeam(teamId: string, userIds: string[]) {
|
||||||
|
toggleGroup('team', teamId, userIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPhotos() {
|
function clearPhotos() {
|
||||||
@@ -963,7 +1022,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
|||||||
ref="assigneeSearchInput"
|
ref="assigneeSearchInput"
|
||||||
v-model="assigneeQuery"
|
v-model="assigneeQuery"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="جستجوی نقش یا نام عضو…"
|
:placeholder="teamGroups.length ? 'جستجوی تیم، نقش یا نام عضو…' : 'جستجوی نقش یا نام عضو…'"
|
||||||
@keydown.esc="assigneeOpen = false"
|
@keydown.esc="assigneeOpen = false"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -985,7 +1044,24 @@ watch([id, isNew], () => void load(), { immediate: true });
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
<li v-if="roleGroups.length && filteredUsers.length" class="menu-label">اعضا</li>
|
<template v-if="filteredTeamGroups.length">
|
||||||
|
<li class="menu-label">تیمها</li>
|
||||||
|
<li v-for="team in filteredTeamGroups" :key="team.id">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="multi-option"
|
||||||
|
:class="{ on: pickedTeamIds.includes(team.id) }"
|
||||||
|
@click="toggleTeam(team.id, team.userIds)"
|
||||||
|
>
|
||||||
|
<span class="check" :class="{ on: pickedTeamIds.includes(team.id) }">
|
||||||
|
<UiIcon v-if="pickedTeamIds.includes(team.id)" name="check-plain" :size="12" />
|
||||||
|
</span>
|
||||||
|
<span class="role-name">{{ team.name }}</span>
|
||||||
|
<small class="role-count">{{ toFaDigits(team.userIds.length) }} نفر</small>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
<li v-if="(roleGroups.length || teamGroups.length) && filteredUsers.length" class="menu-label">اعضا</li>
|
||||||
<li v-for="user in filteredUsers" :key="user.id">
|
<li v-for="user in filteredUsers" :key="user.id">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1002,19 +1078,29 @@ watch([id, isNew], () => void load(), { immediate: true });
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
v-if="assigneeSearch && !filteredRoleGroups.length && !filteredUsers.length"
|
v-if="assigneeSearch && !filteredRoleGroups.length && !filteredTeamGroups.length && !filteredUsers.length"
|
||||||
class="person-empty"
|
class="person-empty"
|
||||||
>
|
>
|
||||||
نقشی یا عضوی پیدا نشد.
|
موردی پیدا نشد.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<small class="hint">یک نقش را انتخاب کنید تا همهٔ اعضای آن مسئول شوند، یا چند نفر را جدا انتخاب کنید.</small>
|
<small class="hint">
|
||||||
<div v-if="selectedRoles.length || extraAssignees.length" class="assignee-chips">
|
{{
|
||||||
|
teamGroups.length
|
||||||
|
? 'یک تیم یا نقش را انتخاب کنید تا همهٔ اعضایش مسئول شوند، یا چند نفر را جدا انتخاب کنید.'
|
||||||
|
: 'یک نقش را انتخاب کنید تا همهٔ اعضای آن مسئول شوند، یا چند نفر را جدا انتخاب کنید.'
|
||||||
|
}}
|
||||||
|
</small>
|
||||||
|
<div v-if="selectedRoles.length || selectedTeams.length || extraAssignees.length" class="assignee-chips">
|
||||||
<span v-for="role in selectedRoles" :key="role.id" class="chip">
|
<span v-for="role in selectedRoles" :key="role.id" class="chip">
|
||||||
{{ role.name }}
|
{{ role.name }}
|
||||||
<button type="button" aria-label="حذف نقش" @click="toggleRole(role.id, role.userIds)">×</button>
|
<button type="button" aria-label="حذف نقش" @click="toggleRole(role.id, role.userIds)">×</button>
|
||||||
</span>
|
</span>
|
||||||
|
<span v-for="team in selectedTeams" :key="team.id" class="chip">
|
||||||
|
{{ team.name }}
|
||||||
|
<button type="button" aria-label="حذف تیم" @click="toggleTeam(team.id, team.userIds)">×</button>
|
||||||
|
</span>
|
||||||
<span v-for="user in extraAssignees" :key="user.id" class="chip">
|
<span v-for="user in extraAssignees" :key="user.id" class="chip">
|
||||||
{{ user.fullName }}
|
{{ user.fullName }}
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user