From 42d2563197fbe4ed3d6cb24cbe956d4ae8a85bc4 Mon Sep 17 00:00:00 2001 From: LD Date: Sat, 14 Nov 2026 21:11:27 +0800 Subject: [PATCH 1/3] feat(dashboard): add role-scoped operational news and reminders (FOO-262) --- apps/backoffice/src/App.tsx | 17 ++- apps/backoffice/src/access.test.ts | 9 ++ apps/backoffice/src/access.ts | 4 +- apps/backoffice/src/api.ts | 11 ++ .../dashboard/DashboardUpdatesPanel.tsx | 71 ++++++++++++ .../modules/dashboard/dashboardWorkspace.css | 16 +++ .../src/modules/leads/SalesMyCarsPanel.tsx | 5 +- .../src/modules/vehicles/VehiclePage.tsx | 13 +++ docs/API.md | 1 + docs/FOO-262-dashboard-updates.md | 11 ++ .../YSHeng.Api/Features/DashboardUpdates.cs | 102 ++++++++++++++++++ services/api/src/YSHeng.Api/Program.cs | 8 ++ .../YSHeng.Api.Tests/BusinessRulesTests.cs | 44 ++++++++ 13 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 apps/backoffice/src/modules/dashboard/DashboardUpdatesPanel.tsx create mode 100644 docs/FOO-262-dashboard-updates.md create mode 100644 services/api/src/YSHeng.Api/Features/DashboardUpdates.cs diff --git a/apps/backoffice/src/App.tsx b/apps/backoffice/src/App.tsx index bb69b2b..8e1bd92 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" && ( Promise; onUpdate: (lead: Lead) => Promise | void }) { +export function LeadsPage({ initialMyCarsPlate, currentUser, vehicles, customers, leads, onCreateCustomer, onUpdate }: { initialMyCarsPlate?: string; currentUser: CurrentUser | null; vehicles: Vehicle[]; customers: Customer[]; leads: Lead[]; onCreateCustomer: (lead: Lead) => Promise; onUpdate: (lead: Lead) => Promise | void }) { const [leadKeyword, setLeadKeyword] = useState(""); const [leadStatusFilter, setLeadStatusFilter] = useState("All"); const [leadLinkFilter, setLeadLinkFilter] = useState("All"); const [leadSortMode, setLeadSortMode] = useState<"CloseAsap" | "Received">("Received"); const [closingLead, setClosingLead] = useState(null); const [closureOutcome, setClosureOutcome] = useState<"Sold" | "Lost" | "Invalid">("Lost"); - const [leadView, setLeadView] = useState<"Leads" | "MyCars">("Leads"); + const [leadView, setLeadView] = useState<"Leads" | "MyCars">(initialMyCarsPlate ? "MyCars" : "Leads"); + useEffect(() => { if (initialMyCarsPlate) setLeadView("MyCars"); }, [initialMyCarsPlate]); const [pendingLeadIds, setPendingLeadIds] = useState>(new Set()); const [leadErrors, setLeadErrors] = useState>({}); const pendingLeadRequests = useRef(new Set()); @@ -5583,7 +5590,7 @@ export function LeadsPage({ currentUser, vehicles, customers, leads, onCreateCus { value: "Invalid", label: "Invalid / 无效询问" } ]} /> - : } + : } ); } diff --git a/apps/backoffice/src/access.test.ts b/apps/backoffice/src/access.test.ts index 487d092..4322196 100644 --- a/apps/backoffice/src/access.test.ts +++ b/apps/backoffice/src/access.test.ts @@ -2,6 +2,15 @@ import { describe, expect, it } from "vitest"; import { backOfficeDataKeysForRoles, canAccessRoute, canApproveVehicles, canAssignStaffRoles, firstAccessiblePath, isRouteVisibleInNavigation } from "./access"; describe("backoffice role access", () => { + it("opens operational Dashboard without loading management finance data", () => { + for (const role of ["Sales", "Loan", "Delivery", "Finance", "Repair", "HrSalary"]) { + expect(canAccessRoute([role], "/dashboard")).toBe(true); + expect(backOfficeDataKeysForRoles([role])).not.toContain("dashboard"); + expect(backOfficeDataKeysForRoles([role])).not.toContain("reminders"); + } + expect(canAccessRoute([], "/dashboard")).toBe(false); + }); + it("shows Customer 360 in module navigation for authorized roles", () => { expect(isRouteVisibleInNavigation("/customer-360")).toBe(true); expect(isRouteVisibleInNavigation("/vehicles")).toBe(true); diff --git a/apps/backoffice/src/access.ts b/apps/backoffice/src/access.ts index 90f912c..e5882ee 100644 --- a/apps/backoffice/src/access.ts +++ b/apps/backoffice/src/access.ts @@ -50,7 +50,7 @@ export type RouteAccess = { }; export const routeAccess: RouteAccess[] = [ - { path: "/dashboard", roles: ["BossAdmin"] }, + { path: "/dashboard", roles: assignableStaffRoles }, { path: "/vehicles", roles: ["BossAdmin", "Sales"] }, { path: "/repairs", roles: ["BossAdmin", "Repair"] }, { path: "/loans", roles: ["BossAdmin", "Loan"] }, @@ -149,7 +149,7 @@ export function canAccessRoute(userRoles: string[] | undefined, path: string) { } export function firstAccessiblePath(userRoles: string[] | undefined): AppRoutePath { - return routeAccess.find((item) => canAccessRoute(userRoles, item.path))?.path ?? "/vehicles"; + return routeAccess.find((item) => (item.path !== "/dashboard" || userRoles?.includes("BossAdmin")) && canAccessRoute(userRoles, item.path))?.path ?? "/vehicles"; } export function canAssignStaffRoles(roles: string[] | undefined) { diff --git a/apps/backoffice/src/api.ts b/apps/backoffice/src/api.ts index d0409c3..869edcf 100644 --- a/apps/backoffice/src/api.ts +++ b/apps/backoffice/src/api.ts @@ -3786,3 +3786,14 @@ export const decideHrAttendanceCorrection = (id: string, approve: boolean, notes export const previewHrPayslips = (id: string) => request(`/api/hr/pay-periods/${id}/preview`); export const saveHrStatutory = (id: string, input: HrStatutoryInput) => request(`/api/hr/payslips/${id}/statutory`, { method: "PUT", body: JSON.stringify(input) }); export const decideHrPayroll = (id: string, version: number, action: HrPayrollAction, notes?: string) => request(`/api/hr/payslips/${id}/decision`, { method: "PUT", body: JSON.stringify({ version, action, notes }) }); + +export type DashboardUpdate = { + id: string; kind: string; title: string; status: string; occurredAt?: string; + vehicleId?: string; plate?: string; broker?: string; recordedBy?: string; + date?: string; time?: string; daysOpen?: number; askingPrice?: number; + path?: string; louDocumentId?: string; +}; +export type DashboardUpdatesResponse = { news: DashboardUpdate[]; reminders: DashboardUpdate[] }; +export function getDashboardUpdates(): Promise { + return request("/api/dashboard/updates", {}, "Unable to load dashboard updates"); +} diff --git a/apps/backoffice/src/modules/dashboard/DashboardUpdatesPanel.tsx b/apps/backoffice/src/modules/dashboard/DashboardUpdatesPanel.tsx new file mode 100644 index 0000000..7b8f209 --- /dev/null +++ b/apps/backoffice/src/modules/dashboard/DashboardUpdatesPanel.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef, useState } from "react"; +import { Alert, Button, Col, Empty, List, Row, Space, Spin, Tag, Typography } from "antd"; +import { ProCard } from "@ant-design/pro-components"; +import { canAccessRoute } from "../../access"; +import { getDashboardUpdates, getVehicleDocumentContent, humanizeApiError, vehicleDocumentContentUrl, type DashboardUpdate, type DashboardUpdatesResponse } from "../../api"; +import { DocumentPreviewButton } from "../shared/DocumentPreviewButton"; +import { formatDisplayDate } from "../../dateDisplay"; + +const labels: Record = { + NewStock: "New stock / 新库存", Loan: "Loan / 贷款", LoanApproved: "Loan approved / 贷款已批", + Deal: "Deal / 成交", Delivery: "Delivery / 交车", MissingDealPrice: "Deal price / 成交价", Task: "Task / 任务" +}; + +export function DashboardUpdatesPanel({ roles, onNavigate }: { roles: string[]; onNavigate: (path: string) => void }) { + const [data, setData] = useState(); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + const requestId = useRef(0); + const roleKey = roles.join(","); + async function refresh() { + const id = ++requestId.current; + setLoading(true); + try { + const result = await getDashboardUpdates(); + if (id === requestId.current) { setData(result); setError(""); } + } catch (reason) { + if (id === requestId.current) { setData(undefined); setError(humanizeApiError(reason)); } + } finally { if (id === requestId.current) setLoading(false); } + } + useEffect(() => { + setData(undefined); + void refresh(); + return () => { requestId.current += 1; }; + }, [roleKey]); + + const renderItem = (item: DashboardUpdate) => + + {labels[item.kind] ?? item.kind}{item.plate ?? item.title}{item.status} + {item.plate && {item.title}} + + {item.date && {formatDisplayDate(item.date)} {item.time ? `${item.time} MYT` : ""}} + {item.daysOpen != null && {item.daysOpen} days / 天} + {item.askingPrice != null && RM {item.askingPrice.toLocaleString("en-MY", { minimumFractionDigits: 2 })}} + + {item.broker && Broker / 经纪: {item.broker}} + {item.kind === "Loan" && {item.recordedBy ? `Last handled by / 最近处理人: ${item.recordedBy}` : "Pending assignment / 待分配"}} + {item.kind !== "Loan" && item.recordedBy && {item.kind === "Delivery" ? "Booked by / 预约人" : item.kind === "Task" ? "Assigned to / 负责人" : "Recorded by / 记录人"}: {item.recordedBy}} + + {item.path && canAccessRoute(roles, item.path.split("?")[0]) && } + {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 ea84d35..07436c1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -543,6 +543,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..7bbdf0f --- /dev/null +++ b/services/api/src/YSHeng.Api/Features/DashboardUpdates.cs @@ -0,0 +1,102 @@ +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) => !string.IsNullOrEmpty(userId) && + (!string.IsNullOrWhiteSpace(vehicle.SalesAgentUserId) ? vehicle.SalesAgentUserId == userId + : leads.Any(x => x.VehicleId == vehicle.Id && x.TakenByUserId == userId && + (x.Status != LeadStatus.Closed || x.ClosureOutcome == LeadClosureOutcome.Sold))); + 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 c04e6d4..0f22ae9 100644 --- a/services/api/src/YSHeng.Api/Program.cs +++ b/services/api/src/YSHeng.Api/Program.cs @@ -5233,6 +5233,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..6233564 100644 --- a/services/api/tests/YSHeng.Api.Tests/BusinessRulesTests.cs +++ b/services/api/tests/YSHeng.Api.Tests/BusinessRulesTests.cs @@ -21,6 +21,50 @@ 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"); + } + [Fact] public void WhatsApp_hr_attendance_uses_mutually_exclusive_known_scheduled_categories() { From b0c13f7d3c850b1c04231cd1db621ca927ab0e80 Mon Sep 17 00:00:00 2001 From: LD Date: Sat, 14 Nov 2026 21:16:49 +0800 Subject: [PATCH 2/3] fix(dashboard): use latest legacy sale ownership for reminders --- .../api/src/YSHeng.Api/Features/DashboardUpdates.cs | 13 +++++++++---- .../tests/YSHeng.Api.Tests/BusinessRulesTests.cs | 6 ++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/services/api/src/YSHeng.Api/Features/DashboardUpdates.cs b/services/api/src/YSHeng.Api/Features/DashboardUpdates.cs index 7bbdf0f..b4a5a8b 100644 --- a/services/api/src/YSHeng.Api/Features/DashboardUpdates.cs +++ b/services/api/src/YSHeng.Api/Features/DashboardUpdates.cs @@ -39,10 +39,15 @@ public static DashboardUpdatesResponse Create(string userId, string actor, strin var reminders = new List(); var vehicleById = vehicles.ToDictionary(x => x.Id); var cutoff = today.AddDays(-30); - bool Owns(Vehicle vehicle) => !string.IsNullOrEmpty(userId) && - (!string.IsNullOrWhiteSpace(vehicle.SalesAgentUserId) ? vehicle.SalesAgentUserId == userId - : leads.Any(x => x.VehicleId == vehicle.Id && x.TakenByUserId == userId && - (x.Status != LeadStatus.Closed || x.ClosureOutcome == LeadClosureOutcome.Sold))); + 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) { diff --git a/services/api/tests/YSHeng.Api.Tests/BusinessRulesTests.cs b/services/api/tests/YSHeng.Api.Tests/BusinessRulesTests.cs index 6233564..1827e1f 100644 --- a/services/api/tests/YSHeng.Api.Tests/BusinessRulesTests.cs +++ b/services/api/tests/YSHeng.Api.Tests/BusinessRulesTests.cs @@ -63,6 +63,12 @@ public void Dashboard_updates_remove_finished_work_and_respect_durable_broker_as [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] From eec4f5d165d3d6cf79fb88d382f1c71fc6e055aa Mon Sep 17 00:00:00 2001 From: LD Date: Sat, 14 Nov 2026 21:18:46 +0800 Subject: [PATCH 3/3] fix(dashboard): omit management counters from staff view --- apps/backoffice/src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backoffice/src/App.tsx b/apps/backoffice/src/App.tsx index 34068a6..daccadc 100644 --- a/apps/backoffice/src/App.tsx +++ b/apps/backoffice/src/App.tsx @@ -1909,14 +1909,14 @@ function ModuleCommandBar({
-
+ {(pathname !== "/dashboard" || roles.includes("BossAdmin")) &&
{stats.map((stat) => ( {stat.value} {stat.label} ))} -
+
}