add attendance
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/meal-attendance")]
|
||||
public class MealAttendanceController : ControllerBase
|
||||
{
|
||||
private readonly IMealAttendanceService _svc;
|
||||
|
||||
public MealAttendanceController(IMealAttendanceService svc) => _svc = svc;
|
||||
|
||||
/// <summary>Today's live counts. Public — feeds the volunteer counter page on first load.</summary>
|
||||
[HttpGet("today")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetToday()
|
||||
=> Ok(await _svc.GetOrCreateAsync(_svc.Today));
|
||||
|
||||
/// <summary>Daily counts within a date range, for the back-office dashboard chart.</summary>
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to)
|
||||
=> Ok(await _svc.GetRangeAsync(from, to));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ROLAC.API.DTOs.MealAttendance;
|
||||
|
||||
/// <summary>The current head-count for one Sunday, broadcast over SignalR.</summary>
|
||||
public class AttendanceCountsDto
|
||||
{
|
||||
public string Date { get; set; } = ""; // yyyy-MM-dd (local)
|
||||
public int Adult { get; set; }
|
||||
public int Youth { get; set; }
|
||||
public int Kid { get; set; }
|
||||
}
|
||||
@@ -22,6 +22,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
|
||||
public DbSet<Check> Checks => Set<Check>();
|
||||
public DbSet<CheckLine> CheckLines => Set<CheckLine>();
|
||||
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
@@ -284,6 +285,17 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
.HasForeignKey(e => e.ExpenseId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// ── MealAttendance (one shared row per Sunday) ───────────────────────
|
||||
builder.Entity<MealAttendance>(entity =>
|
||||
{
|
||||
entity.Property(e => e.AdultCount).HasDefaultValue(0);
|
||||
entity.Property(e => e.YouthCount).HasDefaultValue(0);
|
||||
entity.Property(e => e.KidCount).HasDefaultValue(0);
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
entity.HasIndex(e => e.AttendanceDate).IsUnique();
|
||||
});
|
||||
|
||||
// ── MonthlyStatement ─────────────────────────────────────────────────
|
||||
builder.Entity<MonthlyStatement>(entity =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using ROLAC.API.Entities.Base;
|
||||
|
||||
namespace ROLAC.API.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One row per Sunday holding the live shared head-count for the three
|
||||
/// age groups. Volunteers increment these concurrently from the public
|
||||
/// counter page; the columns are updated with atomic SQL increments.
|
||||
/// </summary>
|
||||
public class MealAttendance : AuditableEntity
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateOnly AttendanceDate { get; set; }
|
||||
public int AdultCount { get; set; }
|
||||
public int YouthCount { get; set; }
|
||||
public int KidCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Real-time hub backing the public Sunday attendance counter. Anonymous
|
||||
/// (no [Authorize]) so volunteers can use it without logging in. Every
|
||||
/// increment is broadcast to all connected clients so multiple people can
|
||||
/// count the same Sunday together and see each other's changes instantly.
|
||||
/// </summary>
|
||||
public class AttendanceHub : Hub
|
||||
{
|
||||
private readonly IMealAttendanceService _svc;
|
||||
|
||||
public AttendanceHub(IMealAttendanceService svc) => _svc = svc;
|
||||
|
||||
// Push the current counts to a client the moment it connects.
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
var counts = await _svc.GetOrCreateAsync(_svc.Today);
|
||||
await Clients.Caller.SendAsync("ReceiveCounts", counts);
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
// Apply a batched delta for one age group, then broadcast the new totals to everyone.
|
||||
public async Task Increment(string category, int delta)
|
||||
{
|
||||
var counts = await _svc.IncrementAsync(_svc.Today, category, delta);
|
||||
await Clients.All.SendAsync("ReceiveCounts", counts);
|
||||
}
|
||||
|
||||
// Overwrite one age group with an absolute value, then broadcast the new totals to everyone.
|
||||
public async Task SetCount(string category, int value)
|
||||
{
|
||||
var counts = await _svc.SetAsync(_svc.Today, category, value);
|
||||
await Clients.All.SendAsync("ReceiveCounts", counts);
|
||||
}
|
||||
}
|
||||
@@ -885,6 +885,56 @@ namespace ROLAC.API.Migrations
|
||||
b.ToTable("GivingCategories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.MealAttendance", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AdultCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<DateOnly>("AttendanceDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<int>("KidCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<int>("YouthCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AttendanceDate")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MealAttendances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.Member", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
||||
@@ -133,6 +133,10 @@ builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
|
||||
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
|
||||
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
|
||||
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
||||
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
||||
|
||||
// Real-time hub for the live Sunday attendance counter.
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Swagger / MVC
|
||||
@@ -207,6 +211,7 @@ app.UseCors("Angular");
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
app.MapHub<ROLAC.API.Hubs.AttendanceHub>("/hubs/attendance");
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using ROLAC.API.DTOs.MealAttendance;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
public interface IMealAttendanceService
|
||||
{
|
||||
/// <summary>Today's date in the server's local time zone (the church's "current Sunday").</summary>
|
||||
DateOnly Today { get; }
|
||||
|
||||
/// <summary>Returns the counts for <paramref name="date"/>, creating a zeroed row if none exists.</summary>
|
||||
Task<AttendanceCountsDto> GetOrCreateAsync(DateOnly date);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically adds <paramref name="delta"/> (may be negative) to one age-group column,
|
||||
/// clamped at zero, and returns the resulting authoritative counts.
|
||||
/// </summary>
|
||||
Task<AttendanceCountsDto> IncrementAsync(DateOnly date, string category, int delta);
|
||||
|
||||
/// <summary>
|
||||
/// Sets one age-group column to an absolute <paramref name="value"/> (clamped at zero),
|
||||
/// overwriting the current count, and returns the resulting authoritative counts.
|
||||
/// </summary>
|
||||
Task<AttendanceCountsDto> SetAsync(DateOnly date, string category, int value);
|
||||
|
||||
/// <summary>Returns the daily counts within the inclusive date range, ordered by date (for the dashboard).</summary>
|
||||
Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.MealAttendance;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
public class MealAttendanceService : IMealAttendanceService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public MealAttendanceService(AppDbContext db) => _db = db;
|
||||
|
||||
// Server local time is assumed to match the church's local day.
|
||||
public DateOnly Today => DateOnly.FromDateTime(DateTime.Now);
|
||||
|
||||
public async Task<AttendanceCountsDto> GetOrCreateAsync(DateOnly date)
|
||||
{
|
||||
await EnsureRowAsync(date);
|
||||
return await ReadAsync(date);
|
||||
}
|
||||
|
||||
public async Task<AttendanceCountsDto> IncrementAsync(DateOnly date, string category, int delta)
|
||||
{
|
||||
await EnsureRowAsync(date);
|
||||
|
||||
var rows = _db.MealAttendances.Where(a => a.AttendanceDate == date);
|
||||
|
||||
// Atomic, race-free server-side increment; never let a column go below zero.
|
||||
switch (category.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "adult":
|
||||
await rows.ExecuteUpdateAsync(s => s.SetProperty(
|
||||
a => a.AdultCount, a => a.AdultCount + delta < 0 ? 0 : a.AdultCount + delta));
|
||||
break;
|
||||
case "youth":
|
||||
await rows.ExecuteUpdateAsync(s => s.SetProperty(
|
||||
a => a.YouthCount, a => a.YouthCount + delta < 0 ? 0 : a.YouthCount + delta));
|
||||
break;
|
||||
case "kid":
|
||||
await rows.ExecuteUpdateAsync(s => s.SetProperty(
|
||||
a => a.KidCount, a => a.KidCount + delta < 0 ? 0 : a.KidCount + delta));
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException($"Unknown attendance category '{category}'.", nameof(category));
|
||||
}
|
||||
|
||||
return await ReadAsync(date);
|
||||
}
|
||||
|
||||
public async Task<AttendanceCountsDto> SetAsync(DateOnly date, string category, int value)
|
||||
{
|
||||
await EnsureRowAsync(date);
|
||||
|
||||
// Counts can never be negative; clamp before writing.
|
||||
var clamped = value < 0 ? 0 : value;
|
||||
|
||||
var rows = _db.MealAttendances.Where(a => a.AttendanceDate == date);
|
||||
|
||||
switch (category.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "adult":
|
||||
await rows.ExecuteUpdateAsync(s => s.SetProperty(a => a.AdultCount, clamped));
|
||||
break;
|
||||
case "youth":
|
||||
await rows.ExecuteUpdateAsync(s => s.SetProperty(a => a.YouthCount, clamped));
|
||||
break;
|
||||
case "kid":
|
||||
await rows.ExecuteUpdateAsync(s => s.SetProperty(a => a.KidCount, clamped));
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException($"Unknown attendance category '{category}'.", nameof(category));
|
||||
}
|
||||
|
||||
return await ReadAsync(date);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to)
|
||||
{
|
||||
var rows = await _db.MealAttendances.AsNoTracking()
|
||||
.Where(a => a.AttendanceDate >= from && a.AttendanceDate <= to)
|
||||
.OrderBy(a => a.AttendanceDate)
|
||||
.ToListAsync();
|
||||
|
||||
return rows.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Insert a zeroed row if today's row doesn't exist yet. A concurrent insert
|
||||
// that wins the unique index throws DbUpdateException — harmless, the row exists.
|
||||
private async Task EnsureRowAsync(DateOnly date)
|
||||
{
|
||||
if (await _db.MealAttendances.AsNoTracking().AnyAsync(a => a.AttendanceDate == date))
|
||||
return;
|
||||
|
||||
var row = new MealAttendance { AttendanceDate = date };
|
||||
_db.MealAttendances.Add(row);
|
||||
try
|
||||
{
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
_db.Entry(row).State = EntityState.Detached;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AttendanceCountsDto> ReadAsync(DateOnly date)
|
||||
{
|
||||
var row = await _db.MealAttendances.AsNoTracking()
|
||||
.FirstOrDefaultAsync(a => a.AttendanceDate == date);
|
||||
return row is null
|
||||
? new AttendanceCountsDto { Date = date.ToString("yyyy-MM-dd") }
|
||||
: ToDto(row);
|
||||
}
|
||||
|
||||
private static AttendanceCountsDto ToDto(MealAttendance row) => new()
|
||||
{
|
||||
Date = row.AttendanceDate.ToString("yyyy-MM-dd"),
|
||||
Adult = row.AdultCount,
|
||||
Youth = row.YouthCount,
|
||||
Kid = row.KidCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "rolac-app",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["start"],
|
||||
"port": 4200
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+193
-1
@@ -16,6 +16,7 @@
|
||||
"@angular/localize": "^20.1.6",
|
||||
"@angular/platform-browser": "^20.1.0",
|
||||
"@angular/router": "^20.1.0",
|
||||
"@microsoft/signalr": "^8.0.17",
|
||||
"@progress/kendo-angular-buttons": "^20.0.0",
|
||||
"@progress/kendo-angular-charts": "^20.0.0",
|
||||
"@progress/kendo-angular-common": "^20.0.0",
|
||||
@@ -2082,6 +2083,49 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@microsoft/signalr": {
|
||||
"version": "8.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.17.tgz",
|
||||
"integrity": "sha512-5pM6xPtKZNJLO0Tq5nQasVyPFwi/WBY3QB5uc/v3dIPTpS1JXQbaXAQAPxFoQ5rTBFE094w8bbqkp17F9ReQvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"eventsource": "^2.0.2",
|
||||
"fetch-cookie": "^2.0.3",
|
||||
"node-fetch": "^2.6.7",
|
||||
"ws": "^7.5.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/signalr/node_modules/eventsource": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
|
||||
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/signalr/node_modules/ws": {
|
||||
"version": "7.5.11",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
|
||||
"integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": "^5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.17.3",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.3.tgz",
|
||||
@@ -5334,6 +5378,18 @@
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -6688,6 +6744,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
@@ -6840,6 +6905,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-cookie": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
|
||||
"integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"set-cookie-parser": "^2.4.8",
|
||||
"tough-cookie": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -9254,6 +9329,26 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp": {
|
||||
"version": "11.4.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.4.2.tgz",
|
||||
@@ -10224,6 +10319,27 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/lupomontero"
|
||||
}
|
||||
},
|
||||
"node_modules/psl/node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
|
||||
@@ -10257,6 +10373,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -10328,7 +10450,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
@@ -10624,6 +10745,12 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -11393,6 +11520,45 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
|
||||
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"psl": "^1.1.33",
|
||||
"punycode": "^2.1.1",
|
||||
"universalify": "^0.2.0",
|
||||
"url-parse": "^1.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie/node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie/node_modules/universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -11587,6 +11753,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/url-parse": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
@@ -11747,6 +11923,22 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@angular/localize": "^20.1.6",
|
||||
"@angular/platform-browser": "^20.1.0",
|
||||
"@angular/router": "^20.1.0",
|
||||
"@microsoft/signalr": "^8.0.17",
|
||||
"@progress/kendo-angular-buttons": "^20.0.0",
|
||||
"@progress/kendo-angular-charts": "^20.0.0",
|
||||
"@progress/kendo-angular-common": "^20.0.0",
|
||||
|
||||
@@ -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';
|
||||
+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 });
|
||||
}
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user