Skip to content

Commit a4b5db1

Browse files
authored
feat(meetings,votes): guest mgmt, vote api and dashboard fixes (#239)
* feat(meetings,votes): guest mgmt, vote api and dashboard fixes - Meeting guests management with committee sync and UI enhancements - Vote creation API with create and enable endpoints - Meeting RSVP toggle for organizers who are also invited - Dashboard my meetings with recurring display fixes - Fix invitation status check for all users including organizers LFXV2-1066, LFXV2-1067, LFXV2-1068, LFXV2-1069, LFXV2-1070 Signed-off-by: Asitha de Silva <asithade@gmail.com> * fix(meetings,votes): address code review feedback - Sort occurrences by start_time before .find() for stable selection - Validate limit query param is positive integer - Use snake_case for committee_filters API contract LFXV2-1066, LFXV2-1067, LFXV2-1069 Signed-off-by: Asitha de Silva <asithade@gmail.com> --------- Signed-off-by: Asitha de Silva <asithade@gmail.com>
1 parent b0a6447 commit a4b5db1

File tree

24 files changed

+642
-315
lines changed

24 files changed

+642
-315
lines changed

apps/lfx-one/src/app/modules/dashboards/components/dashboard-meeting-card/dashboard-meeting-card.component.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@
1111
[severity]="meetingTypeInfo().severity"
1212
[icon]="meetingTypeInfo().icon"
1313
data-testid="dashboard-meeting-card-type-badge"></lfx-tag>
14+
@if (isRecurring()) {
15+
<lfx-tag
16+
[value]="meeting().recurrence | recurrenceSummary"
17+
severity="secondary"
18+
icon="fa-light fa-repeat"
19+
pTooltip="This is a recurring meeting"
20+
data-testid="dashboard-meeting-card-recurring-badge"></lfx-tag>
21+
}
1422
</div>
1523

1624
<!-- Meeting title with file icons -->

apps/lfx-one/src/app/modules/dashboards/components/dashboard-meeting-card/dashboard-meeting-card.component.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,15 @@ import {
1919
TagSeverity,
2020
} from '@lfx-one/shared';
2121
import { FileTypeIconPipe } from '@pipes/file-type-icon.pipe';
22+
import { RecurrenceSummaryPipe } from '@pipes/recurrence-summary.pipe';
2223
import { MeetingService } from '@services/meeting.service';
2324
import { UserService } from '@services/user.service';
2425
import { TooltipModule } from 'primeng/tooltip';
2526
import { catchError, combineLatest, map, of, switchMap } from 'rxjs';
2627

2728
@Component({
2829
selector: 'lfx-dashboard-meeting-card',
29-
imports: [NgClass, ButtonComponent, TagComponent, TooltipModule, ClipboardModule, FileTypeIconPipe],
30+
imports: [NgClass, ButtonComponent, TagComponent, TooltipModule, ClipboardModule, FileTypeIconPipe, RecurrenceSummaryPipe],
3031
templateUrl: './dashboard-meeting-card.component.html',
3132
})
3233
export class DashboardMeetingCardComponent {
@@ -54,6 +55,7 @@ export class DashboardMeetingCardComponent {
5455
public readonly hasAiSummary: Signal<boolean> = this.initHasAiSummary();
5556
public readonly meetingTitle: Signal<string> = this.initMeetingTitle();
5657
public readonly isLegacyMeeting: Signal<boolean> = this.initIsLegacyMeeting();
58+
public readonly isRecurring: Signal<boolean> = this.initIsRecurring();
5759
public readonly meetingDetailUrl: Signal<string> = this.initMeetingDetailUrl();
5860

5961
public constructor() {
@@ -237,6 +239,10 @@ export class DashboardMeetingCardComponent {
237239
return computed(() => this.meeting().version === 'v1');
238240
}
239241

242+
private initIsRecurring(): Signal<boolean> {
243+
return computed(() => !!this.meeting().recurrence);
244+
}
245+
240246
private initMeetingDetailUrl(): Signal<string> {
241247
return computed(() => {
242248
const meeting = this.meeting();

apps/lfx-one/src/app/modules/dashboards/components/my-meetings/my-meetings.component.ts

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -45,25 +45,29 @@ export class MyMeetingsComponent {
4545
const meetings: MeetingWithOccurrence[] = [];
4646

4747
for (const meeting of this.allMeetings()) {
48-
// Process occurrences if they exist
48+
// Process occurrences if they exist - find the FIRST active occurrence for today
4949
if (meeting.occurrences && meeting.occurrences.length > 0) {
50-
// Get only active (non-cancelled) occurrences
51-
const activeOccurrences = getActiveOccurrences(meeting.occurrences);
50+
// Get only active (non-cancelled) occurrences, sorted by start_time for stable selection
51+
const activeOccurrences = getActiveOccurrences(meeting.occurrences).sort(
52+
(a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
53+
);
5254

53-
for (const occurrence of activeOccurrences) {
55+
// Find the first occurrence that's happening today and hasn't ended
56+
const todayOccurrence = activeOccurrences.find((occurrence) => {
5457
const startTime = new Date(occurrence.start_time);
5558
const startTimeMs = startTime.getTime();
5659
const endTime = startTimeMs + occurrence.duration * 60 * 1000 + buffer;
60+
return startTime >= today && startTime < todayEnd && endTime >= currentTime;
61+
});
5762

58-
// Include if meeting is today and hasn't ended yet (including buffer)
59-
if (startTime >= today && startTime < todayEnd && endTime >= currentTime) {
60-
meetings.push({
61-
meeting,
62-
occurrence,
63-
sortTime: startTimeMs,
64-
trackId: `${meeting.uid}-${occurrence.occurrence_id}`,
65-
});
66-
}
63+
if (todayOccurrence) {
64+
const startTimeMs = new Date(todayOccurrence.start_time).getTime();
65+
meetings.push({
66+
meeting,
67+
occurrence: todayOccurrence,
68+
sortTime: startTimeMs,
69+
trackId: meeting.uid,
70+
});
6771
}
6872
} else {
6973
// Handle meetings without occurrences (single meetings)
@@ -103,24 +107,27 @@ export class MyMeetingsComponent {
103107
const meetings: MeetingWithOccurrence[] = [];
104108

105109
for (const meeting of this.allMeetings()) {
106-
// Process occurrences if they exist
110+
// Process occurrences if they exist - find the FIRST active occurrence after today
107111
if (meeting.occurrences && meeting.occurrences.length > 0) {
108-
// Get only active (non-cancelled) occurrences
109-
const activeOccurrences = getActiveOccurrences(meeting.occurrences);
112+
// Get only active (non-cancelled) occurrences, sorted by start_time for stable selection
113+
const activeOccurrences = getActiveOccurrences(meeting.occurrences).sort(
114+
(a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
115+
);
110116

111-
for (const occurrence of activeOccurrences) {
117+
// Find the first occurrence that's after today
118+
const upcomingOccurrence = activeOccurrences.find((occurrence) => {
112119
const startTime = new Date(occurrence.start_time);
113-
const startTimeMs = startTime.getTime();
120+
return startTime >= todayEnd;
121+
});
114122

115-
// Include if meeting is after today
116-
if (startTime >= todayEnd) {
117-
meetings.push({
118-
meeting,
119-
occurrence,
120-
sortTime: startTimeMs,
121-
trackId: `${meeting.uid}-${occurrence.occurrence_id}`,
122-
});
123-
}
123+
if (upcomingOccurrence) {
124+
const startTimeMs = new Date(upcomingOccurrence.start_time).getTime();
125+
meetings.push({
126+
meeting,
127+
occurrence: upcomingOccurrence,
128+
sortTime: startTimeMs,
129+
trackId: meeting.uid,
130+
});
124131
}
125132
} else {
126133
// Handle meetings without occurrences (single meetings)
@@ -164,7 +171,8 @@ export class MyMeetingsComponent {
164171
return of([]);
165172
}
166173

167-
return this.userService.getUserMeetings(project.uid).pipe(
174+
// Limit to 2 meetings for the dashboard display
175+
return this.userService.getUserMeetings(project.uid, 2).pipe(
168176
tap(() => this.loading.set(false)),
169177
catchError((error) => {
170178
console.error('Failed to load user meetings:', error);

apps/lfx-one/src/app/modules/meetings/components/meeting-card/meeting-card.component.html

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -265,16 +265,27 @@ <h3 class="text-base font-medium text-gray-900 leading-tight tracking-tight" dat
265265
<!-- People/Attendees Card Column -->
266266
<div class="flex flex-col">
267267
@if (meeting().organizer && !isLegacyMeeting()) {
268-
<!-- Show RSVP Details for organizers and past meetings -->
269-
<lfx-meeting-rsvp-details
270-
[meeting]="meeting()"
271-
[currentOccurrence]="currentOccurrence()"
272-
[pastMeeting]="pastMeeting()"
273-
[showAddButton]="!pastMeeting() && !isLegacyMeeting()"
274-
[additionalRegistrantsCount]="additionalRegistrantsCount()"
275-
[disabled]="isLegacyMeeting()"
276-
disabledMessage="RSVP functionality will soon be available for all upcoming LFX meetings visible to you">
277-
</lfx-meeting-rsvp-details>
268+
<!-- Organizer view with optional toggle for invited organizers -->
269+
@if (canToggleRsvpView() && showMyRsvp()) {
270+
<!-- Show RSVP Button Group when organizer toggles to set their own RSVP -->
271+
<lfx-rsvp-button-group
272+
[meeting]="meeting()"
273+
[occurrenceId]="occurrence()?.occurrence_id"
274+
[disabled]="isLegacyMeeting()"
275+
disabledMessage="RSVP functionality will soon be available for all upcoming LFX meetings visible to you">
276+
</lfx-rsvp-button-group>
277+
} @else {
278+
<!-- Show RSVP Details for organizers (default view) -->
279+
<lfx-meeting-rsvp-details
280+
[meeting]="meeting()"
281+
[currentOccurrence]="currentOccurrence()"
282+
[pastMeeting]="pastMeeting()"
283+
[showAddButton]="!pastMeeting() && !isLegacyMeeting()"
284+
[additionalRegistrantsCount]="additionalRegistrantsCount()"
285+
[disabled]="isLegacyMeeting()"
286+
disabledMessage="RSVP functionality will soon be available for all upcoming LFX meetings visible to you">
287+
</lfx-meeting-rsvp-details>
288+
}
278289
} @else if (!pastMeeting()) {
279290
<!-- Show RSVP Selection for authenticated invited non-organizers (upcoming meetings only) -->
280291
@if (isInvited()) {
@@ -343,6 +354,18 @@ <h3 class="text-base font-medium text-gray-900 leading-tight tracking-tight" dat
343354
data-testid="view-guests-button"
344355
(click)="onRegistrantsToggle()">
345356
</lfx-button>
357+
@if (canToggleRsvpView()) {
358+
<lfx-button
359+
class="w-full"
360+
[icon]="showMyRsvp() ? 'fa-light fa-users' : 'fa-light fa-hand'"
361+
[label]="showMyRsvp() ? 'Show Guests' : 'Set My RSVP'"
362+
size="small"
363+
severity="secondary"
364+
styleClass="w-full"
365+
data-testid="toggle-rsvp-view-button"
366+
(click)="onRsvpViewToggle()">
367+
</lfx-button>
368+
}
346369
}
347370
@if (!pastMeeting()) {
348371
<lfx-button
@@ -361,15 +384,37 @@ <h3 class="text-base font-medium text-gray-900 leading-tight tracking-tight" dat
361384
</div>
362385
</div>
363386

364-
<!-- Meeting Registrants (v2 meetings only, shown if feature enabled OR user is organizer) -->
387+
<!-- Meeting Registrants Drawer (v2 meetings only, shown if feature enabled OR user is organizer) -->
365388
@if (!isLegacyMeeting() && (meeting().show_meeting_attendees || meeting().organizer)) {
366-
<lfx-meeting-registrants-display
367-
[meeting]="meeting()"
368-
[pastMeeting]="pastMeeting()"
369-
[visible]="showRegistrants()"
370-
[showAddRegistrant]="!pastMeeting()"
371-
(registrantsCountChange)="additionalRegistrantsCount.set($event)">
372-
</lfx-meeting-registrants-display>
389+
<p-drawer [visible]="showRegistrants()" position="right" styleClass="lg:w-1/3 w-full" (onHide)="onDrawerHide()" data-testid="meeting-registrants-drawer">
390+
<ng-template #header>
391+
<div class="flex items-center gap-3">
392+
<div class="bg-blue-600 text-white rounded-full w-12 h-12 flex items-center justify-center">
393+
<i class="fa-light fa-user-group text-white text-2xl"></i>
394+
</div>
395+
@if (meeting().committees && meeting().committees!.length > 0) {
396+
<div class="flex flex-col">
397+
<h1>Meeting Members</h1>
398+
<span class="text-base text-gray-500">{{ meeting().committee_members_count }} members</span>
399+
</div>
400+
} @else {
401+
<div class="flex flex-col">
402+
<h1>Meeting Guests</h1>
403+
<span class="text-base text-gray-500"
404+
>{{ (meeting().individual_registrants_count || 0) + (meeting().committee_members_count || 0) }} guests</span
405+
>
406+
</div>
407+
}
408+
</div>
409+
</ng-template>
410+
<lfx-meeting-registrants-display
411+
[meeting]="meeting()"
412+
[pastMeeting]="pastMeeting()"
413+
[visible]="showRegistrants()"
414+
[showAddRegistrant]="!pastMeeting()"
415+
(registrantsCountChange)="additionalRegistrantsCount.set($event)">
416+
</lfx-meeting-registrants-display>
417+
</p-drawer>
373418
}
374419

375420
<!-- Confirmation Dialog -->

apps/lfx-one/src/app/modules/meetings/components/meeting-card/meeting-card.component.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { UserService } from '@services/user.service';
4848
import { AnimateOnScrollModule } from 'primeng/animateonscroll';
4949
import { ConfirmationService, MessageService } from 'primeng/api';
5050
import { ConfirmDialogModule } from 'primeng/confirmdialog';
51+
import { DrawerModule } from 'primeng/drawer';
5152
import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog';
5253
import { TooltipModule } from 'primeng/tooltip';
5354
import { catchError, combineLatest, map, of, switchMap, take, tap } from 'rxjs';
@@ -67,6 +68,7 @@ import { PublicRegistrationModalComponent } from '../../components/public-regist
6768
TooltipModule,
6869
AnimateOnScrollModule,
6970
ConfirmDialogModule,
71+
DrawerModule,
7072
ExpandableTextComponent,
7173
LinkifyPipe,
7274
FileTypeIconPipe,
@@ -96,6 +98,7 @@ export class MeetingCardComponent implements OnInit {
9698
public readonly showBorder = input<boolean>(false);
9799

98100
public showRegistrants: WritableSignal<boolean> = signal(false);
101+
public showMyRsvp: WritableSignal<boolean> = signal(false);
99102
public meeting: WritableSignal<Meeting | PastMeeting> = signal({} as Meeting | PastMeeting);
100103
public occurrence: WritableSignal<MeetingOccurrence | null> = signal(null);
101104
public recording: WritableSignal<PastMeetingRecording | null> = signal(null);
@@ -138,6 +141,11 @@ export class MeetingCardComponent implements OnInit {
138141
public readonly canRegisterForMeeting: Signal<boolean> = computed(
139142
() => !this.isInvited() && !this.meeting().restricted && this.meeting().visibility === 'public'
140143
);
144+
// Computed signal to check if user can toggle between RSVP Details and RSVP Button Group
145+
// True when user is both an organizer AND invited to the meeting (for non-past, non-legacy meetings)
146+
public readonly canToggleRsvpView: Signal<boolean> = computed(
147+
() => !!this.meeting().organizer && this.isInvited() && !this.pastMeeting() && !this.isLegacyMeeting()
148+
);
141149

142150
public readonly meetingTitle: Signal<string> = this.initMeetingTitle();
143151
public readonly meetingDescription: Signal<string> = this.initMeetingDescription();
@@ -214,6 +222,14 @@ export class MeetingCardComponent implements OnInit {
214222
this.showRegistrants.set(!this.showRegistrants());
215223
}
216224

225+
public onRsvpViewToggle(): void {
226+
this.showMyRsvp.set(!this.showMyRsvp());
227+
}
228+
229+
public onDrawerHide(): void {
230+
this.showRegistrants.set(false);
231+
}
232+
217233
public openCommitteeModal(): void {
218234
const header =
219235
this.meeting().committees && this.meeting().committees!.length > 0 ? 'Manage ' + this.committeeLabel.singular : 'Connect ' + this.committeeLabel.singular;

0 commit comments

Comments
 (0)