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" />
|
||||
to add/remove them to/from the meeting.
|
||||
</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
|
||||
v-if="
|
||||
membersToShow().length > 0 && getNonContactMembers().length > 0
|
||||
@@ -82,6 +91,8 @@
|
||||
'bg-blue-50 border-t border-blue-300 -mt-[1px]':
|
||||
!member.member.admitted &&
|
||||
(isOrganizer || member.did === activeDid),
|
||||
'bg-amber-50 opacity-60':
|
||||
member.member.admitted && excludedDids.includes(member.did),
|
||||
},
|
||||
{ 'border-slate-300': member.member.admitted },
|
||||
]"
|
||||
@@ -95,6 +106,9 @@
|
||||
'text-slate-500':
|
||||
!member.member.admitted &&
|
||||
(isOrganizer || member.did === activeDid),
|
||||
'line-through text-slate-400':
|
||||
member.member.admitted &&
|
||||
excludedDids.includes(member.did),
|
||||
},
|
||||
]"
|
||||
>
|
||||
@@ -168,8 +182,31 @@
|
||||
v-if="
|
||||
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
|
||||
:class="
|
||||
member.member.admitted
|
||||
@@ -290,6 +327,8 @@ export default class MeetingMembersList extends Vue {
|
||||
@Prop({ required: true }) password!: string;
|
||||
@Prop({ default: false }) showOrganizerTools!: boolean;
|
||||
@Prop({ default: null }) matchPairs!: MatchPair[] | null;
|
||||
@Prop({ default: () => [] }) excludedDids!: string[];
|
||||
@Prop({ default: false }) exclusionLocked!: boolean;
|
||||
|
||||
// Emit methods using @Emit decorator
|
||||
@Emit("error")
|
||||
@@ -297,6 +336,16 @@ export default class MeetingMembersList extends Vue {
|
||||
return message;
|
||||
}
|
||||
|
||||
@Emit("toggle-exclusion")
|
||||
emitToggleExclusion(did: string) {
|
||||
return did;
|
||||
}
|
||||
|
||||
@Emit("members-loaded")
|
||||
emitMembersLoaded() {
|
||||
return;
|
||||
}
|
||||
|
||||
contacts: Array<Contact> = [];
|
||||
decryptedMembers: DecryptedMember[] = [];
|
||||
firstName = "";
|
||||
@@ -358,6 +407,7 @@ export default class MeetingMembersList extends Vue {
|
||||
this.emitError(serverMessageForUser(error) || "Failed to fetch members.");
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.emitMembersLoaded();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,7 +549,7 @@ export default class MeetingMembersList extends Vue {
|
||||
|
||||
informAboutAdmission() {
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
this.stopAutoRefresh();
|
||||
this.lastRefreshTime = Date.now();
|
||||
@@ -811,6 +878,11 @@ export default class MeetingMembersList extends Vue {
|
||||
transition-colors;
|
||||
}
|
||||
|
||||
.btn-exclusion-toggle {
|
||||
/* stylelint-disable-next-line at-rule-no-unknown */
|
||||
@apply text-lg transition-colors;
|
||||
}
|
||||
|
||||
.btn-admission-remove {
|
||||
/* stylelint-disable-next-line at-rule-no-unknown */
|
||||
@apply text-lg text-rose-500 hover:text-rose-700
|
||||
|
||||
@@ -34,3 +34,16 @@ export interface MemberData {
|
||||
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"
|
||||
:password="currentMeeting.password || ''"
|
||||
:show-organizer-tools="true"
|
||||
:excluded-dids="excludedDids"
|
||||
:exclusion-locked="hasActiveMatches"
|
||||
class="mt-4"
|
||||
@error="handleMembersError"
|
||||
@toggle-exclusion="toggleExclusion"
|
||||
@members-loaded="refreshAdmittedMembers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -329,7 +333,7 @@
|
||||
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"
|
||||
:disabled="isPostingMatch"
|
||||
@click="postNewMatchesThenRefresh()"
|
||||
@click="promptPreMatchConfirm()"
|
||||
>
|
||||
<font-awesome
|
||||
v-if="isPostingMatch"
|
||||
@@ -340,9 +344,13 @@
|
||||
</button>
|
||||
<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"
|
||||
:disabled="isPostingMatch || !matchPairs?.length"
|
||||
@click="clearMatchesThenRefresh()"
|
||||
:class="[
|
||||
'px-3 py-2 text-sm rounded',
|
||||
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
|
||||
v-if="isPostingMatch"
|
||||
@@ -352,6 +360,25 @@
|
||||
Erase to Start Over
|
||||
</button>
|
||||
</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">
|
||||
<font-awesome icon="spinner" class="fa-spin fa-fw" />
|
||||
Loading matches…
|
||||
@@ -395,9 +422,123 @@
|
||||
v-else-if="matchPairs && matchPairs.length === 0"
|
||||
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.
|
||||
</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>
|
||||
|
||||
@@ -433,6 +574,7 @@ import QuickNav from "../components/QuickNav.vue";
|
||||
import TopMessage from "../components/TopMessage.vue";
|
||||
import MeetingMembersList from "../components/MeetingMembersList.vue";
|
||||
import MeetingMemberMatch from "../components/MeetingMemberMatch.vue";
|
||||
import MeetingExclusionGroups from "../components/MeetingExclusionGroups.vue";
|
||||
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
|
||||
import ProjectIcon from "../components/ProjectIcon.vue";
|
||||
import {
|
||||
@@ -455,7 +597,12 @@ import {
|
||||
} from "@/constants/notifications";
|
||||
import { PlanData } from "../interfaces/records";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { AxiosErrorResponse, MatchPair } from "@/interfaces";
|
||||
import {
|
||||
AxiosErrorResponse,
|
||||
DoNotPairGroup,
|
||||
MatchPair,
|
||||
MeetingExclusionState,
|
||||
} from "@/interfaces";
|
||||
interface ServerMeeting {
|
||||
groupId: number; // from the server
|
||||
name: string; // to & from the server
|
||||
@@ -479,6 +626,7 @@ interface MeetingSetupInputs {
|
||||
TopMessage,
|
||||
MeetingMembersList,
|
||||
MeetingMemberMatch,
|
||||
MeetingExclusionGroups,
|
||||
MeetingProjectDialog,
|
||||
ProjectIcon,
|
||||
},
|
||||
@@ -512,6 +660,10 @@ export default class OnboardMeetingView extends Vue {
|
||||
/** Accumulated pair DIDs from every match run; sent as previousPairDids on future posts. */
|
||||
previousMatchedPairs: [string, string][] = [];
|
||||
|
||||
excludedDids: string[] = [];
|
||||
doNotPairGroups: DoNotPairGroup[] = [];
|
||||
showPreMatchConfirm = false;
|
||||
|
||||
selectedProjectData: PlanData | null = null;
|
||||
showDeleteConfirm = false;
|
||||
|
||||
@@ -521,6 +673,86 @@ export default class OnboardMeetingView extends Vue {
|
||||
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() {
|
||||
this.notify = createNotifyHelpers(
|
||||
this.$notify as Parameters<typeof createNotifyHelpers>[0],
|
||||
@@ -547,6 +779,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
await this.fetchMatchPairs();
|
||||
}
|
||||
|
||||
this.loadExclusionState();
|
||||
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> {
|
||||
this.isPostingMatch = true;
|
||||
try {
|
||||
const previousPairDids = this.previousMatchedPairs.length
|
||||
? this.previousMatchedPairs
|
||||
: undefined;
|
||||
const body: {
|
||||
excludedDids?: string[];
|
||||
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(
|
||||
previousPairDids ? { previousPairDids } : undefined,
|
||||
Object.keys(body).length > 0 ? body : undefined,
|
||||
);
|
||||
if (Array.isArray(pairs) && pairs.length > 0) {
|
||||
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> {
|
||||
try {
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
@@ -1120,30 +1408,52 @@ export default class OnboardMeetingView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed property for selected project
|
||||
* Returns the separately stored selected project data
|
||||
*/
|
||||
get selectedProject(): PlanData | null {
|
||||
return this.selectedProjectData;
|
||||
loadExclusionState(): void {
|
||||
if (!this.meetingGroupIdStr) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(
|
||||
OnboardMeetingView.EXCLUSION_STORAGE_KEY,
|
||||
);
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
saveExclusionState(): void {
|
||||
if (!this.meetingGroupIdStr) return;
|
||||
const state: MeetingExclusionState = {
|
||||
meetingGroupId: this.meetingGroupIdStr,
|
||||
excludedDids: this.excludedDids,
|
||||
doNotPairGroups: this.doNotPairGroups,
|
||||
};
|
||||
localStorage.setItem(
|
||||
OnboardMeetingView.EXCLUSION_STORAGE_KEY,
|
||||
JSON.stringify(state),
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user