diff --git a/apps/backoffice/src/App.tsx b/apps/backoffice/src/App.tsx index fc44fc6..daccadc 100644 --- a/apps/backoffice/src/App.tsx +++ b/apps/backoffice/src/App.tsx @@ -1,5 +1,6 @@ import { formatDisplayDate } from "./dateDisplay"; import { forwardRef, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"; +import { DashboardUpdatesPanel } from "./modules/dashboard/DashboardUpdatesPanel"; import { OperationsCalendar } from "./modules/shared/OperationsCalendar"; import { CollectionAgeingPanel } from "./modules/dashboard/CollectionAgeingPanel"; import { DataFreshness } from "./modules/shared/DataFreshness"; @@ -355,7 +356,7 @@ const repairReceiptOcrSections = { } satisfies Record; const bilingual = { - dashboard: "Dashboard / 管理层分析", + dashboard: "Dashboard / 工作看板", vehicles: "Vehicles / 收车库存", repairs: "Repair / 整备", loans: "Loan / 贷款", @@ -1317,9 +1318,13 @@ export default function App() { }} />
- {pathname === "/dashboard" && refreshDashboard()} onAnalyticsPeriodChange={changeDashboardPeriod} onNavigate={navigateTo} onOpenDelivery={currentRoles.includes("BossAdmin") || currentRoles.includes("Delivery") ? (deliveryId) => navigateTo(`/delivery?deliveryId=${encodeURIComponent(deliveryId)}`) : undefined} />} + {pathname === "/dashboard" &&
+ + {currentRoles.includes("BossAdmin") ? refreshDashboard()} onAnalyticsPeriodChange={changeDashboardPeriod} onNavigate={navigateTo} onOpenDelivery={currentRoles.includes("BossAdmin") || currentRoles.includes("Delivery") ? (deliveryId) => navigateTo(`/delivery?deliveryId=${encodeURIComponent(deliveryId)}`) : undefined} /> : navigateTo(`/delivery?deliveryId=${encodeURIComponent(id)}`) : undefined} />} +
} {pathname === "/vehicles" && (
-
+ {(pathname !== "/dashboard" || roles.includes("BossAdmin")) &&
{stats.map((stat) => ( {stat.value} {stat.label} ))} -
+
} } + {item.louDocumentId && item.vehicleId && getVehicleDocumentContent(item.vehicleId!, item.louDocumentId!)} />} + + + ; + return void refresh()}>Refresh / 刷新} style={{ marginBottom: 16 }}> + {error ? : + + + News / 公司动态 + Recent 30 days · latest 60 updates / 近30天,最新60项 + }} /> + + + {roles.includes("BossAdmin") ? "Pending work / 待办事项" : "My reminders / 我的待办"} + }} /> + + + } + ; +} diff --git a/apps/backoffice/src/modules/dashboard/dashboardWorkspace.css b/apps/backoffice/src/modules/dashboard/dashboardWorkspace.css index 918af29..56a6830 100644 --- a/apps/backoffice/src/modules/dashboard/dashboardWorkspace.css +++ b/apps/backoffice/src/modules/dashboard/dashboardWorkspace.css @@ -200,3 +200,19 @@ .dashboardInboxFilters .ant-select { width: 100%; } .dashboardWorkspace .dashboardSupportingMetrics .metricCard { grid-template-columns: minmax(0, 1fr); } } + +/* Pro Components v3 wraps the title and actions inside the Ant Card header. */ +.dashboardOperational > .ant-pro-card > .ant-pro-card-header > .ant-card-head-wrapper { + width: 100%; + min-width: 0; + flex-wrap: wrap; + gap: 12px; +} +.dashboardOperational > .ant-pro-card > .ant-pro-card-header .ant-pro-card-title { + flex: 1 1 180px; + white-space: normal; +} +.dashboardOperational > .ant-pro-card > .ant-pro-card-header .ant-pro-card-extra { + flex: 0 1 auto; + max-width: 100%; +} diff --git a/apps/backoffice/src/modules/leads/SalesMyCarsPanel.tsx b/apps/backoffice/src/modules/leads/SalesMyCarsPanel.tsx index a4aa2d7..58d02ba 100644 --- a/apps/backoffice/src/modules/leads/SalesMyCarsPanel.tsx +++ b/apps/backoffice/src/modules/leads/SalesMyCarsPanel.tsx @@ -29,17 +29,20 @@ export function filterSalesMyCars(items: readonly SalesWorkboardItem[], keyword? export function SalesMyCarsPanel({ currentUser, initialData, + initialKeyword = "", autoLoad = true }: { currentUser: CurrentUser | null; initialData?: SalesWorkboard; + initialKeyword?: string; autoLoad?: boolean; }) { const [data, setData] = useState(initialData); const [loading, setLoading] = useState(autoLoad); const [loadError, setLoadError] = useState(); const [agentUserId, setAgentUserId] = useState("All"); - const [keyword, setKeyword] = useState(""); + const [keyword, setKeyword] = useState(initialKeyword); + useEffect(() => { setKeyword(initialKeyword); }, [initialKeyword]); const [mobilePage, setMobilePage] = useState(1); const [priceDrafts, setPriceDrafts] = useState>({}); const [priceSavingFor, setPriceSavingFor] = useState(null); diff --git a/apps/backoffice/src/modules/vehicles/VehiclePage.tsx b/apps/backoffice/src/modules/vehicles/VehiclePage.tsx index 775e815..0ce6514 100644 --- a/apps/backoffice/src/modules/vehicles/VehiclePage.tsx +++ b/apps/backoffice/src/modules/vehicles/VehiclePage.tsx @@ -879,6 +879,7 @@ export function filterOperationIntakeVehicles( } export function VehiclePage({ + initialVehicleId, vehicles, leads, loans, @@ -907,6 +908,7 @@ export function VehiclePage({ onUploadPhoto, onUploadDocument }: { + initialVehicleId?: string; vehicles: Vehicle[]; leads: Lead[]; loans: LoanApplication[]; @@ -1391,6 +1393,17 @@ export function VehiclePage({ setVehicleDetailOpen(true); }; + const openedRouteVehicle = useRef(undefined); + useEffect(() => { + if (!initialVehicleId || openedRouteVehicle.current === initialVehicleId || !vehicles.some(vehicle => vehicle.id === initialVehicleId)) return; + openedRouteVehicle.current = initialVehicleId; + setEditVehicleId(initialVehicleId); + setUploadVehicleId(initialVehicleId); + setVehicleDetailTab("overview"); + setVehicleAssetTab("documents"); + setVehicleDetailOpen(true); + }, [initialVehicleId, vehicles]); + const openVehicleAssets = (assetTab: "documents" | "photos") => { setVehicleDetailTab("documents"); setVehicleAssetTab(assetTab); diff --git a/docs/API.md b/docs/API.md index e1f1693..ef13ea7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -544,6 +544,7 @@ Collection ageing preserves the headline formula: legacy non-Reconciled payment | Method | Path | Policy | Purpose | | --- | --- | --- | --- | +| `GET` | `/api/dashboard/updates` | `BackOffice` | Operational news (latest 60 from 30 days) and pending work scoped to saved broker/delivery/task ownership; Boss/Admin sees all pending work. No costs, customer identities or finance metrics. LOU links retain existing document permissions. | | `GET` | `/api/dashboard/summary?from=YYYY-MM-DD&to=YYYY-MM-DD` | `Dashboard` | Boss/Admin operational metrics. `from` and `to` are optional but must be supplied together as an inclusive analytics period. Live stock, loan, collection, settlement, purchase cost, repair cost, and aging metrics remain current; the period scopes sales, actual profit, lead, refurbishment, and aggregate OCR activity analysis. The response preserves `totalProfit` and `estimatedProfit`, and adds `purchaseCost`, `totalSales`, `actualProfit`, `outstandingCollection`, `settlementDueAmount`, `refurbishment`, and `aiDocumentProcessing`. OCR reporting contains aggregate category counts, reviewed outcomes, low-confidence and failed counts, the live pending-review backlog, and current quota capacity only; it never returns document IDs, file names, images, identity data, raw OCR text, or extracted values. | | `GET` | `/api/dashboard/reminders?type={type}&due={All\|Overdue\|DueToday\|DueSoon\|Upcoming}` | `Dashboard` | Reminder inbox, optionally filtered. `DueSoon` applies only to unpaid Daily Spend due from tomorrow through the next 10 calendar days. | | `GET` | `/api/dashboard/collection-ageing` | `Dashboard` | Current outstanding-collection amount/count in five buckets: `NotYetDue` (includes today), `Overdue1To7`, `Overdue8To30`, `OverdueOver30`, `UnknownDueDate`. Returns `asOfDate` in `Asia/Singapore`, totals and aggregate buckets only. Independent of the analytics period. | diff --git a/docs/FOO-262-dashboard-updates.md b/docs/FOO-262-dashboard-updates.md new file mode 100644 index 0000000..4e6fb9f --- /dev/null +++ b/docs/FOO-262-dashboard-updates.md @@ -0,0 +1,11 @@ +# Dashboard operational updates + +Scope: slides 1 and 3–5, as authorized with items 2–4. Add recent stock, loan, confirmed-deal and delivery news plus personal loan, missing deal-price, delivery and assigned-task reminders. Reuse saved ownership and activity records. Keep management analytics and finance APIs Boss-only; staff receive a separate allowlisted operational response. Do not change WhatsApp delivery or task completion workflows. + +Acceptance: employees can open Dashboard without receiving management metrics, costs, customer details or other employees' personal reminders. News links respect existing module/document permissions. Completed/cancelled records leave the pending list on refresh. Unknown loan handling staff remain labelled Pending assignment / 待分配 rather than inferred as an assignment. Existing department landing pages remain unchanged. + +Implementation: a read-only API projector and endpoint, API client contract, shared operational panel, and role-aware Dashboard composition. No new tables, dependencies or document permissions. LOU links only use existing LoanDocument authorization; Broker cannot gain access through this feed. + +Verification: extend existing business-rule tests for cross-user reminder isolation and sensitive-data exclusion; existing access tests for staff navigation without finance data. Build API/frontend and inspect populated mobile/tablet/desktop UI. Independent review before release. + +Existing audit events identify the last recorded loan handler, not a formal assignment. News reflects saved current records, not a new immutable notification history. Cancelled/released deliveries leave the operational feed. Broker deal-price reminders open Cars I’m Handling with the plate filter. diff --git a/services/api/src/YSHeng.Api/Features/DashboardUpdates.cs b/services/api/src/YSHeng.Api/Features/DashboardUpdates.cs new file mode 100644 index 0000000..b4a5a8b --- /dev/null +++ b/services/api/src/YSHeng.Api/Features/DashboardUpdates.cs @@ -0,0 +1,107 @@ +using Microsoft.EntityFrameworkCore; +using YSHeng.Api.Data; +using YSHeng.Api.Domain; + +namespace YSHeng.Api.Features; + +// Operational data is separate from the Boss-only finance dashboard. +public sealed record DashboardUpdate(string Id, string Kind, string Title, string Status, DateTime? OccurredAt, + Guid? VehicleId = null, string? Plate = null, string? Broker = null, string? RecordedBy = null, + DateOnly? Date = null, string? Time = null, int? DaysOpen = null, decimal? AskingPrice = null, + string? Path = null, Guid? LouDocumentId = null); +public sealed record DashboardUpdatesResponse(IReadOnlyList News, IReadOnlyList Reminders); +public sealed record DashboardLoanDocument(Guid Id, Guid? LoanApplicationId, Guid? VehicleId); + +public static class DashboardUpdates +{ + public static async Task LoadAsync(AppDbContext db, string userId, string actor, + string[] roles, DateOnly today) => Create(userId, actor, roles, today, + await db.Vehicles.AsNoTracking().ToListAsync(), await db.Leads.AsNoTracking().ToListAsync(), + await db.LoanApplications.AsNoTracking().ToListAsync(), await db.DeliverySchedules.AsNoTracking().ToListAsync(), + await db.DeliveryActivities.AsNoTracking().Where(x => x.Action == "Created").ToListAsync(), + await db.BrokerDealPrices.AsNoTracking().ToListAsync(), + await db.CalendarTasks.AsNoTracking().Where(x => x.Status == CalendarTaskStatus.Open).ToListAsync(), + await db.AuditLogs.AsNoTracking().Where(x => x.EntityName == nameof(LoanApplication)).ToListAsync(), + await db.DocumentBlobs.AsNoTracking().Where(x => x.Category == FileCategory.LoanDocument) + .Select(x => new DashboardLoanDocument(x.Id, x.LoanApplicationId, x.VehicleId)).ToListAsync()); + + public static DashboardUpdatesResponse Create(string userId, string actor, string[] roles, DateOnly today, + IReadOnlyList vehicles, IReadOnlyList leads, IReadOnlyList loans, + IReadOnlyList deliveries, IReadOnlyList deliveryActivities, + IReadOnlyList prices, IReadOnlyList tasks, IReadOnlyList audits, + IReadOnlyList documents) + { + var boss = roles.Contains("BossAdmin"); + var broker = roles.Contains("Sales"); + var loanStaff = roles.Contains("Loan"); + var deliveryStaff = roles.Contains("Delivery"); + var news = new List(); + var reminders = new List(); + var vehicleById = vehicles.ToDictionary(x => x.Id); + var cutoff = today.AddDays(-30); + bool Owns(Vehicle vehicle) + { + if (string.IsNullOrEmpty(userId)) return false; + if (!string.IsNullOrWhiteSpace(vehicle.SalesAgentUserId)) return vehicle.SalesAgentUserId == userId; + if (vehicle.Status == VehicleStatus.Sold) + return leads.Where(x => x.VehicleId == vehicle.Id && x.ClosureOutcome == LeadClosureOutcome.Sold && !string.IsNullOrWhiteSpace(x.TakenByUserId)) + .OrderByDescending(x => x.TakenAt ?? x.CreatedAt).FirstOrDefault()?.TakenByUserId == userId; + return leads.Any(x => x.VehicleId == vehicle.Id && x.Status != LeadStatus.Closed && x.TakenByUserId == userId); + } + string? VehiclePath(Vehicle vehicle) => boss || broker ? $"/vehicles?vehicleId={vehicle.Id}" : null; + foreach (var vehicle in vehicles) + { + if (vehicle.IntakeDate >= cutoff) + news.Add(new($"stock:{vehicle.Id}", "NewStock", $"{vehicle.Year} {vehicle.Make} {vehicle.Model}", + vehicle.Status.ToString(), vehicle.IntakeAt ?? vehicle.IntakeDate.ToDateTime(TimeOnly.MinValue), + vehicle.Id, vehicle.PlateNumber, Date: vehicle.IntakeDate, AskingPrice: vehicle.SellingPrice, Path: VehiclePath(vehicle))); + var price = prices.FirstOrDefault(x => x.VehicleId == vehicle.Id && x.Amount > 0); + if (vehicle.Status == VehicleStatus.Sold && vehicle.CustomerId.HasValue) + { + if (price is not null && (boss || broker || loanStaff) && DateOnly.FromDateTime(vehicle.SoldAt ?? price.UpdatedAt) >= cutoff) + news.Add(new($"deal:{vehicle.Id}", "Deal", "Deal confirmed / 已成交", "Sold", vehicle.SoldAt ?? price.UpdatedAt, + vehicle.Id, vehicle.PlateNumber, price.BrokerName, Path: VehiclePath(vehicle))); + if (price is null && (boss || broker && Owns(vehicle))) + reminders.Add(new($"price:{vehicle.Id}", "MissingDealPrice", "Fill deal price / 补成交价", "Pending", + vehicle.SoldAt, vehicle.Id, vehicle.PlateNumber, vehicle.SalesAgentName, Path: $"/leads?myCarsPlate={Uri.EscapeDataString(vehicle.PlateNumber)}")); + } + } + foreach (var loan in loans) + { + if (!vehicleById.TryGetValue(loan.VehicleId, out var vehicle)) continue; + var activity = audits.Where(x => x.EntityId == loan.Id).OrderBy(x => x.CreatedAt).ToList(); + var created = activity.FirstOrDefault(x => x.Action == "loan.created"); + var handled = activity.LastOrDefault(x => x.Action is "loan.updated" or "loan.approved" or "loan.rejected"); + var date = loan.SubmittedAt ?? (created is null ? (DateOnly?)null : DateOnly.FromDateTime(created.CreatedAt)); + var path = boss || loanStaff ? $"/loans?loanId={loan.Id}" : VehiclePath(vehicle); + var item = new DashboardUpdate($"loan:{loan.Id}", "Loan", "Loan case / 贷款个案", loan.Status.ToString(), + created?.CreatedAt, vehicle.Id, vehicle.PlateNumber, vehicle.SalesAgentName, handled?.Actor, date, + DaysOpen: date.HasValue ? Math.Max(0, today.DayNumber - date.Value.DayNumber) : null, Path: path); + if ((boss || broker || loanStaff) && date >= cutoff) news.Add(item); + if ((boss || broker || loanStaff) && loan.LouApproved && loan.DecisionAt.HasValue && DateOnly.FromDateTime(loan.DecisionAt.Value) >= cutoff) + news.Add(item with { Id = $"approved:{loan.Id}", Kind = "LoanApproved", Title = "Loan approved / 贷款已批", + OccurredAt = loan.DecisionAt, RecordedBy = loan.DecisionBy, + LouDocumentId = DepartmentAccess.CanViewDocument(roles, FileCategory.LoanDocument) + ? documents.FirstOrDefault(x => x.LoanApplicationId == loan.Id && x.VehicleId == vehicle.Id)?.Id : null }); + var ownLoan = broker && Owns(vehicle) || loanStaff && !string.IsNullOrWhiteSpace(actor) && activity.Any(x => x.Actor == actor); + if (loan.Status is not (LoanStatus.Done or LoanStatus.Rejected) && (boss || ownLoan)) reminders.Add(item); + } + foreach (var delivery in deliveries) + { + if (delivery.Status is DeliveryStatus.BookingInspection or DeliveryStatus.Cancelled or DeliveryStatus.Released || + !vehicleById.TryGetValue(delivery.VehicleId, out var vehicle)) continue; + var booking = deliveryActivities.Where(x => x.DeliveryScheduleId == delivery.Id && x.Action == "Created").OrderBy(x => x.CreatedAt).FirstOrDefault(); + var item = new DashboardUpdate($"delivery:{delivery.Id}", "Delivery", "Prepare delivery / 准备交车", delivery.Status.ToString(), + booking?.CreatedAt, vehicle.Id, vehicle.PlateNumber, vehicle.SalesAgentName, booking?.ActorName, + delivery.ScheduledDate, delivery.ScheduledTime?.ToString("HH:mm"), + Path: boss || deliveryStaff ? $"/delivery?deliveryId={delivery.Id}" : VehiclePath(vehicle)); + if (delivery.ScheduledDate >= cutoff) news.Add(item); + if (boss || !string.IsNullOrEmpty(userId) && (delivery.PicUserId == userId || booking?.ActorUserId == userId) || broker && Owns(vehicle)) reminders.Add(item); + } + foreach (var task in tasks.Where(x => x.Status == CalendarTaskStatus.Open && (boss || !string.IsNullOrEmpty(userId) && x.AssignedToUserId == userId))) + reminders.Add(new($"task:{task.Id}", "Task", task.Title, "Open", task.CreatedAt, + RecordedBy: task.AssignedToName, Date: task.StartDate, Time: task.StartTime?.ToString("HH:mm"))); + return new(news.OrderByDescending(x => x.OccurredAt ?? x.Date?.ToDateTime(TimeOnly.MinValue)).Take(60).ToList(), + reminders.OrderBy(x => x.Date ?? DateOnly.MaxValue).ThenBy(x => x.Id).ToList()); + } +} diff --git a/services/api/src/YSHeng.Api/Program.cs b/services/api/src/YSHeng.Api/Program.cs index e042b91..54c7c5b 100644 --- a/services/api/src/YSHeng.Api/Program.cs +++ b/services/api/src/YSHeng.Api/Program.cs @@ -5261,6 +5261,14 @@ await db.DeliverySchedules.AsNoTracking().ToListAsync(), return Results.Ok(saved); }); +backOffice.MapGet("/dashboard/updates", async (AppDbContext db, HttpContext context) => +{ + context.Response.Headers.CacheControl = "no-store"; + var roles = SeedData.Roles.Where(context.User.IsInRole).ToArray(); + return Results.Ok(await DashboardUpdates.LoadAsync(db, StaffIdentity.CurrentUserId(context), + AuditTrail.ActorFrom(context.User), roles, BusinessClock.Today())); +}); + backOffice.MapGet("/dashboard/collection-ageing", async (AppDbContext db, CancellationToken cancellationToken) => { var today = BusinessClock.Today(); diff --git a/services/api/tests/YSHeng.Api.Tests/BusinessRulesTests.cs b/services/api/tests/YSHeng.Api.Tests/BusinessRulesTests.cs index 86f686b..1827e1f 100644 --- a/services/api/tests/YSHeng.Api.Tests/BusinessRulesTests.cs +++ b/services/api/tests/YSHeng.Api.Tests/BusinessRulesTests.cs @@ -21,6 +21,56 @@ namespace YSHeng.Api.Tests; public sealed class BusinessRulesTests { + [Fact] + public void Dashboard_updates_keep_personal_reminders_and_loan_documents_scoped() + { + var today = new DateOnly(2026, 11, 14); + var mine = new Vehicle { PlateNumber = "OWN1", SalesAgentUserId = "broker-a", Status = VehicleStatus.Sold, + CustomerId = Guid.NewGuid(), IntakeDate = today, PurchasePrice = 987654m, ModifiedPurchasePrice = 876543m }; + var other = mine with { Id = Guid.NewGuid(), PlateNumber = "OTHER2", SalesAgentUserId = "broker-b" }; + var loan = new LoanApplication { VehicleId = mine.Id, Status = LoanStatus.Approved, LouApproved = true, + SubmittedAt = today.AddDays(-3), DecisionAt = today.ToDateTime(TimeOnly.MinValue) }; + var otherLoan = loan with { Id = Guid.NewGuid(), VehicleId = other.Id }; + var document = new DashboardLoanDocument(Guid.NewGuid(), loan.Id, mine.Id); + var tasks = new[] { new CalendarTask { AssignedToUserId = "broker-a", Title = "Mine" }, + new CalendarTask { AssignedToUserId = "broker-b", Title = "Private other task" } }; + var result = DashboardUpdates.Create("broker-a", "broker-a", ["Sales"], today, + [mine, other], [], [loan, otherLoan], [], [], [], tasks, [], [document]); + Assert.Contains(result.Reminders, x => x.Kind == "MissingDealPrice" && x.VehicleId == mine.Id); + Assert.Contains(result.Reminders, x => x.Kind == "Loan" && x.VehicleId == mine.Id && x.DaysOpen == 3); + Assert.DoesNotContain(result.Reminders, x => x.VehicleId == other.Id || x.Title == "Private other task"); + Assert.All(result.News, x => Assert.Null(x.LouDocumentId)); + var json = JsonSerializer.Serialize(result); + Assert.DoesNotContain("987654", json); + Assert.DoesNotContain("876543", json); + Assert.DoesNotContain(mine.CustomerId.Value.ToString(), json); + var boss = DashboardUpdates.Create("boss", "boss", ["BossAdmin"], today, + [mine, other], [], [loan, otherLoan], [], [], [], tasks, [], [document]); + Assert.Contains(boss.News, x => x.LouDocumentId == document.Id); + } + + [Fact] + public void Dashboard_updates_remove_finished_work_and_respect_durable_broker_assignment() + { + var today = new DateOnly(2026, 11, 14); + var vehicle = new Vehicle { SalesAgentUserId = "new-broker", IntakeDate = today, + Status = VehicleStatus.Sold, CustomerId = Guid.NewGuid() }; + var lead = new Lead { VehicleId = vehicle.Id, TakenByUserId = "old-broker", Status = LeadStatus.Contacted }; + var result = DashboardUpdates.Create("old-broker", "old-broker", ["Sales"], today, + [vehicle], [lead], [new LoanApplication { VehicleId = vehicle.Id, Status = LoanStatus.Done }], + [new DeliverySchedule { VehicleId = vehicle.Id, PicUserId = "old-broker", Status = DeliveryStatus.Cancelled }], + [], [new BrokerDealPrice { VehicleId = vehicle.Id, Amount = 50000, BrokerUserId = "new-broker" }], + [new CalendarTask { AssignedToUserId = "old-broker", Status = CalendarTaskStatus.Done }], [], []); + Assert.Empty(result.Reminders); + Assert.DoesNotContain(result.News, x => x.Kind == "Delivery"); + var legacy = vehicle with { SalesAgentUserId = null }; + var oldSale = lead with { Status = LeadStatus.Closed, ClosureOutcome = LeadClosureOutcome.Sold, TakenAt = DateTime.UtcNow.AddDays(-2) }; + var currentSale = oldSale with { Id = Guid.NewGuid(), TakenByUserId = "new-broker", TakenAt = DateTime.UtcNow }; + var oldBroker = DashboardUpdates.Create("old-broker", "old-broker", ["Sales"], today, + [legacy], [oldSale, currentSale], [], [], [], [], [], [], []); + Assert.Empty(oldBroker.Reminders); + } + [Fact] public void WhatsApp_hr_attendance_uses_mutually_exclusive_known_scheduled_categories() {