add attendance
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
/** The three age-group head-counts for one Sunday. */
|
||||
export interface AttendanceCounts {
|
||||
date: string; // yyyy-MM-dd
|
||||
adult: number;
|
||||
youth: number;
|
||||
kid: number;
|
||||
}
|
||||
|
||||
/** The age-group keys the server's Increment hub method accepts. */
|
||||
export type AttendanceCategory = 'adult' | 'youth' | 'kid';
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<div class="ac">
|
||||
<div class="ac__inner">
|
||||
|
||||
<!-- Header: church + auto-selected current day -->
|
||||
<header class="ac__head">
|
||||
<span class="ac__eyebrow">River of Life · 生命河靈糧堂</span>
|
||||
<h1 class="ac__title">Sunday Worship Count<span>主日崇拜人數統計</span></h1>
|
||||
<div class="ac__date">{{ today | date:'fullDate' }}</div>
|
||||
<div class="ac__status" [class.is-on]="connected">
|
||||
<span class="ac__dot"></span>
|
||||
{{ connected ? 'Live · 即時連線中' : 'Connecting… · 連線中' }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- One row per age group, each with its own big +/- controls -->
|
||||
<section class="ac__rows">
|
||||
<article class="row" *ngFor="let row of rows">
|
||||
<div class="row__label">
|
||||
<span class="row__en">{{ row.labelEn }}</span>
|
||||
<span class="row__zh">{{ row.labelZh }}</span>
|
||||
</div>
|
||||
|
||||
<div class="row__controls">
|
||||
<button type="button" class="btn btn--minus"
|
||||
[disabled]="display(row.key) === 0"
|
||||
(click)="bump(row.key, -1)"
|
||||
aria-label="Decrease">−</button>
|
||||
|
||||
<button type="button" class="row__num" [attr.data-key]="row.key"
|
||||
(click)="openEditor(row)"
|
||||
[attr.aria-label]="'Edit ' + row.labelEn + ' count'">
|
||||
{{ display(row.key) }}
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn--plus"
|
||||
(click)="bump(row.key, 1)"
|
||||
aria-label="Increase">+</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Live grand total -->
|
||||
<footer class="ac__total">
|
||||
<span class="ac__total-label">Total · 總人數</span>
|
||||
<span class="ac__total-num">
|
||||
{{ display('adult') + display('youth') + display('kid') }}
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Direct-entry dialog: tap a number to type an exact count -->
|
||||
<div class="edit" *ngIf="editing" (click)="cancelEditor()">
|
||||
<div class="edit__card" (click)="$event.stopPropagation()">
|
||||
<div class="edit__label">
|
||||
<span class="edit__en">{{ editing.labelEn }}</span>
|
||||
<span class="edit__zh">{{ editing.labelZh }}</span>
|
||||
</div>
|
||||
|
||||
<input class="edit__input" type="number" inputmode="numeric" min="0" step="1"
|
||||
[(ngModel)]="editValue" autofocus
|
||||
(keyup.enter)="confirmEditor()" (keyup.escape)="cancelEditor()" />
|
||||
|
||||
<div class="edit__actions">
|
||||
<button type="button" class="edit__btn edit__btn--cancel"
|
||||
(click)="cancelEditor()">Cancel · 取消</button>
|
||||
<button type="button" class="edit__btn edit__btn--confirm"
|
||||
(click)="confirmEditor()">Update · 更新</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ac {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: radial-gradient(120% 80% at 50% 0%, #1e293b 0%, #0f172a 55%, #020617 100%);
|
||||
color: #e2e8f0;
|
||||
padding: clamp(1rem, 4vw, 2rem) 1rem calc(1.5rem + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ac__inner {
|
||||
width: 100%;
|
||||
max-width: 30rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(1rem, 3.5vw, 1.75rem);
|
||||
}
|
||||
|
||||
/* ── Header ────────────────────────────────────────────────────────────── */
|
||||
.ac__head {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ac__eyebrow {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.ac__title {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: clamp(1.5rem, 6vw, 1.9rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
color: #f8fafc;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #cbd5e1;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ac__date {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.ac__status {
|
||||
margin-top: 0.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
color: #94a3b8;
|
||||
|
||||
&.is-on { color: #4ade80; }
|
||||
}
|
||||
|
||||
.ac__dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: #64748b;
|
||||
|
||||
.is-on & {
|
||||
background: #4ade80;
|
||||
box-shadow: 0 0 0 4px rgba(74, 222, 128, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Counter rows ──────────────────────────────────────────────────────── */
|
||||
.ac__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(0.85rem, 3vw, 1.25rem);
|
||||
}
|
||||
|
||||
.row {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border-radius: 1.25rem;
|
||||
padding: 1rem 1.1rem 1.25rem;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.row__label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.row__en {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.row__zh {
|
||||
font-size: 0.95rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.row__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Big, thumb-friendly tap targets */
|
||||
.btn {
|
||||
flex: 0 0 auto;
|
||||
width: clamp(4rem, 18vw, 5rem);
|
||||
height: clamp(4rem, 18vw, 5rem);
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
font-size: clamp(2rem, 9vw, 2.6rem);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform 0.08s ease, filter 0.15s ease, opacity 0.15s ease;
|
||||
box-shadow: 0 8px 20px -8px rgba(0, 0, 0, 0.6);
|
||||
|
||||
&:active { transform: scale(0.92); }
|
||||
&:disabled { opacity: 0.35; cursor: default; }
|
||||
}
|
||||
|
||||
.btn--minus {
|
||||
background: linear-gradient(135deg, #475569, #334155);
|
||||
}
|
||||
|
||||
.btn--plus {
|
||||
background: linear-gradient(135deg, #2563eb, #4f46e5);
|
||||
}
|
||||
|
||||
/* Tap the number to type an exact count */
|
||||
.row__num {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
font-size: clamp(2.6rem, 13vw, 3.6rem);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
padding: 0.4rem 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
border-radius: 0.85rem;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform 0.08s ease, filter 0.15s ease;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
|
||||
&:active { transform: scale(0.94); }
|
||||
|
||||
&[data-key='adult'] { background-image: linear-gradient(135deg, #38bdf8, #6366f1); }
|
||||
&[data-key='youth'] { background-image: linear-gradient(135deg, #34d399, #14b8a6); }
|
||||
&[data-key='kid'] { background-image: linear-gradient(135deg, #fbbf24, #fb923c); }
|
||||
}
|
||||
|
||||
/* ── Direct-entry dialog ───────────────────────────────────────────────── */
|
||||
.edit {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.25rem;
|
||||
background: rgba(2, 6, 23, 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.edit__card {
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: 1.25rem;
|
||||
background: linear-gradient(135deg, #1e293b, #0f172a);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.edit__label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.edit__en {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.edit__zh {
|
||||
font-size: 0.95rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.edit__input {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: clamp(2.4rem, 12vw, 3rem);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
padding: 0.6rem 0.5rem;
|
||||
border-radius: 0.85rem;
|
||||
color: #f8fafc;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border: 2px solid rgba(99, 102, 241, 0.5);
|
||||
outline: none;
|
||||
|
||||
&:focus { border-color: #6366f1; }
|
||||
}
|
||||
|
||||
.edit__actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.edit__btn {
|
||||
flex: 1 1 0;
|
||||
padding: 0.85rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 0.85rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform 0.08s ease, filter 0.15s ease;
|
||||
|
||||
&:active { transform: scale(0.96); }
|
||||
}
|
||||
|
||||
.edit__btn--cancel {
|
||||
background: linear-gradient(135deg, #475569, #334155);
|
||||
}
|
||||
|
||||
.edit__btn--confirm {
|
||||
background: linear-gradient(135deg, #2563eb, #4f46e5);
|
||||
}
|
||||
|
||||
/* ── Grand total ───────────────────────────────────────────────────────── */
|
||||
.ac__total {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.4rem;
|
||||
border-radius: 1.25rem;
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.18), rgba(79, 70, 229, 0.18));
|
||||
border: 1px solid rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
|
||||
.ac__total-label {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: #c7d2fe;
|
||||
}
|
||||
|
||||
.ac__total-num {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Subject, debounceTime, takeUntil } from 'rxjs';
|
||||
import { AttendanceSignalrService } from '../../services/attendance-signalr.service';
|
||||
import { AttendanceCategory } from '../../models/attendance.model';
|
||||
|
||||
interface CounterRow {
|
||||
key: AttendanceCategory;
|
||||
labelEn: string;
|
||||
labelZh: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-attendance-counter-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './attendance-counter-page.component.html',
|
||||
styleUrls: ['./attendance-counter-page.component.scss'],
|
||||
})
|
||||
export class AttendanceCounterPageComponent implements OnInit, OnDestroy {
|
||||
/** Auto-selected current day shown in the header. */
|
||||
readonly today = new Date();
|
||||
|
||||
readonly rows: CounterRow[] = [
|
||||
{ key: 'adult', labelEn: 'Adult', labelZh: '成人' },
|
||||
{ key: 'youth', labelEn: 'Youth', labelZh: '青少年' },
|
||||
{ key: 'kid', labelEn: 'Kid', labelZh: '兒童' },
|
||||
];
|
||||
|
||||
/** The number shown on screen — updated optimistically on every tap. */
|
||||
local: Record<AttendanceCategory, number> = { adult: 0, youth: 0, kid: 0 };
|
||||
|
||||
connected = false;
|
||||
|
||||
// The row whose number is being directly edited (null when the dialog is closed).
|
||||
editing: CounterRow | null = null;
|
||||
// Bound to the dialog's input box while editing.
|
||||
editValue: number | null = null;
|
||||
|
||||
// Last authoritative value from the server (per broadcast).
|
||||
private server: Record<AttendanceCategory, number> = { adult: 0, youth: 0, kid: 0 };
|
||||
// Taps accumulated but not yet sent to the server.
|
||||
private pending: Record<AttendanceCategory, number> = { adult: 0, youth: 0, kid: 0 };
|
||||
// Deltas sent to the server but not yet acknowledged.
|
||||
private inFlight: Record<AttendanceCategory, number> = { adult: 0, youth: 0, kid: 0 };
|
||||
// True while an absolute set is awaiting its server ack, so reconcile() doesn't
|
||||
// momentarily snap the number back to a stale broadcast.
|
||||
private setting: Record<AttendanceCategory, boolean> = { adult: false, youth: false, kid: false };
|
||||
|
||||
// One debounce stream per category so taps on different rows don't cancel each other.
|
||||
private readonly flush$: Record<AttendanceCategory, Subject<void>> = {
|
||||
adult: new Subject<void>(),
|
||||
youth: new Subject<void>(),
|
||||
kid: new Subject<void>(),
|
||||
};
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
constructor(private signalr: AttendanceSignalrService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
for (const row of this.rows) {
|
||||
this.flush$[row.key]
|
||||
.pipe(debounceTime(500), takeUntil(this.destroy$))
|
||||
.subscribe(() => this.flush(row.key));
|
||||
}
|
||||
|
||||
this.signalr.counts$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(counts => {
|
||||
if (!counts) {
|
||||
return;
|
||||
}
|
||||
for (const row of this.rows) {
|
||||
this.server[row.key] = counts[row.key];
|
||||
this.reconcile(row.key);
|
||||
}
|
||||
});
|
||||
|
||||
this.signalr.start()
|
||||
.then(() => (this.connected = true))
|
||||
.catch(() => (this.connected = false));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Flush any taps the volunteer made just before leaving so nothing is lost.
|
||||
for (const row of this.rows) {
|
||||
this.flush(row.key);
|
||||
}
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
this.signalr.stop();
|
||||
}
|
||||
|
||||
/** Displayed value for one age group. */
|
||||
display(key: AttendanceCategory): number {
|
||||
return this.local[key];
|
||||
}
|
||||
|
||||
/** Optimistic +/-: update the screen instantly, debounce the write to the DB. */
|
||||
bump(key: AttendanceCategory, step: number): void {
|
||||
if (this.local[key] + step < 0) {
|
||||
return; // never count below zero
|
||||
}
|
||||
this.local[key] += step;
|
||||
this.pending[key] += step;
|
||||
this.flush$[key].next();
|
||||
}
|
||||
|
||||
/** Open the direct-entry dialog for a row, pre-filled with the current count. */
|
||||
openEditor(row: CounterRow): void {
|
||||
this.editing = row;
|
||||
this.editValue = this.local[row.key];
|
||||
}
|
||||
|
||||
/** Close the dialog without changing anything. */
|
||||
cancelEditor(): void {
|
||||
this.editing = null;
|
||||
this.editValue = null;
|
||||
}
|
||||
|
||||
/** Apply the typed number, overwriting the current count for the edited row. */
|
||||
confirmEditor(): void {
|
||||
if (!this.editing) {
|
||||
return;
|
||||
}
|
||||
const row = this.editing;
|
||||
const value = Math.floor(Number(this.editValue));
|
||||
// Ignore blank/invalid/negative input — just keep the current value.
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
this.cancelEditor();
|
||||
return;
|
||||
}
|
||||
this.cancelEditor();
|
||||
this.setCount(row.key, value);
|
||||
}
|
||||
|
||||
// Overwrite one age group with an absolute value: show it instantly, discard any
|
||||
// queued taps it supersedes, and push the new value to the server for everyone.
|
||||
private setCount(key: AttendanceCategory, value: number): void {
|
||||
if (value === this.local[key]) {
|
||||
return; // nothing changed
|
||||
}
|
||||
this.pending[key] = 0; // taps before the set no longer matter
|
||||
this.local[key] = value;
|
||||
this.setting[key] = true;
|
||||
|
||||
this.signalr.setCount(key, value)
|
||||
.then(() => {
|
||||
this.setting[key] = false;
|
||||
this.reconcile(key);
|
||||
})
|
||||
.catch(() => {
|
||||
// Send failed — drop the guard and let the next broadcast correct us.
|
||||
this.setting[key] = false;
|
||||
this.reconcile(key);
|
||||
});
|
||||
}
|
||||
|
||||
// Send the accumulated delta for one category as a single batched write.
|
||||
private flush(key: AttendanceCategory): void {
|
||||
const delta = this.pending[key];
|
||||
if (delta === 0) {
|
||||
return;
|
||||
}
|
||||
this.pending[key] = 0;
|
||||
this.inFlight[key] += delta;
|
||||
|
||||
this.signalr.increment(key, delta)
|
||||
.then(() => {
|
||||
this.inFlight[key] -= delta;
|
||||
this.reconcile(key);
|
||||
})
|
||||
.catch(() => {
|
||||
// Send failed — re-queue the delta so the count isn't silently dropped.
|
||||
this.inFlight[key] -= delta;
|
||||
this.pending[key] += delta;
|
||||
});
|
||||
}
|
||||
|
||||
// Adopt the server's value only when nothing of ours is outstanding, so the
|
||||
// local number never momentarily jumps back to a stale total mid-flight.
|
||||
private reconcile(key: AttendanceCategory): void {
|
||||
if (this.pending[key] === 0 && this.inFlight[key] === 0 && !this.setting[key]) {
|
||||
this.local[key] = this.server[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import {
|
||||
HubConnection, HubConnectionBuilder, HubConnectionState, LogLevel,
|
||||
} from '@microsoft/signalr';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { AttendanceCounts, AttendanceCategory } from '../models/attendance.model';
|
||||
|
||||
/**
|
||||
* Thin wrapper around the AttendanceHub SignalR connection. Exposes the latest
|
||||
* authoritative counts as an observable and a fire-and-forget increment() that
|
||||
* sends a batched delta to the server. The server broadcasts the new totals
|
||||
* back to every connected client.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttendanceSignalrService {
|
||||
// Hub lives at the host root; environment.apiUrl is e.g. http://localhost:42019/api
|
||||
private readonly hubUrl = environment.apiUrl.replace(/\/api\/?$/, '') + '/hubs/attendance';
|
||||
|
||||
private connection?: HubConnection;
|
||||
private readonly counts$$ = new BehaviorSubject<AttendanceCounts | null>(null);
|
||||
|
||||
/** Latest authoritative counts pushed by the server (null until first message). */
|
||||
get counts$(): Observable<AttendanceCounts | null> {
|
||||
return this.counts$$.asObservable();
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.connection = new HubConnectionBuilder()
|
||||
.withUrl(this.hubUrl)
|
||||
.withAutomaticReconnect()
|
||||
.configureLogging(LogLevel.Warning)
|
||||
.build();
|
||||
|
||||
this.connection.on('ReceiveCounts', (counts: AttendanceCounts) => {
|
||||
this.counts$$.next(counts);
|
||||
});
|
||||
|
||||
await this.connection.start();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.connection) {
|
||||
await this.connection.stop();
|
||||
this.connection = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Send a batched delta (may be negative) for one age group. */
|
||||
async increment(category: AttendanceCategory, delta: number): Promise<void> {
|
||||
if (delta === 0 || this.connection?.state !== HubConnectionState.Connected) {
|
||||
return;
|
||||
}
|
||||
await this.connection.invoke('Increment', category, delta);
|
||||
}
|
||||
|
||||
/** Overwrite one age group with an absolute value (clamped at zero server-side). */
|
||||
async setCount(category: AttendanceCategory, value: number): Promise<void> {
|
||||
if (this.connection?.state !== HubConnectionState.Connected) {
|
||||
throw new Error('Not connected to the attendance hub.');
|
||||
}
|
||||
await this.connection.invoke('SetCount', category, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiConfigService } from '../../../core/services/api-config.service';
|
||||
import { AttendanceCounts } from '../models/attendance.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MealAttendanceApiService {
|
||||
private readonly endpoint: string;
|
||||
|
||||
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
|
||||
this.endpoint = apiConfig.getApiUrl('meal-attendance');
|
||||
}
|
||||
|
||||
/** Today's live counts (public; SignalR-independent fallback). */
|
||||
getToday(): Observable<AttendanceCounts> {
|
||||
return this.http.get<AttendanceCounts>(`${this.endpoint}/today`);
|
||||
}
|
||||
|
||||
/** Daily counts within an inclusive yyyy-MM-dd range, for the dashboard chart. */
|
||||
getRange(from: string, to: string): Observable<AttendanceCounts[]> {
|
||||
const params = new HttpParams().set('from', from).set('to', to);
|
||||
return this.http.get<AttendanceCounts[]>(this.endpoint, { params });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user