Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions apps/backoffice/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -355,7 +356,7 @@ const repairReceiptOcrSections = {
} satisfies Record<string, OcrFieldSection>;

const bilingual = {
dashboard: "Dashboard / 管理层分析",
dashboard: "Dashboard / 工作看板",
vehicles: "Vehicles / 收车库存",
repairs: "Repair / 整备",
loans: "Loan / 贷款",
Expand Down Expand Up @@ -1317,9 +1318,13 @@ export default function App() {
}}
/>
<div ref={moduleContentRef}>
{pathname === "/dashboard" && <DashboardPage dashboard={dashboard} dashboardLoadError={dashboardLoadError} reminders={reminders} priorityActions={priorityActions} reminderLoadError={reminderLoadError} vehicles={vehicles} staffUsers={staffUsers} lastCheckedAt={dashboardLastCheckedAt} refreshing={dashboardRefreshing} actionsLastCheckedAt={actionsLastCheckedAt} actionsRefreshing={actionsRefreshing} onRefreshActions={refreshDashboardActions} analyticsPeriod={dashboardPeriod} analyticsRangePreset={dashboardRangePreset} onRefresh={() => refreshDashboard()} onAnalyticsPeriodChange={changeDashboardPeriod} onNavigate={navigateTo} onOpenDelivery={currentRoles.includes("BossAdmin") || currentRoles.includes("Delivery") ? (deliveryId) => navigateTo(`/delivery?deliveryId=${encodeURIComponent(deliveryId)}`) : undefined} />}
{pathname === "/dashboard" && <div className="dashboardOperational">
<DashboardUpdatesPanel roles={currentRoles} onNavigate={navigateTo} />
{currentRoles.includes("BossAdmin") ? <DashboardPage dashboard={dashboard} dashboardLoadError={dashboardLoadError} reminders={reminders} priorityActions={priorityActions} reminderLoadError={reminderLoadError} vehicles={vehicles} staffUsers={staffUsers} lastCheckedAt={dashboardLastCheckedAt} refreshing={dashboardRefreshing} actionsLastCheckedAt={actionsLastCheckedAt} actionsRefreshing={actionsRefreshing} onRefreshActions={refreshDashboardActions} analyticsPeriod={dashboardPeriod} analyticsRangePreset={dashboardRangePreset} onRefresh={() => refreshDashboard()} onAnalyticsPeriodChange={changeDashboardPeriod} onNavigate={navigateTo} onOpenDelivery={currentRoles.includes("BossAdmin") || currentRoles.includes("Delivery") ? (deliveryId) => navigateTo(`/delivery?deliveryId=${encodeURIComponent(deliveryId)}`) : undefined} /> : <OperationsCalendar compact onOpenDelivery={currentRoles.includes("Delivery") ? (id) => navigateTo(`/delivery?deliveryId=${encodeURIComponent(id)}`) : undefined} />}
</div>}
{pathname === "/vehicles" && (
<VehiclePage
initialVehicleId={new URLSearchParams(routeUrl.split("?")[1] ?? "").get("vehicleId") ?? undefined}
vehicles={vehicles}
leads={leads}
loans={loans}
Expand Down Expand Up @@ -1508,6 +1513,7 @@ export default function App() {
)}
{pathname === "/leads" && (
<LeadsPage
initialMyCarsPlate={new URLSearchParams(routeUrl.split("?")[1] ?? "").get("myCarsPlate") ?? undefined}
currentUser={currentUser}
vehicles={vehicles}
customers={customers}
Expand Down Expand Up @@ -1903,14 +1909,14 @@ function ModuleCommandBar({
</div>
</div>
<div className="moduleCommandAside">
<div className="moduleStats">
{(pathname !== "/dashboard" || roles.includes("BossAdmin")) && <div className="moduleStats">
{stats.map((stat) => (
<span key={stat.label}>
<strong>{stat.value}</strong>
{stat.label}
</span>
))}
</div>
</div>}
<span className="moduleGuideButtonWrap" ref={guideButtonRef}>
<Button className="moduleGuideButton" icon={<QuestionCircleOutlined />} onClick={onOpenGuide}>
How to use / 使用指南
Expand Down Expand Up @@ -5187,14 +5193,15 @@ export function DeliveryPage({
);
}

export function LeadsPage({ currentUser, vehicles, customers, leads, onCreateCustomer, onUpdate }: { currentUser: CurrentUser | null; vehicles: Vehicle[]; customers: Customer[]; leads: Lead[]; onCreateCustomer: (lead: Lead) => Promise<void>; onUpdate: (lead: Lead) => Promise<void> | 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<void>; onUpdate: (lead: Lead) => Promise<void> | void }) {
const [leadKeyword, setLeadKeyword] = useState("");
const [leadStatusFilter, setLeadStatusFilter] = useState<Lead["status"] | "All">("All");
const [leadLinkFilter, setLeadLinkFilter] = useState<LeadLinkFilter>("All");
const [leadSortMode, setLeadSortMode] = useState<"CloseAsap" | "Received">("Received");
const [closingLead, setClosingLead] = useState<Lead | null>(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<Set<string>>(new Set());
const [leadErrors, setLeadErrors] = useState<Record<string, string>>({});
const pendingLeadRequests = useRef(new Set<string>());
Expand Down Expand Up @@ -5583,7 +5590,7 @@ export function LeadsPage({ currentUser, vehicles, customers, leads, onCreateCus
{ value: "Invalid", label: "Invalid / 无效询问" }
]} />
</Modal>
</> : <SalesMyCarsPanel currentUser={currentUser} />}
</> : <SalesMyCarsPanel currentUser={currentUser} initialKeyword={initialMyCarsPlate} />}
</Space>
);
}
Expand Down
9 changes: 9 additions & 0 deletions apps/backoffice/src/access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions apps/backoffice/src/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions apps/backoffice/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3794,3 +3794,14 @@ export const decideHrAttendanceCorrection = (id: string, approve: boolean, notes
export const previewHrPayslips = (id: string) => request<HrPayrollCandidate[]>(`/api/hr/pay-periods/${id}/preview`);
export const saveHrStatutory = (id: string, input: HrStatutoryInput) => request<HrPayslip>(`/api/hr/payslips/${id}/statutory`, { method: "PUT", body: JSON.stringify(input) });
export const decideHrPayroll = (id: string, version: number, action: HrPayrollAction, notes?: string) => request<HrPayslip>(`/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<DashboardUpdatesResponse> {
return request("/api/dashboard/updates", {}, "Unable to load dashboard updates");
}
71 changes: 71 additions & 0 deletions apps/backoffice/src/modules/dashboard/DashboardUpdatesPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<DashboardUpdatesResponse>();
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) => <List.Item key={item.id}>
<Space orientation="vertical" size={6} style={{ width: "100%", minWidth: 0 }}>
<Space wrap><Tag>{labels[item.kind] ?? item.kind}</Tag><Typography.Text strong>{item.plate ?? item.title}</Typography.Text><Tag>{item.status}</Tag></Space>
{item.plate && <Typography.Text>{item.title}</Typography.Text>}
<Space wrap>
{item.date && <Typography.Text>{formatDisplayDate(item.date)} {item.time ? `${item.time} MYT` : ""}</Typography.Text>}
{item.daysOpen != null && <Typography.Text>{item.daysOpen} days / 天</Typography.Text>}
{item.askingPrice != null && <Typography.Text>RM {item.askingPrice.toLocaleString("en-MY", { minimumFractionDigits: 2 })}</Typography.Text>}
</Space>
{item.broker && <Typography.Text type="secondary">Broker / 经纪: {item.broker}</Typography.Text>}
{item.kind === "Loan" && <Typography.Text type="secondary">{item.recordedBy ? `Last handled by / 最近处理人: ${item.recordedBy}` : "Pending assignment / 待分配"}</Typography.Text>}
{item.kind !== "Loan" && item.recordedBy && <Typography.Text type="secondary">{item.kind === "Delivery" ? "Booked by / 预约人" : item.kind === "Task" ? "Assigned to / 负责人" : "Recorded by / 记录人"}: {item.recordedBy}</Typography.Text>}
<Space wrap>
{item.path && canAccessRoute(roles, item.path.split("?")[0]) && <Button onClick={() => onNavigate(item.path!)}>Open / 查看</Button>}
{item.louDocumentId && item.vehicleId && <DocumentPreviewButton fileName="LOU" previewLabel="View LOU / 查看 LOU" downloadLabel="Download / 下载"
downloadUrl={vehicleDocumentContentUrl(item.vehicleId, item.louDocumentId)}
loadContent={() => getVehicleDocumentContent(item.vehicleId!, item.louDocumentId!)} />}
</Space>
</Space>
</List.Item>;
return <ProCard title="Updates / 工作动态" extra={<Button loading={loading} onClick={() => void refresh()}>Refresh / 刷新</Button>} style={{ marginBottom: 16 }}>
{error ? <Alert type="error" showIcon title="Updates unavailable / 暂时无法读取" description={error} /> : <Spin spinning={loading}>
<Row gutter={[24, 16]}>
<Col xs={24} lg={12}>
<Typography.Title level={5}>News / 公司动态</Typography.Title>
<Typography.Text type="secondary">Recent 30 days · latest 60 updates / 近30天,最新60项</Typography.Text>
<List dataSource={data?.news ?? []} renderItem={renderItem} pagination={{ pageSize: 5, size: "small", showSizeChanger: false }} locale={{ emptyText: <Empty description="No news / 暂无动态" /> }} />
</Col>
<Col xs={24} lg={12}>
<Typography.Title level={5}>{roles.includes("BossAdmin") ? "Pending work / 待办事项" : "My reminders / 我的待办"}</Typography.Title>
<List dataSource={data?.reminders ?? []} renderItem={renderItem} pagination={{ pageSize: 5, size: "small", showSizeChanger: false }} locale={{ emptyText: <Empty description="No pending work / 暂无待办" /> }} />
</Col>
</Row>
</Spin>}
</ProCard>;
}
16 changes: 16 additions & 0 deletions apps/backoffice/src/modules/dashboard/dashboardWorkspace.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
}
5 changes: 4 additions & 1 deletion apps/backoffice/src/modules/leads/SalesMyCarsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SalesWorkboard | undefined>(initialData);
const [loading, setLoading] = useState(autoLoad);
const [loadError, setLoadError] = useState<string>();
const [agentUserId, setAgentUserId] = useState<string | "All">("All");
const [keyword, setKeyword] = useState("");
const [keyword, setKeyword] = useState(initialKeyword);
useEffect(() => { setKeyword(initialKeyword); }, [initialKeyword]);
const [mobilePage, setMobilePage] = useState(1);
const [priceDrafts, setPriceDrafts] = useState<Record<string, number | undefined>>({});
const [priceSavingFor, setPriceSavingFor] = useState<string | null>(null);
Expand Down
13 changes: 13 additions & 0 deletions apps/backoffice/src/modules/vehicles/VehiclePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,7 @@ export function filterOperationIntakeVehicles(
}

export function VehiclePage({
initialVehicleId,
vehicles,
leads,
loans,
Expand Down Expand Up @@ -907,6 +908,7 @@ export function VehiclePage({
onUploadPhoto,
onUploadDocument
}: {
initialVehicleId?: string;
vehicles: Vehicle[];
leads: Lead[];
loans: LoanApplication[];
Expand Down Expand Up @@ -1391,6 +1393,17 @@ export function VehiclePage({
setVehicleDetailOpen(true);
};

const openedRouteVehicle = useRef<string | undefined>(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);
Expand Down
1 change: 1 addition & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
Loading
Loading