Skip to content

Commit 46e6cd9

Browse files
committed
feat(meetings): add ability to cancel meeting occurrences
Implement modal flow to cancel individual occurrences of recurring meetings: - Add delete type selection modal for recurring meetings - Add cancel occurrence confirmation modal with occurrence details - Implement ETag-based concurrency control for cancellation API - Filter cancelled occurrences from all meeting displays - Add centralized getActiveOccurrences utility function in shared package - Update frontend service with cancelOccurrence method - Add backend route, controller, and service for cancel occurrence API - Fix modal subscription logic to prevent unintended page refreshes Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon <andrest2455@gmail.com>
1 parent f89bab0 commit 46e6cd9

File tree

13 files changed

+460
-6
lines changed

13 files changed

+460
-6
lines changed

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Router } from '@angular/router';
88
import { MeetingService } from '@app/shared/services/meeting.service';
99
import { ButtonComponent } from '@components/button/button.component';
1010
import { DashboardMeetingCardComponent } from '@components/dashboard-meeting-card/dashboard-meeting-card.component';
11+
import { getActiveOccurrences } from '@lfx-one/shared';
1112

1213
import type { Meeting, MeetingOccurrence } from '@lfx-one/shared/interfaces';
1314

@@ -41,7 +42,10 @@ export class MyMeetingsComponent {
4142
for (const meeting of this.allMeetings()) {
4243
// Process occurrences if they exist
4344
if (meeting.occurrences && meeting.occurrences.length > 0) {
44-
for (const occurrence of meeting.occurrences) {
45+
// Get only active (non-cancelled) occurrences
46+
const activeOccurrences = getActiveOccurrences(meeting.occurrences);
47+
48+
for (const occurrence of activeOccurrences) {
4549
const startTime = new Date(occurrence.start_time);
4650
const startTimeMs = startTime.getTime();
4751
const endTime = startTimeMs + occurrence.duration * 60 * 1000 + buffer;
@@ -92,7 +96,10 @@ export class MyMeetingsComponent {
9296
for (const meeting of this.allMeetings()) {
9397
// Process occurrences if they exist
9498
if (meeting.occurrences && meeting.occurrences.length > 0) {
95-
for (const occurrence of meeting.occurrences) {
99+
// Get only active (non-cancelled) occurrences
100+
const activeOccurrences = getActiveOccurrences(meeting.occurrences);
101+
102+
for (const occurrence of activeOccurrences) {
96103
const startTime = new Date(occurrence.start_time);
97104
const startTimeMs = startTime.getTime();
98105

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
2+
<!-- SPDX-License-Identifier: MIT -->
3+
4+
<div class="meeting-cancel-occurrence-confirmation">
5+
<!-- Occurrence Details -->
6+
<div class="mb-6">
7+
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200">
8+
<h3 class="text-lg font-semibold text-gray-900 mb-3">Occurrence Details</h3>
9+
10+
<div class="space-y-2">
11+
<div class="flex items-start">
12+
<span class="font-medium text-gray-700 w-20 flex-shrink-0">Title:</span>
13+
<span class="text-gray-900">{{ occurrence.title || meeting.title || 'Untitled Meeting' }}</span>
14+
</div>
15+
16+
<div class="flex items-start">
17+
<span class="font-medium text-gray-700 w-20 flex-shrink-0">Date:</span>
18+
<span class="text-gray-900">{{ occurrence.start_time | meetingTime: occurrence.duration : 'date' }}</span>
19+
</div>
20+
21+
<div class="flex items-start">
22+
<span class="font-medium text-gray-700 w-20 flex-shrink-0">Time:</span>
23+
<span class="text-gray-900">{{ occurrence.start_time | meetingTime: occurrence.duration : 'time' }}</span>
24+
</div>
25+
26+
<div class="flex items-start">
27+
<span class="font-medium text-gray-700 w-20 flex-shrink-0">Series:</span>
28+
<span class="text-gray-900 flex items-center gap-2">
29+
<i class="fa-light fa-repeat text-blue-500"></i>
30+
Part of Recurring Series
31+
</span>
32+
</div>
33+
</div>
34+
</div>
35+
</div>
36+
37+
<!-- Warning Message -->
38+
<lfx-message severity="error" icon="fa-light fa-triangle-exclamation" styleClass="mb-6">
39+
<ng-template #content>
40+
<h4 class="font-medium mb-1">Warning</h4>
41+
<p class="text-sm">This will permanently cancel this specific occurrence. Guests will be notified of the cancellation. This action cannot be undone.</p>
42+
</ng-template>
43+
</lfx-message>
44+
45+
<!-- Action Buttons -->
46+
<div class="flex justify-end gap-3">
47+
<lfx-button
48+
label="Cancel"
49+
severity="secondary"
50+
[outlined]="true"
51+
size="small"
52+
type="button"
53+
[disabled]="isCanceling()"
54+
(click)="onCancel()"
55+
data-testid="cancel-occurrence-cancel-button">
56+
</lfx-button>
57+
58+
<lfx-button
59+
[label]="isCanceling() ? 'Canceling...' : 'Cancel Occurrence'"
60+
severity="danger"
61+
size="small"
62+
type="button"
63+
[icon]="isCanceling() ? 'fa-light fa-circle-notch fa-spin' : 'fa-light fa-ban'"
64+
[disabled]="isCanceling()"
65+
(click)="onConfirm()"
66+
data-testid="cancel-occurrence-confirm-button">
67+
</lfx-button>
68+
</div>
69+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
import { Component, inject, signal } from '@angular/core';
5+
import { CommonModule } from '@angular/common';
6+
import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
7+
import { Meeting, MeetingOccurrence } from '@lfx-one/shared/interfaces';
8+
import { MeetingService } from '@services/meeting.service';
9+
import { ButtonComponent } from '@components/button/button.component';
10+
import { MessageComponent } from '@components/message/message.component';
11+
import { MeetingTimePipe } from '@pipes/meeting-time.pipe';
12+
import { HttpErrorResponse } from '@angular/common/http';
13+
14+
export interface MeetingCancelOccurrenceResult {
15+
confirmed: boolean;
16+
error?: string;
17+
}
18+
19+
@Component({
20+
selector: 'lfx-meeting-cancel-occurrence-confirmation',
21+
standalone: true,
22+
imports: [CommonModule, ButtonComponent, MessageComponent, MeetingTimePipe],
23+
templateUrl: './meeting-cancel-occurrence-confirmation.component.html',
24+
})
25+
export class MeetingCancelOccurrenceConfirmationComponent {
26+
private readonly dialogRef = inject(DynamicDialogRef);
27+
private readonly config = inject(DynamicDialogConfig);
28+
private readonly meetingService = inject(MeetingService);
29+
30+
public readonly meeting: Meeting = this.config.data.meeting;
31+
public readonly occurrence: MeetingOccurrence = this.config.data.occurrence;
32+
public readonly isCanceling = signal(false);
33+
34+
public onCancel(): void {
35+
this.dialogRef.close({ confirmed: false });
36+
}
37+
38+
public onConfirm(): void {
39+
this.isCanceling.set(true);
40+
41+
this.meetingService.cancelOccurrence(this.meeting.uid, this.occurrence.occurrence_id).subscribe({
42+
next: () => {
43+
this.isCanceling.set(false);
44+
this.dialogRef.close({ confirmed: true });
45+
},
46+
error: (error: HttpErrorResponse) => {
47+
this.isCanceling.set(false);
48+
let errorMessage = 'Failed to cancel occurrence. Please try again.';
49+
50+
if (error.status === 404) {
51+
errorMessage = 'Meeting occurrence not found.';
52+
} else if (error.status === 403) {
53+
errorMessage = 'You do not have permission to cancel this occurrence.';
54+
} else if (error.status === 500) {
55+
errorMessage = 'Server error occurred while canceling occurrence.';
56+
} else if (error.status === 0) {
57+
errorMessage = 'Network error. Please check your connection.';
58+
}
59+
60+
this.dialogRef.close({ confirmed: false, error: errorMessage });
61+
},
62+
});
63+
}
64+
}

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

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,13 @@ import { DialogService } from 'primeng/dynamicdialog';
3535
import { TooltipModule } from 'primeng/tooltip';
3636
import { BehaviorSubject, catchError, filter, finalize, map, of, switchMap, take, tap } from 'rxjs';
3737

38+
import {
39+
MeetingCancelOccurrenceConfirmationComponent,
40+
MeetingCancelOccurrenceResult,
41+
} from '../meeting-cancel-occurrence-confirmation/meeting-cancel-occurrence-confirmation.component';
3842
import { MeetingCommitteeModalComponent } from '../meeting-committee-modal/meeting-committee-modal.component';
3943
import { MeetingDeleteConfirmationComponent, MeetingDeleteResult } from '../meeting-delete-confirmation/meeting-delete-confirmation.component';
44+
import { MeetingDeleteTypeSelectionComponent, MeetingDeleteTypeResult } from '../meeting-delete-type-selection/meeting-delete-type-selection.component';
4045
import { RegistrantModalComponent } from '../registrant-modal/registrant-modal.component';
4146

4247
@Component({
@@ -376,6 +381,85 @@ export class MeetingCardComponent implements OnInit {
376381
const meeting = this.meeting();
377382
if (!meeting) return;
378383

384+
// Check if meeting is recurring
385+
const isRecurring = !!meeting.recurrence;
386+
387+
if (isRecurring) {
388+
// For recurring meetings, first show the delete type selection modal
389+
this.dialogService
390+
.open(MeetingDeleteTypeSelectionComponent, {
391+
header: 'Delete Recurring Meeting',
392+
width: '500px',
393+
modal: true,
394+
closable: true,
395+
dismissableMask: true,
396+
data: {
397+
meeting: meeting,
398+
},
399+
})
400+
.onClose.pipe(take(1))
401+
.subscribe((typeResult: MeetingDeleteTypeResult) => {
402+
if (typeResult) {
403+
if (typeResult.deleteType === 'occurrence') {
404+
// User wants to cancel just this occurrence
405+
this.showCancelOccurrenceModal(meeting);
406+
} else {
407+
// User wants to delete the entire series
408+
this.showDeleteMeetingModal(meeting);
409+
}
410+
}
411+
});
412+
} else {
413+
// For non-recurring meetings, show delete confirmation directly
414+
this.showDeleteMeetingModal(meeting);
415+
}
416+
}
417+
418+
private showCancelOccurrenceModal(meeting: Meeting): void {
419+
// Get the next occurrence for the meeting
420+
const nextOccurrence = getCurrentOrNextOccurrence(meeting);
421+
422+
if (!nextOccurrence) {
423+
this.messageService.add({
424+
severity: 'error',
425+
summary: 'Error',
426+
detail: 'No upcoming occurrence found to cancel.',
427+
});
428+
return;
429+
}
430+
431+
this.dialogService
432+
.open(MeetingCancelOccurrenceConfirmationComponent, {
433+
header: 'Cancel Occurrence',
434+
width: '450px',
435+
modal: true,
436+
closable: true,
437+
dismissableMask: true,
438+
data: {
439+
meeting: meeting,
440+
occurrence: nextOccurrence,
441+
},
442+
})
443+
.onClose.pipe(take(1))
444+
.subscribe((result: MeetingCancelOccurrenceResult) => {
445+
if (result?.confirmed) {
446+
this.messageService.add({
447+
severity: 'success',
448+
summary: 'Success',
449+
detail: 'Meeting occurrence canceled successfully',
450+
});
451+
this.meetingDeleted.emit();
452+
} else if (result?.error) {
453+
this.messageService.add({
454+
severity: 'error',
455+
summary: 'Error',
456+
detail: result.error,
457+
});
458+
}
459+
});
460+
}
461+
462+
private showDeleteMeetingModal(meeting: Meeting): void {
379463
this.dialogService
380464
.open(MeetingDeleteConfirmationComponent, {
381465
header: 'Delete Meeting',
@@ -389,7 +473,7 @@ export class MeetingCardComponent implements OnInit {
389473
})
390474
.onClose.pipe(take(1))
391475
.subscribe((result: MeetingDeleteResult) => {
392-
if (result) {
476+
if (result?.confirmed) {
393477
this.meetingDeleted.emit();
394478
}
395479
});

apps/lfx-one/src/app/modules/project/meetings/components/meeting-delete-confirmation/meeting-delete-confirmation.component.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,13 @@ <h4 class="font-medium mb-1">Warning</h4>
9292

9393
<!-- Action Buttons -->
9494
<div class="flex justify-end gap-3">
95-
<lfx-button label="Cancel" severity="secondary" [outlined]="true" size="small" [disabled]="isDeleting()" (click)="onCancel()"> </lfx-button>
95+
<lfx-button label="Cancel" severity="secondary" [outlined]="true" size="small" [disabled]="isDeleting()" type="button" (click)="onCancel()"> </lfx-button>
9696

9797
<lfx-button
9898
[label]="isDeleting() ? 'Deleting...' : 'Delete Meeting'"
9999
severity="danger"
100100
size="small"
101+
type="button"
101102
[icon]="isDeleting() ? 'fa-light fa-circle-notch fa-spin' : 'fa-light fa-trash'"
102103
[disabled]="isDeleting()"
103104
(click)="onConfirm()">
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
2+
<!-- SPDX-License-Identifier: MIT -->
3+
4+
<div class="meeting-delete-type-selection">
5+
<!-- Description -->
6+
<div class="mb-6">
7+
<p class="text-gray-700">This is a recurring meeting. Would you like to cancel just this occurrence or delete the entire meeting series?</p>
8+
</div>
9+
10+
<!-- Selection Options -->
11+
<div class="space-y-3 mb-6">
12+
<div
13+
class="border rounded-lg p-4 cursor-pointer transition-all"
14+
[class.border-blue-500]="selectedType === 'occurrence'"
15+
[class.bg-blue-50]="selectedType === 'occurrence'"
16+
[class.border-gray-200]="selectedType !== 'occurrence'"
17+
(click)="selectType('occurrence')"
18+
data-testid="delete-type-occurrence-option">
19+
<div class="flex items-start gap-3">
20+
<div class="mt-1">
21+
<i
22+
class="fa-light text-lg"
23+
[class.fa-circle-dot]="selectedType === 'occurrence'"
24+
[class.fa-circle]="selectedType !== 'occurrence'"
25+
[class.text-blue-500]="selectedType === 'occurrence'"
26+
[class.text-gray-400]="selectedType !== 'occurrence'"></i>
27+
</div>
28+
<div class="flex-1">
29+
<h4 class="font-semibold text-gray-900 mb-1">Cancel This Occurrence</h4>
30+
<p class="text-sm text-gray-600">Only this specific meeting instance will be canceled. The rest of the series will remain.</p>
31+
</div>
32+
</div>
33+
</div>
34+
35+
<div
36+
class="border rounded-lg p-4 cursor-pointer transition-all"
37+
[class.border-blue-500]="selectedType === 'series'"
38+
[class.bg-blue-50]="selectedType === 'series'"
39+
[class.border-gray-200]="selectedType !== 'series'"
40+
(click)="selectType('series')"
41+
data-testid="delete-type-series-option">
42+
<div class="flex items-start gap-3">
43+
<div class="mt-1">
44+
<i
45+
class="fa-light text-lg"
46+
[class.fa-circle-dot]="selectedType === 'series'"
47+
[class.fa-circle]="selectedType !== 'series'"
48+
[class.text-blue-500]="selectedType === 'series'"
49+
[class.text-gray-400]="selectedType !== 'series'"></i>
50+
</div>
51+
<div class="flex-1">
52+
<h4 class="font-semibold text-gray-900 mb-1">Delete Entire Series</h4>
53+
<p class="text-sm text-gray-600">The entire recurring meeting series will be permanently deleted.</p>
54+
</div>
55+
</div>
56+
</div>
57+
</div>
58+
59+
<!-- Action Buttons -->
60+
<div class="flex justify-end gap-3">
61+
<lfx-button label="Cancel" severity="secondary" [outlined]="true" size="small" type="button" (click)="onCancel()" data-testid="delete-type-cancel-button">
62+
</lfx-button>
63+
64+
<lfx-button
65+
label="Continue"
66+
severity="primary"
67+
size="small"
68+
type="button"
69+
[disabled]="!selectedType"
70+
(click)="onContinue()"
71+
data-testid="delete-type-continue-button">
72+
</lfx-button>
73+
</div>
74+
</div>

0 commit comments

Comments
 (0)