add attendance

This commit is contained in:
Chris Chen
2026-06-20 19:33:04 -07:00
parent 2af169fa60
commit 87425b3276
24 changed files with 1357 additions and 5 deletions
+4
View File
@@ -17,11 +17,15 @@ import { FinanceDashboardPageComponent } from './features/finance-dashboard/page
import { DisbursementPageComponent } from './features/disbursement/pages/disbursement-page/disbursement-page.component';
import { CheckRegisterPageComponent } from './features/disbursement/pages/check-register-page/check-register-page.component';
import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component';
import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component';
export const routes: Routes = [
// Public routes
{ path: 'login', component: LoginPage },
// Public Sunday meal attendance counter — no login required (volunteers on phones).
{ path: 'attendance', component: AttendanceCounterPageComponent },
// Keep the startup surface intentionally small: login + guarded mock dashboard.
{
path: 'user-portal',
@@ -0,0 +1,63 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { Router } from '@angular/router';
import { authInterceptor } from './auth.interceptor';
import { AuthService } from '../../shared/services/auth.service';
import { ApiConfigService } from '../services/api-config.service';
describe('authInterceptor', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
let apiConfig: ApiConfigService;
let router: jasmine.SpyObj<Router>;
beforeEach(() => {
router = jasmine.createSpyObj<Router>('Router', ['navigate']);
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
AuthService,
ApiConfigService,
{ provide: Router, useValue: router },
],
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
apiConfig = TestBed.inject(ApiConfigService);
});
afterEach(() => httpMock.verify());
it('does NOT redirect to login when a public auth request (refresh) returns 401', () => {
// Startup session-restore: an unauthenticated visitor has no refresh cookie, so
// POST /auth/refresh returns 401. This must NOT bounce them to /login — public
// routes like /attendance need to stay put. refresh() handles its own 401.
http.post(`${apiConfig.authUrl}/refresh`, {}).subscribe({ error: () => {} });
httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush(
{ message: 'No cookie' },
{ status: 401, statusText: 'Unauthorized' },
);
expect(router.navigate).not.toHaveBeenCalled();
});
it('redirects to login when a protected request returns 401 after refresh fails', () => {
http.get(`${apiConfig.getBaseUrl()}/expenses`).subscribe({ error: () => {} });
// Original protected call 401s -> interceptor attempts a silent refresh...
httpMock.expectOne(`${apiConfig.getBaseUrl()}/expenses`).flush(
null, { status: 401, statusText: 'Unauthorized' },
);
// ...refresh also 401s (no valid cookie) -> genuine auth failure.
httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush(
null, { status: 401, statusText: 'Unauthorized' },
);
expect(router.navigate).toHaveBeenCalledWith(['/login']);
// logout() fires a fire-and-forget POST /auth/logout; flush it so verify() stays clean.
httpMock.expectOne(`${apiConfig.authUrl}/logout`).flush(null, { status: 204, statusText: 'No Content' });
});
});
@@ -39,7 +39,11 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
);
}
if (error.status === 401) {
// Redirect on a genuine auth failure, but NOT for the public auth endpoints
// (login / refresh / logout). The startup session-restore POSTs /auth/refresh on
// every page load; for an unauthenticated visitor that 401s, and bouncing them to
// /login here would break public routes like /attendance. refresh() handles its own 401.
if (error.status === 401 && !isPublicAuthRequest(req, apiConfig)) {
authService.logout();
router.navigate(['/login']);
}
@@ -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';
@@ -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>
@@ -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;
}
@@ -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 });
}
}
@@ -64,8 +64,56 @@
</div>
</div>
<!-- Recent Transactions -->
<!-- Sunday Attendance Trend -->
<div class="section">
<div class="section-header">
<h2>Sunday Attendance · 主日出席人數</h2>
</div>
<div class="attendance-chart">
<kendo-chart *ngIf="hasAttendanceData" [style.height.px]="340">
<kendo-chart-legend position="bottom"></kendo-chart-legend>
<kendo-chart-category-axis>
<kendo-chart-category-axis-item [categories]="attendanceDates">
</kendo-chart-category-axis-item>
</kendo-chart-category-axis>
<kendo-chart-series>
<kendo-chart-series-item type="column" [stack]="true" [data]="attendanceKid" name="Kid · 兒童"
color="#42a5f5">
<kendo-chart-series-item-labels [visible]="true" position="center" color="#ffffff"
background="transparent" font="bold 13px sans-serif" [content]="segmentLabelContent">
</kendo-chart-series-item-labels>
</kendo-chart-series-item>
<kendo-chart-series-item type="column" [stack]="true" [data]="attendanceYouth" name="Youth · 青少年"
color="#66bb6a">
<kendo-chart-series-item-labels [visible]="true" position="center" color="#ffffff"
background="transparent" font="bold 13px sans-serif" [content]="segmentLabelContent">
</kendo-chart-series-item-labels>
</kendo-chart-series-item>
<kendo-chart-series-item type="column" [stack]="true" [data]="attendanceAdult" name="Adult · 成人"
color="#ef5350">
<kendo-chart-series-item-labels [visible]="true" position="center" color="#ffffff"
background="transparent" font="bold 13px sans-serif" [content]="segmentLabelContent">
</kendo-chart-series-item-labels>
</kendo-chart-series-item>
<!-- Invisible line series whose only job is to carry the total label on top of each bar. -->
<kendo-chart-series-item type="line" [data]="attendanceTotal" [visibleInLegend]="false"
color="transparent" [width]="0" [markers]="{ visible: false }"
[highlight]="{ visible: false }" [tooltip]="{ visible: false }">
<kendo-chart-series-item-labels [visible]="true" position="above" color="#374151"
font="bold 13px sans-serif" [content]="totalLabelContent">
</kendo-chart-series-item-labels>
</kendo-chart-series-item>
</kendo-chart-series>
<kendo-chart-tooltip [shared]="true" background="#1f2937" color="#ffffff">
</kendo-chart-tooltip>
</kendo-chart>
<p *ngIf="!hasAttendanceData" class="attendance-empty">
No attendance recorded yet · 尚無出席紀錄
</p>
</div>
</div>
<!-- Quick Actions -->
<div class="section">
@@ -534,3 +534,17 @@
}
}
}
.attendance-chart {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 1rem;
}
.attendance-empty {
text-align: center;
color: #94a3b8;
padding: 2rem 0;
margin: 0;
}
@@ -1,6 +1,8 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ChartsModule } from '@progress/kendo-angular-charts';
import { AuthService, UserInfo } from '../../../../shared/services/auth.service';
import { MealAttendanceApiService } from '../../../../features/meal-attendance/services/meal-attendance-api.service';
interface Transaction {
id: string;
@@ -15,13 +17,21 @@ interface Transaction {
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, ChartsModule],
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss']
})
export class DashboardComponent implements OnInit {
currentUser: UserInfo | null = null;
// Sunday attendance trend (last 8 weeks) for the chart.
attendanceDates: string[] = [];
attendanceAdult: number[] = [];
attendanceYouth: number[] = [];
attendanceKid: number[] = [];
attendanceTotal: number[] = [];
hasAttendanceData = false;
activeTransactions = 5;
pendingTasks = 12;
completedTransactions = 23;
@@ -57,15 +67,60 @@ export class DashboardComponent implements OnInit {
}
];
constructor(private authService: AuthService) { }
constructor(
private authService: AuthService,
private attendanceApi: MealAttendanceApiService,
) { }
ngOnInit(): void {
this.authService.currentUser$.subscribe(user => {
this.currentUser = user;
});
this.loadAttendance();
}
getDisplayName(): string {
return this.currentUser?.email || '';
}
// Per-segment label: show the count inside its colored block, but hide zeros to avoid clutter.
segmentLabelContent = (args: { value: number }): string => {
return args.value > 0 ? String(args.value) : '';
};
// Total label sitting on top of each stacked bar.
totalLabelContent = (args: { value: number }): string => {
return args.value > 0 ? String(args.value) : '';
};
private loadAttendance(): void {
const today = new Date();
const from = new Date();
from.setDate(today.getDate() - 56); // last ~8 weeks
this.attendanceApi.getRange(this.toLocalIso(from), this.toLocalIso(today)).subscribe({
next: rows => {
this.attendanceDates = rows.map(r => this.toShortLabel(r.date));
this.attendanceAdult = rows.map(r => r.adult);
this.attendanceYouth = rows.map(r => r.youth);
this.attendanceKid = rows.map(r => r.kid);
this.attendanceTotal = rows.map(r => r.adult + r.youth + r.kid);
this.hasAttendanceData = rows.length > 0;
},
error: () => { this.hasAttendanceData = false; },
});
}
// Local yyyy-MM-dd (never toISOString — it shifts the day by timezone).
private toLocalIso(d: Date): string {
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${month}-${day}`;
}
// 'yyyy-MM-dd' → 'M/d' for the axis label (split to avoid timezone parsing).
private toShortLabel(isoDate: string): string {
const [, month, day] = isoDate.split('-');
return `${Number(month)}/${Number(day)}`;
}
}