src/app/features/attendance/analysis/attendance-calendar/attendance-calendar.component.ts
Displays a calendar view of attendance events for a given activity, allowing drill-down into details and editing attendance for individual events.
This component functions (almost) as a "display component" and relies on inputs to receive data and updates.
| changeDetection | ChangeDetectionStrategy.OnPush |
| encapsulation | ViewEncapsulation.None |
| selector | app-attendance-calendar |
| standalone | true |
| imports |
MatDatepickerModule
CustomDatePipe
MatFormFieldModule
MatInputModule
MatButtonModule
Angulartics2Module
FontAwesomeModule
|
| styleUrls | ./attendance-calendar.component.scss, |
| templateUrl | ./attendance-calendar.component.html |
MatDatepickerModule
CustomDatePipe
EditConfigurableEnumComponent
ReactiveFormsModule
MatFormFieldModule
MatInputModule
FormsModule
PercentPipe
MatButtonModule
Angulartics2Module
FontAwesomeModule
Properties |
Methods |
Inputs |
constructor()
|
| activity | |
Type : Entity
|
|
| highlightForChild | |
Type : string
|
|
| records | |
Type : EventWithAttendance[]
|
|
Default value : []
|
|
| createNewEvent |
createNewEvent()
|
|
Returns :
void
|
| onRemarksChange | ||||||
onRemarksChange(remarks: string)
|
||||||
|
Parameters :
Returns :
void
|
| Async save |
save()
|
|
Returns :
any
|
| selectDay | ||||||
selectDay(newDate?: Date)
|
||||||
|
Parameters :
Returns :
void
|
| showEventDetails | ||||||
showEventDetails(selectedEvent: EventWithAttendance)
|
||||||
|
Parameters :
Returns :
void
|
| Readonly calendar |
Type : unknown
|
Default value : viewChild<MatCalendar<Date>>(MatCalendar)
|
| hasAverage |
Type : unknown
|
Default value : computed(() => {
return !Number.isNaN(this.selectedEventStats()?.average);
})
|
| highlightDate |
Type : unknown
|
Default value : () => {...}
|
| maxDate |
Type : unknown
|
Default value : computed<Date | undefined>(() => {
const dates = this._recordDates();
return dates.length > 0
? moment.max(dates).endOf("month").toDate()
: undefined;
})
|
| minDate |
Type : unknown
|
Default value : computed<Date | undefined>(() => {
const dates = this._recordDates();
return dates.length > 0
? moment.min(dates).startOf("month").toDate()
: undefined;
})
|
| selectedDate |
Type : unknown
|
Default value : signal<moment.Moment | undefined>(undefined)
|
| selectedEvent |
Type : unknown
|
Default value : computed(() =>
this.records().find((e) => this.selectedDate()?.isSame(e.date, "day")),
)
|
| selectedEventAttendance |
Type : unknown
|
Default value : signal<AttendanceItem | undefined>(undefined)
|
| selectedEventParticipantCount |
Type : unknown
|
Default value : computed(
() => this.selectedEvent()?.attendanceItems.length ?? 0,
)
|
| selectedEventStats |
Type : unknown
|
Default value : computed(() =>
this.selectedEvent()?.getAttendanceStats(),
)
|
| statusControl |
Type : unknown
|
Default value : new FormControl<ConfigurableEnumValue>(null)
|
| statusFieldConfig |
Type : FormFieldConfig
|
Default value : {
id: "status",
dataType: "configurable-enum",
additional: ATTENDANCE_STATUS_CONFIG_ID,
}
|
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
inject,
input,
signal,
untracked,
Renderer2,
ViewEncapsulation,
viewChild,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, ReactiveFormsModule, FormsModule } from "@angular/forms";
import { Entity } from "#src/app/core/entity/model/entity";
import {
MatCalendar,
MatCalendarCellCssClasses,
MatDatepickerModule,
} from "@angular/material/datepicker";
import moment from "moment";
import { AttendanceItem } from "../../model/attendance-item";
import {
ATTENDANCE_STATUS_CONFIG_ID,
AttendanceStatusType,
NullAttendanceStatusType,
} from "../../model/attendance-status";
import { EntityMapperService } from "#src/app/core/entity/entity-mapper/entity-mapper.service";
import { FormDialogService } from "#src/app/core/form-dialog/form-dialog.service";
import { AttendanceService } from "../../attendance.service";
import { AnalyticsService } from "#src/app/core/analytics/analytics.service";
import { DOCUMENT, PercentPipe } from "@angular/common";
import { CustomDatePipe } from "#src/app/core/basic-datatypes/date/custom-date.pipe";
import { EditConfigurableEnumComponent } from "#src/app/core/basic-datatypes/configurable-enum/edit-configurable-enum/edit-configurable-enum.component";
import { FormFieldConfig } from "#src/app/core/common-components/entity-form/FormConfig";
import { ConfigurableEnumValue } from "#src/app/core/basic-datatypes/configurable-enum/configurable-enum.types";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { MatButtonModule } from "@angular/material/button";
import { Angulartics2Module } from "angulartics2";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { EventWithAttendance } from "../../model/event-with-attendance";
import { Logging } from "#src/app/core/logging/logging.service";
import { ConfigurableEnumService } from "#src/app/core/basic-datatypes/configurable-enum/configurable-enum.service";
import { defaultAttendanceStatusTypes } from "#src/app/core/config/default-config/default-attendance-status-types";
/** CSS class of a calendar day on which the highlighted participant had this attendance status */
function statusClass(statusId: string): string {
return "attendance-status-" + statusId.replace(/[^A-Za-z0-9_-]/g, "_");
}
/**
* Displays a calendar view of attendance events for a given activity,
* allowing drill-down into details and editing attendance for individual events.
*
* This component functions (almost) as a "display component"
* and relies on inputs to receive data and updates.
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-attendance-calendar",
templateUrl: "./attendance-calendar.component.html",
styleUrls: [
"./attendance-calendar.component.scss",
"../../../../core/common-components/dialog-close/dialog-close.component.scss",
],
encapsulation: ViewEncapsulation.None,
imports: [
MatDatepickerModule,
CustomDatePipe,
EditConfigurableEnumComponent,
ReactiveFormsModule,
MatFormFieldModule,
MatInputModule,
FormsModule,
PercentPipe,
MatButtonModule,
Angulartics2Module,
FontAwesomeModule,
],
})
export class AttendanceCalendarComponent {
private entityMapper = inject(EntityMapperService);
private formDialog = inject(FormDialogService);
private analyticsService = inject(AnalyticsService);
private attendanceService = inject(AttendanceService);
private readonly destroyRef = inject(DestroyRef);
private readonly enumService = inject(ConfigurableEnumService);
private readonly renderer = inject(Renderer2);
private readonly document = inject(DOCUMENT);
records = input<EventWithAttendance[]>([]);
highlightForChild = input<string>();
activity = input<Entity>();
readonly calendar = viewChild<MatCalendar<Date>>(MatCalendar);
private readonly _recordDates = computed(() =>
this.records()
.map((e) => e.date)
.filter((d): d is Date => !!d)
.map((d) => moment(d)),
);
minDate = computed<Date | undefined>(() => {
const dates = this._recordDates();
return dates.length > 0
? moment.min(dates).startOf("month").toDate()
: undefined;
});
maxDate = computed<Date | undefined>(() => {
const dates = this._recordDates();
return dates.length > 0
? moment.max(dates).endOf("month").toDate()
: undefined;
});
selectedDate = signal<moment.Moment | undefined>(undefined);
selectedEvent = computed(() =>
this.records().find((e) => this.selectedDate()?.isSame(e.date, "day")),
);
selectedEventStats = computed(() =>
this.selectedEvent()?.getAttendanceStats(),
);
selectedEventAttendance = signal<AttendanceItem | undefined>(undefined);
private selectedEventAttendanceOriginal: AttendanceItem | undefined;
statusControl = new FormControl<ConfigurableEnumValue>(null);
statusFieldConfig: FormFieldConfig = {
id: "status",
dataType: "configurable-enum",
additional: ATTENDANCE_STATUS_CONFIG_ID,
};
constructor() {
this.addStatusColorStyles();
// Update calendar's visible month when date range changes
effect(() => {
const maxDate = this.maxDate();
const calendar = this.calendar();
if (calendar && maxDate) {
calendar.activeDate = maxDate;
}
});
// Sync attendance state when selected event or highlighted child changes
effect(() => {
const event = this.selectedEvent();
const childId = this.highlightForChild();
if (event && childId) {
const attendance = untracked(() =>
this.ensureParticipantInAttendance(event, childId),
);
this.selectedEventAttendance.set(attendance);
this.selectedEventAttendanceOriginal = attendance.copy();
this.statusControl.setValue(attendance.status ?? null, {
emitEvent: false,
});
} else {
this.selectedEventAttendance.set(undefined);
this.selectedEventAttendanceOriginal = undefined;
this.statusControl.setValue(null, { emitEvent: false });
}
});
this.statusControl.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((status) => {
const attendance = this.selectedEventAttendance();
if (attendance) {
const normalizedStatus: AttendanceStatusType =
(status as AttendanceStatusType) ?? NullAttendanceStatusType;
const updatedAttendance = attendance.copy();
updatedAttendance.status = normalizedStatus;
this.selectedEventAttendance.set(updatedAttendance);
this.save().catch((error) =>
Logging.warn("Could not save attendance status change", error),
);
}
});
}
/**
* mat-calendar can only give day cells CSS classes, so the background color configured for each
* attendance status is written into a stylesheet rule for that status's class.
* Statuses saved without a color (older configs) keep the default color of their id.
*/
private addStatusColorStyles() {
const configured = this.enumService.getEnumValues<AttendanceStatusType>(
ATTENDANCE_STATUS_CONFIG_ID,
);
const statuses =
configured.length > 0 ? configured : defaultAttendanceStatusTypes;
const rules = statuses
.map((status) => ({
id: status.id,
color:
status.color ??
defaultAttendanceStatusTypes.find((d) => d.id === status.id)?.color,
}))
// a color is config data that ends up in CSS: skip anything that is not a plain value
.filter(({ color }) => color && !/[;{}<>\\]/.test(color))
.map(
({ id, color }) =>
`.${statusClass(id)} { background-color: ${color} !important; }`,
);
const style: HTMLStyleElement = this.renderer.createElement("style");
style.textContent = rules.join("\n");
this.renderer.appendChild(this.document.head, style);
this.destroyRef.onDestroy(() => style.remove());
}
private ensureParticipantInAttendance(
event: EventWithAttendance,
participantId: string,
): AttendanceItem {
let item = event.getAttendanceForParticipant(participantId);
if (!item) {
item = new AttendanceItem(NullAttendanceStatusType, "", participantId);
event.attendanceItems = [...event.attendanceItems, item];
}
return item;
}
selectedEventParticipantCount = computed(
() => this.selectedEvent()?.attendanceItems.length ?? 0,
);
highlightDate = (cellDate: Date): MatCalendarCellCssClasses => {
const cellMoment = moment(cellDate);
const classes = {
"attendance-calendar-date-general": true,
};
if (this.selectedDate()) {
classes["attendance-calendar-date-selected"] = cellMoment.isSame(
this.selectedDate(),
"day",
);
}
const event = this.records().find((e) => cellMoment.isSame(e.date, "day"));
if (event && this.highlightForChild()) {
// coloring for individual child
const eventAttendance = event.getAttendanceForParticipant(
this.highlightForChild(),
);
if (!eventAttendance?.status?.id) {
classes[
"attendance-calendar-date-has-participants-with-unknown-status"
] = true;
} else {
classes[statusClass(eventAttendance.status.id)] = true;
}
classes["attendance-calendar-date-has-remarks"] =
eventAttendance?.remarks && eventAttendance?.remarks !== "";
}
if (event && !this.highlightForChild()) {
// coloring based on averages across all children
const stats = event.getAttendanceStats();
if (isNaN(stats.average)) {
classes["attendance-calendar-date-no-data"] = true;
} else {
const percentageSlab = Math.round(stats.average * 10) * 10;
classes["w-" + percentageSlab] = true;
}
classes["attendance-calendar-date-has-participants-with-unknown-status"] =
stats.excludedUnknown > 0;
}
return classes;
};
hasAverage = computed(() => {
return !Number.isNaN(this.selectedEventStats()?.average);
});
selectDay(newDate?: Date) {
this.selectedDate.set(newDate ? moment(newDate) : undefined);
if (newDate) {
this.analyticsService.eventTrack("calendar_select_date", {
category: "Attendance",
label: this.selectedEvent() ? "with event" : "without event",
});
}
this.calendar()?.updateTodaysDate();
}
async save() {
const attendance = this.selectedEventAttendance();
const original = this.selectedEventAttendanceOriginal;
if (
attendance?.status === original?.status &&
attendance?.remarks === original?.remarks
) {
// don't write unchanged object
return;
}
await this.entityMapper.save(this.selectedEvent().entity);
this.analyticsService.eventTrack("calendar_save_event_changes", {
category: "Attendance",
});
}
onRemarksChange(remarks: string) {
const att = this.selectedEventAttendance();
if (!att) return;
const updatedAttendance = att.copy();
updatedAttendance.remarks = remarks;
this.selectedEventAttendance.set(updatedAttendance);
}
createNewEvent() {
const activity = this.activity();
if (!activity) {
return;
}
this.attendanceService
.createEventForActivity(activity, this.selectedDate().toDate())
.then((note) => {
this.formDialog.openView(note.entity);
});
}
showEventDetails(selectedEvent: EventWithAttendance) {
this.formDialog.openView(selectedEvent.entity);
}
}
<mat-calendar
(selectedChange)="selectDay($event)"
[dateClass]="highlightDate"
[minDate]="minDate()"
[maxDate]="maxDate()"
>
</mat-calendar>
@if (selectedDate()) {
<div class="attendance-calendar-details-form">
<h3 class="remove-margin-top">
{{ selectedDate()!.toDate() | customDate: "shortDate" }}
</h3>
<button
mat-icon-button
class="overlay-close-button"
(click)="selectDay()"
type="button"
>
<fa-icon icon="times"></fa-icon>
</button>
@if (selectedEvent(); as selectedEvent) {
@if (highlightForChild()) {
<div>
<div>
<mat-form-field>
<mat-label i18n="Attendance Status field label">Status</mat-label>
<app-edit-configurable-enum
[formControl]="statusControl"
[formFieldConfig]="statusFieldConfig"
></app-edit-configurable-enum>
</mat-form-field>
</div>
<div>
<mat-form-field class="full-width">
<textarea
matInput
i18n-placeholder="
Placeholder if no remarks for the attendance of a child are
given
"
placeholder="Remarks"
[ngModel]="selectedEventAttendance()?.remarks"
(ngModelChange)="onRemarksChange($event)"
(blur)="save()"
></textarea>
</mat-form-field>
</div>
<button
class="margin-top-regular"
i18n="
Show Details Button|Allows the user to see details of an event
that took place at a particular day
"
mat-stroked-button
(click)="showEventDetails(selectedEvent)"
angulartics2On="click"
angularticsCategory="Attendance"
angularticsAction="calendar_show_event_details"
>
Details
</button>
</div>
} @else {
<div>
@if (!hasAverage()) {
<div
i18n="
Event participants|How many participants attended at an event
(in percent)
"
>
All excused (out of
{{ selectedEventParticipantCount() }}
{selectedEventParticipantCount(), plural,
one {participant}
other {participants}
}
)
</div>
}
@if (hasAverage()) {
<div i18n>
{{ selectedEventStats()!.average | percent: "1.0-0" }}
attended (of
{{ selectedEventParticipantCount() }}
{selectedEventParticipantCount(), plural,
one {participant}
other {participants}
})
</div>
}
@if (selectedEventStats()!.excludedUnknown > 0) {
<div
i18n="Unknown status|How many children are without a status"
class="unknown-status-notice"
>
{{ selectedEventStats()!.excludedUnknown }}
{selectedEventStats()!.excludedUnknown, plural,
one {participant}
other {participants}
}
without recorded status
</div>
}
<button
class="margin-top-regular"
i18n="
Show Details Button|Allows the user to see details of an event
that took place at a particular day
"
mat-stroked-button
(click)="showEventDetails(selectedEvent)"
angulartics2On="click"
angularticsCategory="Attendance"
angularticsAction="calendar_show_event_details"
>
Details
</button>
</div>
}
} @else {
<em
i18n="
No Events|Informs the user that there are no events at a particular
date
"
>
no events on this date
</em>
<button
mat-stroked-button
(click)="createNewEvent()"
i18n="
Add New Event Button|Allows the user to create a new event for the
selected date
"
angulartics2On="click"
angularticsCategory="Attendance"
angularticsAction="calendar_create_event"
style="margin-top: 12px; display: block"
>
Add new event
</button>
}
</div>
}
./attendance-calendar.component.scss
@use "variables/colors";
@use "functions/gradient-helpers";
@use "variables/sizes";
mat-calendar {
min-width: 200px;
max-width: 300px;
}
.unknown-status-notice {
font-style: italic;
color: colors.$error;
}
.attendance-calendar-date-general {
&.attendance-calendar-date-selected {
font-weight: 900;
font-size: 115%;
}
&.attendance-calendar-date-has-remarks {
background: gradient-helpers.top-inline-end-triangle(#5f5f5f);
}
&.attendance-calendar-date-no-data {
background: gradient-helpers.top-inline-end-triangle(grey);
}
&.attendance-calendar-date-has-participants-with-unknown-status {
background: gradient-helpers.top-inline-end-triangle(red);
}
}
.attendance-calendar-details-form {
padding: 10px;
max-width: 300px;
position: relative;
border: 5px solid black;
box-sizing: border-box;
margin-bottom: sizes.$regular;
}
../../../../core/common-components/dialog-close/dialog-close.component.scss
/**
* Positions the 'x' button that is shown for an overlay to the top
* of the overlay's reading-end side (top-right in LTR, top-left in RTL)
*/
.overlay-close-button {
position: absolute;
top: 0;
inset-inline-end: 0;
border-radius: 0;
border-start-end-radius: 4px;
border-end-start-radius: 4px;
}
:host ::ng-deep .mat-mdc-icon-button .mat-mdc-button-persistent-ripple {
border-radius: 0;
border-start-end-radius: 4px;
border-end-start-radius: 4px;
}