meeting matches: add the ability to exclude individuals altogether or groups from matching one another
This commit is contained in:
249
src/components/MeetingExclusionGroups.vue
Normal file
249
src/components/MeetingExclusionGroups.vue
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-for="group in groups" :key="group.id" class="mb-3">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'rounded-lg border p-3',
|
||||||
|
colorSet(group.colorIndex).bg,
|
||||||
|
colorSet(group.colorIndex).border,
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'w-3 h-3 rounded-full shrink-0',
|
||||||
|
colorSet(group.colorIndex).dot,
|
||||||
|
]"
|
||||||
|
></span>
|
||||||
|
<input
|
||||||
|
:value="group.name"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="[
|
||||||
|
'text-sm font-medium bg-transparent border-none',
|
||||||
|
'outline-none flex-1 min-w-0 placeholder-gray-400',
|
||||||
|
{ 'cursor-default': disabled },
|
||||||
|
]"
|
||||||
|
placeholder="Group name…"
|
||||||
|
@input="
|
||||||
|
updateGroupName(
|
||||||
|
group.id,
|
||||||
|
($event.target as HTMLInputElement).value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="!disabled"
|
||||||
|
class="text-slate-400 hover:text-red-600 transition-colors ml-2 shrink-0"
|
||||||
|
title="Delete group"
|
||||||
|
@click="removeGroup(group.id)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="trash-can" class="text-sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-1.5 mb-2">
|
||||||
|
<span
|
||||||
|
v-for="did in group.memberDids"
|
||||||
|
:key="did"
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs',
|
||||||
|
colorSet(group.colorIndex).chip,
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ getMemberName(did) }}
|
||||||
|
<button
|
||||||
|
v-if="!disabled"
|
||||||
|
class="hover:opacity-70"
|
||||||
|
@click="removeMemberFromGroup(group.id, did)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="xmark" class="text-xs" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="group.memberDids.length === 0"
|
||||||
|
class="text-xs text-slate-400 italic"
|
||||||
|
>
|
||||||
|
No members yet
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="!disabled">
|
||||||
|
<div v-if="addingToGroupId === group.id" class="mt-2">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap gap-1.5 p-2 bg-white bg-opacity-60 rounded border border-gray-200"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="member in availableMembersForGroup(group)"
|
||||||
|
:key="member.did"
|
||||||
|
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-white border border-gray-300 hover:bg-gray-100 transition-colors"
|
||||||
|
@click="addMemberToGroup(group.id, member.did)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="plus" class="text-xs text-green-600" />
|
||||||
|
{{ member.name }}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-if="availableMembersForGroup(group).length === 0"
|
||||||
|
class="text-xs text-slate-400 italic"
|
||||||
|
>
|
||||||
|
All members already assigned
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="text-xs text-slate-500 mt-1"
|
||||||
|
@click="addingToGroupId = ''"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="text-xs text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
@click="addingToGroupId = group.id"
|
||||||
|
>
|
||||||
|
<font-awesome icon="plus" class="text-xs" />
|
||||||
|
Add member
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="!disabled"
|
||||||
|
class="text-sm text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
@click="addGroup"
|
||||||
|
>
|
||||||
|
<font-awesome icon="plus" class="text-sm" />
|
||||||
|
New Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
|
||||||
|
import { DoNotPairGroup } from "@/interfaces";
|
||||||
|
|
||||||
|
interface MemberInfo {
|
||||||
|
did: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_COLORS = [
|
||||||
|
{
|
||||||
|
bg: "bg-orange-50",
|
||||||
|
border: "border-orange-200",
|
||||||
|
dot: "bg-orange-400",
|
||||||
|
chip: "bg-orange-200 text-orange-800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bg: "bg-purple-50",
|
||||||
|
border: "border-purple-200",
|
||||||
|
dot: "bg-purple-400",
|
||||||
|
chip: "bg-purple-200 text-purple-800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bg: "bg-teal-50",
|
||||||
|
border: "border-teal-200",
|
||||||
|
dot: "bg-teal-400",
|
||||||
|
chip: "bg-teal-200 text-teal-800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bg: "bg-pink-50",
|
||||||
|
border: "border-pink-200",
|
||||||
|
dot: "bg-pink-400",
|
||||||
|
chip: "bg-pink-200 text-pink-800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bg: "bg-indigo-50",
|
||||||
|
border: "border-indigo-200",
|
||||||
|
dot: "bg-indigo-400",
|
||||||
|
chip: "bg-indigo-200 text-indigo-800",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bg: "bg-yellow-50",
|
||||||
|
border: "border-yellow-200",
|
||||||
|
dot: "bg-yellow-400",
|
||||||
|
chip: "bg-yellow-200 text-yellow-800",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class MeetingExclusionGroups extends Vue {
|
||||||
|
@Prop({ required: true }) groups!: DoNotPairGroup[];
|
||||||
|
@Prop({ required: true }) availableMembers!: MemberInfo[];
|
||||||
|
@Prop({ default: false }) disabled!: boolean;
|
||||||
|
|
||||||
|
addingToGroupId = "";
|
||||||
|
|
||||||
|
colorSet(colorIndex: number): (typeof GROUP_COLORS)[0] {
|
||||||
|
return GROUP_COLORS[colorIndex % GROUP_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
getMemberName(did: string): string {
|
||||||
|
const member = this.availableMembers.find((m) => m.did === did);
|
||||||
|
return member?.name || did.substring(0, 16) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
availableMembersForGroup(group: DoNotPairGroup): MemberInfo[] {
|
||||||
|
const allAssignedDids = new Set(this.groups.flatMap((g) => g.memberDids));
|
||||||
|
return this.availableMembers
|
||||||
|
.filter(
|
||||||
|
(m) => !allAssignedDids.has(m.did) || group.memberDids.includes(m.did),
|
||||||
|
)
|
||||||
|
.filter((m) => !group.memberDids.includes(m.did));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Emit("update")
|
||||||
|
emitUpdate(): DoNotPairGroup[] {
|
||||||
|
return [...this.groups];
|
||||||
|
}
|
||||||
|
|
||||||
|
addGroup(): void {
|
||||||
|
const newGroup: DoNotPairGroup = {
|
||||||
|
id: Date.now().toString(36) + Math.random().toString(36).substring(2, 6),
|
||||||
|
name: "",
|
||||||
|
colorIndex: this.groups.length % GROUP_COLORS.length,
|
||||||
|
memberDids: [],
|
||||||
|
};
|
||||||
|
this.groups.push(newGroup);
|
||||||
|
this.addingToGroupId = newGroup.id;
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeGroup(groupId: string): void {
|
||||||
|
const idx = this.groups.findIndex((g) => g.id === groupId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
this.groups.splice(idx, 1);
|
||||||
|
if (this.addingToGroupId === groupId) {
|
||||||
|
this.addingToGroupId = "";
|
||||||
|
}
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGroupName(groupId: string, name: string): void {
|
||||||
|
const group = this.groups.find((g) => g.id === groupId);
|
||||||
|
if (group) {
|
||||||
|
group.name = name;
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addMemberToGroup(groupId: string, did: string): void {
|
||||||
|
const group = this.groups.find((g) => g.id === groupId);
|
||||||
|
if (group && !group.memberDids.includes(did)) {
|
||||||
|
group.memberDids.push(did);
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMemberFromGroup(groupId: string, did: string): void {
|
||||||
|
const group = this.groups.find((g) => g.id === groupId);
|
||||||
|
if (group) {
|
||||||
|
group.memberDids = group.memberDids.filter((d) => d !== did);
|
||||||
|
this.emitUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -36,6 +36,15 @@
|
|||||||
<font-awesome icon="circle-minus" class="text-rose-500 text-sm" />
|
<font-awesome icon="circle-minus" class="text-rose-500 text-sm" />
|
||||||
to add/remove them to/from the meeting.
|
to add/remove them to/from the meeting.
|
||||||
</li>
|
</li>
|
||||||
|
<li
|
||||||
|
v-if="
|
||||||
|
membersToShow().length > 0 && showOrganizerTools && isOrganizer
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Click
|
||||||
|
<font-awesome icon="ban" class="text-amber-500 text-sm" />
|
||||||
|
to exclude someone from matching.
|
||||||
|
</li>
|
||||||
<li
|
<li
|
||||||
v-if="
|
v-if="
|
||||||
membersToShow().length > 0 && getNonContactMembers().length > 0
|
membersToShow().length > 0 && getNonContactMembers().length > 0
|
||||||
@@ -82,6 +91,8 @@
|
|||||||
'bg-blue-50 border-t border-blue-300 -mt-[1px]':
|
'bg-blue-50 border-t border-blue-300 -mt-[1px]':
|
||||||
!member.member.admitted &&
|
!member.member.admitted &&
|
||||||
(isOrganizer || member.did === activeDid),
|
(isOrganizer || member.did === activeDid),
|
||||||
|
'bg-amber-50 opacity-60':
|
||||||
|
member.member.admitted && excludedDids.includes(member.did),
|
||||||
},
|
},
|
||||||
{ 'border-slate-300': member.member.admitted },
|
{ 'border-slate-300': member.member.admitted },
|
||||||
]"
|
]"
|
||||||
@@ -95,6 +106,9 @@
|
|||||||
'text-slate-500':
|
'text-slate-500':
|
||||||
!member.member.admitted &&
|
!member.member.admitted &&
|
||||||
(isOrganizer || member.did === activeDid),
|
(isOrganizer || member.did === activeDid),
|
||||||
|
'line-through text-slate-400':
|
||||||
|
member.member.admitted &&
|
||||||
|
excludedDids.includes(member.did),
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
@@ -168,8 +182,31 @@
|
|||||||
v-if="
|
v-if="
|
||||||
showOrganizerTools && isOrganizer && member.did !== activeDid
|
showOrganizerTools && isOrganizer && member.did !== activeDid
|
||||||
"
|
"
|
||||||
class="flex items-center gap-1.5"
|
class="flex items-center gap-6"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
v-if="member.member.admitted"
|
||||||
|
:class="[
|
||||||
|
'btn-exclusion-toggle',
|
||||||
|
exclusionLocked
|
||||||
|
? excludedDids.includes(member.did)
|
||||||
|
? 'text-amber-400 opacity-50'
|
||||||
|
: 'text-slate-300 opacity-50'
|
||||||
|
: excludedDids.includes(member.did)
|
||||||
|
? 'text-amber-600'
|
||||||
|
: 'text-slate-500',
|
||||||
|
]"
|
||||||
|
:title="
|
||||||
|
exclusionLocked
|
||||||
|
? 'Erase matches to change exclusions'
|
||||||
|
: excludedDids.includes(member.did)
|
||||||
|
? 'Include in matching'
|
||||||
|
: 'Exclude from matching'
|
||||||
|
"
|
||||||
|
@click="handleExclusionClick(member.did)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="ban" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
:class="
|
:class="
|
||||||
member.member.admitted
|
member.member.admitted
|
||||||
@@ -290,6 +327,8 @@ export default class MeetingMembersList extends Vue {
|
|||||||
@Prop({ required: true }) password!: string;
|
@Prop({ required: true }) password!: string;
|
||||||
@Prop({ default: false }) showOrganizerTools!: boolean;
|
@Prop({ default: false }) showOrganizerTools!: boolean;
|
||||||
@Prop({ default: null }) matchPairs!: MatchPair[] | null;
|
@Prop({ default: null }) matchPairs!: MatchPair[] | null;
|
||||||
|
@Prop({ default: () => [] }) excludedDids!: string[];
|
||||||
|
@Prop({ default: false }) exclusionLocked!: boolean;
|
||||||
|
|
||||||
// Emit methods using @Emit decorator
|
// Emit methods using @Emit decorator
|
||||||
@Emit("error")
|
@Emit("error")
|
||||||
@@ -297,6 +336,16 @@ export default class MeetingMembersList extends Vue {
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Emit("toggle-exclusion")
|
||||||
|
emitToggleExclusion(did: string) {
|
||||||
|
return did;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Emit("members-loaded")
|
||||||
|
emitMembersLoaded() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
contacts: Array<Contact> = [];
|
contacts: Array<Contact> = [];
|
||||||
decryptedMembers: DecryptedMember[] = [];
|
decryptedMembers: DecryptedMember[] = [];
|
||||||
firstName = "";
|
firstName = "";
|
||||||
@@ -358,6 +407,7 @@ export default class MeetingMembersList extends Vue {
|
|||||||
this.emitError(serverMessageForUser(error) || "Failed to fetch members.");
|
this.emitError(serverMessageForUser(error) || "Failed to fetch members.");
|
||||||
} finally {
|
} finally {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
|
this.emitMembersLoaded();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,7 +549,7 @@ export default class MeetingMembersList extends Vue {
|
|||||||
|
|
||||||
informAboutAdmission() {
|
informAboutAdmission() {
|
||||||
this.notify.info(
|
this.notify.info(
|
||||||
"This is to register people in the app and to admit them to the meeting. A green (+) symbol means they are not yet admitted and you can register and admit them. A red (-) symbol means you can remove them, but they will stay registered.",
|
"Click the 'ban' button to exclude from matching. The '+/-' buttons are for admissions: A blue (+) symbol means they are not yet admitted and you can register and admit them. A red (-) symbol means you can remove them, but they will stay registered.",
|
||||||
TIMEOUTS.VERY_LONG,
|
TIMEOUTS.VERY_LONG,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -746,6 +796,23 @@ export default class MeetingMembersList extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAdmittedMembers(): Array<{ did: string; name: string }> {
|
||||||
|
return this.decryptedMembers
|
||||||
|
.filter((m) => m.member.admitted)
|
||||||
|
.map((m) => ({ did: m.did, name: m.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleExclusionClick(did: string): void {
|
||||||
|
if (this.exclusionLocked) {
|
||||||
|
this.notify.warning(
|
||||||
|
"Erase the current matches before changing exclusions.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emitToggleExclusion(did);
|
||||||
|
}
|
||||||
|
|
||||||
startAutoRefresh() {
|
startAutoRefresh() {
|
||||||
this.stopAutoRefresh();
|
this.stopAutoRefresh();
|
||||||
this.lastRefreshTime = Date.now();
|
this.lastRefreshTime = Date.now();
|
||||||
@@ -811,6 +878,11 @@ export default class MeetingMembersList extends Vue {
|
|||||||
transition-colors;
|
transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-exclusion-toggle {
|
||||||
|
/* stylelint-disable-next-line at-rule-no-unknown */
|
||||||
|
@apply text-lg transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-admission-remove {
|
.btn-admission-remove {
|
||||||
/* stylelint-disable-next-line at-rule-no-unknown */
|
/* stylelint-disable-next-line at-rule-no-unknown */
|
||||||
@apply text-lg text-rose-500 hover:text-rose-700
|
@apply text-lg text-rose-500 hover:text-rose-700
|
||||||
|
|||||||
@@ -34,3 +34,16 @@ export interface MemberData {
|
|||||||
memberId: string;
|
memberId: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DoNotPairGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
colorIndex: number;
|
||||||
|
memberDids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeetingExclusionState {
|
||||||
|
meetingGroupId: string;
|
||||||
|
excludedDids: string[];
|
||||||
|
doNotPairGroups: DoNotPairGroup[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -312,8 +312,12 @@
|
|||||||
:match-pairs="matchPairs"
|
:match-pairs="matchPairs"
|
||||||
:password="currentMeeting.password || ''"
|
:password="currentMeeting.password || ''"
|
||||||
:show-organizer-tools="true"
|
:show-organizer-tools="true"
|
||||||
|
:excluded-dids="excludedDids"
|
||||||
|
:exclusion-locked="hasActiveMatches"
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
@error="handleMembersError"
|
@error="handleMembersError"
|
||||||
|
@toggle-exclusion="toggleExclusion"
|
||||||
|
@members-loaded="refreshAdmittedMembers"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -329,7 +333,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="px-3 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
class="px-3 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
:disabled="isPostingMatch"
|
:disabled="isPostingMatch"
|
||||||
@click="postNewMatchesThenRefresh()"
|
@click="promptPreMatchConfirm()"
|
||||||
>
|
>
|
||||||
<font-awesome
|
<font-awesome
|
||||||
v-if="isPostingMatch"
|
v-if="isPostingMatch"
|
||||||
@@ -340,9 +344,13 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="px-3 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
:class="[
|
||||||
:disabled="isPostingMatch || !matchPairs?.length"
|
'px-3 py-2 text-sm rounded',
|
||||||
@click="clearMatchesThenRefresh()"
|
isPostingMatch || !matchPairs?.length
|
||||||
|
? 'bg-red-400 text-white opacity-50 cursor-not-allowed'
|
||||||
|
: 'bg-red-600 text-white hover:bg-red-700',
|
||||||
|
]"
|
||||||
|
@click="handleEraseClick()"
|
||||||
>
|
>
|
||||||
<font-awesome
|
<font-awesome
|
||||||
v-if="isPostingMatch"
|
v-if="isPostingMatch"
|
||||||
@@ -352,6 +360,25 @@
|
|||||||
Erase to Start Over
|
Erase to Start Over
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Do Not Pair Groups -->
|
||||||
|
<div class="mb-4 pt-2 border-t border-gray-100">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Do Not Pair Together
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-500 mb-2">
|
||||||
|
People in the same group will not be matched with each other.
|
||||||
|
</p>
|
||||||
|
<p v-if="hasActiveMatches" class="text-xs text-amber-600 mb-2">
|
||||||
|
Erase matches to change restrictions.
|
||||||
|
</p>
|
||||||
|
<MeetingExclusionGroups
|
||||||
|
:groups="doNotPairGroups"
|
||||||
|
:available-members="admittedMembers"
|
||||||
|
:disabled="hasActiveMatches"
|
||||||
|
@update="handleDoNotPairGroupsUpdate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="isLoadingMatches" class="text-sm text-gray-500 py-2">
|
<div v-if="isLoadingMatches" class="text-sm text-gray-500 py-2">
|
||||||
<font-awesome icon="spinner" class="fa-spin fa-fw" />
|
<font-awesome icon="spinner" class="fa-spin fa-fw" />
|
||||||
Loading matches…
|
Loading matches…
|
||||||
@@ -395,9 +422,123 @@
|
|||||||
v-else-if="matchPairs && matchPairs.length === 0"
|
v-else-if="matchPairs && matchPairs.length === 0"
|
||||||
class="text-sm text-gray-500 py-2"
|
class="text-sm text-gray-500 py-2"
|
||||||
>
|
>
|
||||||
No matches yet. Click “Get matches” to pair members by profile
|
No matches yet. Click "Make New Matches" to pair members by profile
|
||||||
similarity.
|
similarity.
|
||||||
</p>
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="unmatchedMembers.length > 0"
|
||||||
|
class="mt-3 p-3 rounded border border-amber-200 bg-amber-50 text-sm"
|
||||||
|
>
|
||||||
|
<h4 class="font-medium text-amber-800 mb-1">
|
||||||
|
Not Paired ({{ unmatchedMembers.length }})
|
||||||
|
</h4>
|
||||||
|
<ul class="space-y-0.5">
|
||||||
|
<li
|
||||||
|
v-for="m in unmatchedMembers"
|
||||||
|
:key="m.did"
|
||||||
|
class="text-amber-700"
|
||||||
|
>
|
||||||
|
{{ m.name }} —
|
||||||
|
<span class="text-amber-600 italic">{{ m.reason }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pre-Match Confirmation Dialog -->
|
||||||
|
<div
|
||||||
|
v-if="showPreMatchConfirm"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg p-6 max-w-md w-full max-h-[80vh] overflow-y-auto"
|
||||||
|
>
|
||||||
|
<h3 class="text-lg font-medium mb-4">Confirm Matching</h3>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Will be matched ({{ includedMembers.length }})
|
||||||
|
</h4>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="member in includedMembers"
|
||||||
|
:key="member.did"
|
||||||
|
class="inline-block px-2 py-0.5 bg-green-100 text-green-800 rounded-full text-xs"
|
||||||
|
>
|
||||||
|
{{ member.name }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="includedMembers.length === 0"
|
||||||
|
class="text-xs text-gray-400 italic"
|
||||||
|
>
|
||||||
|
No participants
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="excludedDids.length > 0" class="mb-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Excluded ({{ excludedDids.length }})
|
||||||
|
</h4>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="did in excludedDids"
|
||||||
|
:key="did"
|
||||||
|
class="inline-block px-2 py-0.5 bg-amber-100 text-amber-800 rounded-full text-xs line-through"
|
||||||
|
>
|
||||||
|
{{ getMemberNameByDid(did) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="doNotPairGroups.some((g) => g.memberDids.length >= 2)"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Do Not Pair Groups
|
||||||
|
</h4>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="group in doNotPairGroups.filter(
|
||||||
|
(g) => g.memberDids.length >= 2,
|
||||||
|
)"
|
||||||
|
:key="group.id"
|
||||||
|
class="text-xs text-gray-600"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{
|
||||||
|
group.name || "Unnamed group"
|
||||||
|
}}</span
|
||||||
|
>:
|
||||||
|
{{
|
||||||
|
group.memberDids.map((d) => getMemberNameByDid(d)).join(", ")
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="previousMatchedPairs.length > 0"
|
||||||
|
class="mb-4 text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
{{ previousMatchedPairs.length }} previous pair(s) will be avoided.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between space-x-4 pt-2">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 bg-slate-500 text-white rounded hover:bg-slate-700"
|
||||||
|
@click="cancelPreMatchConfirm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||||
|
@click="confirmAndMatch"
|
||||||
|
>
|
||||||
|
Run Matching
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -433,6 +574,7 @@ import QuickNav from "../components/QuickNav.vue";
|
|||||||
import TopMessage from "../components/TopMessage.vue";
|
import TopMessage from "../components/TopMessage.vue";
|
||||||
import MeetingMembersList from "../components/MeetingMembersList.vue";
|
import MeetingMembersList from "../components/MeetingMembersList.vue";
|
||||||
import MeetingMemberMatch from "../components/MeetingMemberMatch.vue";
|
import MeetingMemberMatch from "../components/MeetingMemberMatch.vue";
|
||||||
|
import MeetingExclusionGroups from "../components/MeetingExclusionGroups.vue";
|
||||||
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
|
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
|
||||||
import ProjectIcon from "../components/ProjectIcon.vue";
|
import ProjectIcon from "../components/ProjectIcon.vue";
|
||||||
import {
|
import {
|
||||||
@@ -455,7 +597,12 @@ import {
|
|||||||
} from "@/constants/notifications";
|
} from "@/constants/notifications";
|
||||||
import { PlanData } from "../interfaces/records";
|
import { PlanData } from "../interfaces/records";
|
||||||
import { Contact } from "../db/tables/contacts";
|
import { Contact } from "../db/tables/contacts";
|
||||||
import { AxiosErrorResponse, MatchPair } from "@/interfaces";
|
import {
|
||||||
|
AxiosErrorResponse,
|
||||||
|
DoNotPairGroup,
|
||||||
|
MatchPair,
|
||||||
|
MeetingExclusionState,
|
||||||
|
} from "@/interfaces";
|
||||||
interface ServerMeeting {
|
interface ServerMeeting {
|
||||||
groupId: number; // from the server
|
groupId: number; // from the server
|
||||||
name: string; // to & from the server
|
name: string; // to & from the server
|
||||||
@@ -479,6 +626,7 @@ interface MeetingSetupInputs {
|
|||||||
TopMessage,
|
TopMessage,
|
||||||
MeetingMembersList,
|
MeetingMembersList,
|
||||||
MeetingMemberMatch,
|
MeetingMemberMatch,
|
||||||
|
MeetingExclusionGroups,
|
||||||
MeetingProjectDialog,
|
MeetingProjectDialog,
|
||||||
ProjectIcon,
|
ProjectIcon,
|
||||||
},
|
},
|
||||||
@@ -512,6 +660,10 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
/** Accumulated pair DIDs from every match run; sent as previousPairDids on future posts. */
|
/** Accumulated pair DIDs from every match run; sent as previousPairDids on future posts. */
|
||||||
previousMatchedPairs: [string, string][] = [];
|
previousMatchedPairs: [string, string][] = [];
|
||||||
|
|
||||||
|
excludedDids: string[] = [];
|
||||||
|
doNotPairGroups: DoNotPairGroup[] = [];
|
||||||
|
showPreMatchConfirm = false;
|
||||||
|
|
||||||
selectedProjectData: PlanData | null = null;
|
selectedProjectData: PlanData | null = null;
|
||||||
showDeleteConfirm = false;
|
showDeleteConfirm = false;
|
||||||
|
|
||||||
@@ -521,6 +673,86 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
return this.formatDateForInput(now);
|
return this.formatDateForInput(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly EXCLUSION_STORAGE_KEY = "meeting-exclusion-state";
|
||||||
|
|
||||||
|
get meetingGroupIdStr(): string {
|
||||||
|
return this.currentMeeting?.groupId?.toString() || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasActiveMatches(): boolean {
|
||||||
|
return Array.isArray(this.matchPairs) && this.matchPairs.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
admittedMembers: Array<{ did: string; name: string }> = [];
|
||||||
|
|
||||||
|
get includedMembers(): Array<{ did: string; name: string }> {
|
||||||
|
return this.admittedMembers.filter(
|
||||||
|
(m) => !this.excludedDids.includes(m.did),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get unmatchedMembers(): Array<{ did: string; name: string; reason: string }> {
|
||||||
|
if (!this.matchPairs?.length || !this.admittedMembers.length) return [];
|
||||||
|
const matchedDids = new Set<string>();
|
||||||
|
for (const pair of this.matchPairs) {
|
||||||
|
for (const p of pair.participants) {
|
||||||
|
matchedDids.add(p.issuerDid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.admittedMembers
|
||||||
|
.filter((m) => !matchedDids.has(m.did))
|
||||||
|
.map((m) => {
|
||||||
|
let reason = "not paired (odd number of participants)";
|
||||||
|
if (this.excludedDids.includes(m.did)) {
|
||||||
|
reason = "individually excluded";
|
||||||
|
} else {
|
||||||
|
const inGroup = this.doNotPairGroups.find((g) =>
|
||||||
|
g.memberDids.includes(m.did),
|
||||||
|
);
|
||||||
|
if (inGroup) {
|
||||||
|
reason = `in do-not-pair group "${inGroup.name || "Unnamed"}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { did: m.did, name: m.name, reason };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get excludedPairDids(): [string, string][] {
|
||||||
|
const pairs: [string, string][] = [];
|
||||||
|
for (const group of this.doNotPairGroups) {
|
||||||
|
for (let i = 0; i < group.memberDids.length; i++) {
|
||||||
|
for (let j = i + 1; j < group.memberDids.length; j++) {
|
||||||
|
pairs.push([group.memberDids[i], group.memberDids[j]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for selected project
|
||||||
|
* Returns the separately stored selected project data
|
||||||
|
*/
|
||||||
|
get selectedProject(): PlanData | null {
|
||||||
|
return this.selectedProjectData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for selected project issuer display name
|
||||||
|
* Uses didInfo to format the issuer name similar to ProjectCard
|
||||||
|
*/
|
||||||
|
get selectedProjectIssuerName(): string {
|
||||||
|
if (!this.selectedProject) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return didInfo(
|
||||||
|
this.selectedProject.issuerDid,
|
||||||
|
this.activeDid,
|
||||||
|
this.allMyDids,
|
||||||
|
this.allContacts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async created() {
|
async created() {
|
||||||
this.notify = createNotifyHelpers(
|
this.notify = createNotifyHelpers(
|
||||||
this.$notify as Parameters<typeof createNotifyHelpers>[0],
|
this.$notify as Parameters<typeof createNotifyHelpers>[0],
|
||||||
@@ -547,6 +779,7 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
await this.fetchMatchPairs();
|
await this.fetchMatchPairs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.loadExclusionState();
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1051,14 +1284,51 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
promptPreMatchConfirm(): void {
|
||||||
|
this.refreshAdmittedMembers();
|
||||||
|
this.showPreMatchConfirm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshAdmittedMembers(): void {
|
||||||
|
const membersList = this.$refs.membersList as MeetingMembersList;
|
||||||
|
this.admittedMembers = membersList?.getAdmittedMembers() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPreMatchConfirm(): void {
|
||||||
|
this.showPreMatchConfirm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMemberNameByDid(did: string): string {
|
||||||
|
const member = this.admittedMembers.find((m) => m.did === did);
|
||||||
|
return member?.name || did.substring(0, 16) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmAndMatch(): Promise<void> {
|
||||||
|
this.showPreMatchConfirm = false;
|
||||||
|
await this.postNewMatchesThenRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
async postNewMatchesThenRefresh(): Promise<void> {
|
async postNewMatchesThenRefresh(): Promise<void> {
|
||||||
this.isPostingMatch = true;
|
this.isPostingMatch = true;
|
||||||
try {
|
try {
|
||||||
const previousPairDids = this.previousMatchedPairs.length
|
const body: {
|
||||||
? this.previousMatchedPairs
|
excludedDids?: string[];
|
||||||
: undefined;
|
excludedPairDids?: [string, string][];
|
||||||
|
previousPairDids?: [string, string][];
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (this.excludedDids.length > 0) {
|
||||||
|
body.excludedDids = this.excludedDids;
|
||||||
|
}
|
||||||
|
if (this.excludedPairDids.length > 0) {
|
||||||
|
body.excludedPairDids = this.excludedPairDids;
|
||||||
|
}
|
||||||
|
if (this.previousMatchedPairs.length > 0) {
|
||||||
|
body.previousPairDids = this.previousMatchedPairs;
|
||||||
|
}
|
||||||
|
|
||||||
const pairs = await this.postMatch(
|
const pairs = await this.postMatch(
|
||||||
previousPairDids ? { previousPairDids } : undefined,
|
Object.keys(body).length > 0 ? body : undefined,
|
||||||
);
|
);
|
||||||
if (Array.isArray(pairs) && pairs.length > 0) {
|
if (Array.isArray(pairs) && pairs.length > 0) {
|
||||||
const tempMatchPairs: MatchPair[] = [];
|
const tempMatchPairs: MatchPair[] = [];
|
||||||
@@ -1081,6 +1351,24 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleEraseClick(): void {
|
||||||
|
if (this.isPostingMatch) {
|
||||||
|
this.notify.warning(
|
||||||
|
"Matching is currently in progress. Please wait for it to finish.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.matchPairs?.length) {
|
||||||
|
this.notify.warning(
|
||||||
|
"There are no matches to erase. Run matching first to create pairs.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clearMatchesThenRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
async clearMatchesThenRefresh(): Promise<void> {
|
async clearMatchesThenRefresh(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const headers = await getHeaders(this.activeDid);
|
const headers = await getHeaders(this.activeDid);
|
||||||
@@ -1120,30 +1408,52 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
loadExclusionState(): void {
|
||||||
* Computed property for selected project
|
if (!this.meetingGroupIdStr) return;
|
||||||
* Returns the separately stored selected project data
|
try {
|
||||||
*/
|
const raw = localStorage.getItem(
|
||||||
get selectedProject(): PlanData | null {
|
OnboardMeetingView.EXCLUSION_STORAGE_KEY,
|
||||||
return this.selectedProjectData;
|
);
|
||||||
|
if (!raw) return;
|
||||||
|
const state: MeetingExclusionState = JSON.parse(raw);
|
||||||
|
if (state.meetingGroupId !== this.meetingGroupIdStr) {
|
||||||
|
localStorage.removeItem(OnboardMeetingView.EXCLUSION_STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.excludedDids = state.excludedDids || [];
|
||||||
|
this.doNotPairGroups = state.doNotPairGroups || [];
|
||||||
|
} catch {
|
||||||
|
this.excludedDids = [];
|
||||||
|
this.doNotPairGroups = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
saveExclusionState(): void {
|
||||||
* Computed property for selected project issuer display name
|
if (!this.meetingGroupIdStr) return;
|
||||||
* Uses didInfo to format the issuer name similar to ProjectCard
|
const state: MeetingExclusionState = {
|
||||||
*/
|
meetingGroupId: this.meetingGroupIdStr,
|
||||||
get selectedProjectIssuerName(): string {
|
excludedDids: this.excludedDids,
|
||||||
if (!this.selectedProject) {
|
doNotPairGroups: this.doNotPairGroups,
|
||||||
return "";
|
};
|
||||||
}
|
localStorage.setItem(
|
||||||
return didInfo(
|
OnboardMeetingView.EXCLUSION_STORAGE_KEY,
|
||||||
this.selectedProject.issuerDid,
|
JSON.stringify(state),
|
||||||
this.activeDid,
|
|
||||||
this.allMyDids,
|
|
||||||
this.allContacts,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleExclusion(did: string): void {
|
||||||
|
if (this.excludedDids.includes(did)) {
|
||||||
|
this.excludedDids = this.excludedDids.filter((d) => d !== did);
|
||||||
|
} else {
|
||||||
|
this.excludedDids = [...this.excludedDids, did];
|
||||||
|
}
|
||||||
|
this.saveExclusionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDoNotPairGroupsUpdate(): void {
|
||||||
|
this.saveExclusionState();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the project link selection dialog
|
* Open the project link selection dialog
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user