feat(boss-dashboard): add rep-activity bottom sheet

This commit is contained in:
Dave 2026-08-28 00:24:46 +02:00
parent 44f93feb13
commit 9101871fef

View file

@ -7,7 +7,9 @@ import getPocketBase from "@/lib/pocketbase";
import type {
BossDashboardData,
BossDashboardResponse,
BossRepRow,
BossWindow,
RepActivityItem,
} from "@/lib/server/boss-dashboard-service";
const REALTIME_DEBOUNCE_MS = 1_000;
@ -15,12 +17,21 @@ const REALTIME_DEBOUNCE_MS = 1_000;
export default function BossDashboardView() {
const [data, setData] = useState<BossDashboardData | null>(null);
const [viewerName, setViewerName] = useState<string | null>(null);
const [window, setWindow] = useState<BossWindow>("today");
const [activeWindow, setActiveWindow] = useState<BossWindow>("today");
const [loading, setLoading] = useState(true);
const [lastRefreshed, setLastRefreshed] = useState<Date | null>(null);
const [error, setError] = useState<string | null>(null);
const reqIdRef = useRef(0);
// Sheet (drill-down) state — populated when a rep card is tapped.
const [sheetRep, setSheetRep] = useState<BossRepRow | null>(null);
const [sheetKind, setSheetKind] = useState<"quotes" | "orders">("quotes");
const [sheetItems, setSheetItems] = useState<RepActivityItem[]>([]);
const [sheetPage, setSheetPage] = useState(1);
const [sheetHasMore, setSheetHasMore] = useState(false);
const [sheetLoading, setSheetLoading] = useState(false);
const dialogRef = useRef<HTMLDialogElement>(null);
const fetchData = useCallback(async () => {
// Monotonic request id — discards late responses when a newer fetch
// has started (e.g. tab switch). Survives unmount because refs outlive
@ -31,7 +42,7 @@ export default function BossDashboardView() {
try {
const pb = getPocketBase();
const token = pb.authStore?.token ?? "";
const res = await fetch(`/api/v1/boss-dashboard?window=${window}`, {
const res = await fetch(`/api/v1/boss-dashboard?window=${activeWindow}`, {
headers: { Authorization: token },
cache: "no-store",
});
@ -56,7 +67,7 @@ export default function BossDashboardView() {
} finally {
if (myId === reqIdRef.current) setLoading(false);
}
}, [window]);
}, [activeWindow]);
useEffect(() => {
void fetchData();
@ -66,6 +77,53 @@ export default function BossDashboardView() {
const firstName = (viewerName ?? "").split(" ")[0] || "there";
// --- Rep-activity bottom sheet ------------------------------------------
async function openSheet(rep: BossRepRow) {
if (rep.isUnknown) return;
setSheetRep(rep);
setSheetKind("quotes");
setSheetPage(1);
setSheetItems([]);
dialogRef.current?.showModal();
await loadSheetPage(rep.id, "quotes", 1, true);
}
async function loadSheetPage(
repId: string,
kind: "quotes" | "orders",
page: number,
replace: boolean,
) {
setSheetLoading(true);
try {
const pb = getPocketBase();
const headers: Record<string, string> = {};
if (pb.authStore?.token) headers.Authorization = pb.authStore.token;
const res = await fetch(
`/api/v1/boss-dashboard/rep-activity?rep=${encodeURIComponent(repId)}&window=${activeWindow}&kind=${kind}&page=${page}`,
{ headers, cache: "no-store" },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as {
items: RepActivityItem[];
hasMore: boolean;
};
setSheetItems((prev) => (replace ? data.items : [...prev, ...data.items]));
setSheetHasMore(data.hasMore);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to load");
} finally {
setSheetLoading(false);
}
}
function closeSheet() {
dialogRef.current?.close();
setSheetRep(null);
setSheetItems([]);
}
return (
<div className="mx-auto max-w-md px-4 py-4 space-y-4">
{/* Header strip */}
@ -102,10 +160,10 @@ export default function BossDashboardView() {
key={w}
type="button"
role="tab"
aria-selected={window === w}
onClick={() => setWindow(w)}
aria-selected={activeWindow === w}
onClick={() => setActiveWindow(w)}
className={`flex-1 rounded-lg px-3 py-2 text-sm font-medium capitalize transition-colors ${
window === w
activeWindow === w
? "bg-cyan-500/15 text-cyan-300"
: "text-muted-foreground hover:text-foreground"
}`}
@ -241,6 +299,114 @@ export default function BossDashboardView() {
</ul>
)}
</section>
{/* Rep-activity bottom sheet */}
<dialog
ref={dialogRef}
className="fixed inset-x-0 bottom-0 w-full max-w-md mx-auto rounded-t-2xl bg-card border border-border/60 p-0 backdrop:bg-black/60"
onClose={() => setSheetRep(null)}
>
{sheetRep && (
<div className="p-4 space-y-3">
<header className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold">{sheetRep.name}</div>
<div className="text-xs text-muted-foreground">
{sheetRep.quotesLoaded} loaded · {sheetRep.quotesDoneBy} done
· {sheetRep.ordersPlaced} orders
</div>
</div>
<button
type="button"
onClick={closeSheet}
aria-label="Close"
className="grid h-8 w-8 place-items-center rounded-full border border-border/60"
>
×
</button>
</header>
{/* Tab bar */}
<div className="flex gap-1 rounded-lg bg-muted/30 p-1" role="tablist">
{(["quotes", "orders"] as const).map((k) => (
<button
key={k}
type="button"
role="tab"
aria-selected={sheetKind === k}
onClick={async () => {
setSheetKind(k);
setSheetPage(1);
await loadSheetPage(sheetRep.id, k, 1, true);
}}
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium capitalize transition-colors ${
sheetKind === k
? "bg-cyan-500/15 text-cyan-300"
: "text-muted-foreground hover:text-foreground"
}`}
>
{k}
</button>
))}
</div>
{/* List */}
<ul className="max-h-80 overflow-y-auto divide-y divide-border/40 -mx-1">
{sheetItems.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() => {
const path =
sheetKind === "quotes"
? `/quotes/${item.id}`
: `/orders/${item.id}`;
window.open(path, "_blank");
}}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left hover:bg-muted/30"
>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{item.number}</div>
<div className="text-xs text-muted-foreground truncate">
{item.customer || "—"}
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
{item.status}
</div>
<div className="text-[11px] text-muted-foreground">
{formatAge(item.ageMs)}
</div>
</div>
</button>
</li>
))}
</ul>
{sheetHasMore && (
<button
type="button"
disabled={sheetLoading}
onClick={async () => {
const nextPage = sheetPage + 1;
setSheetPage(nextPage);
await loadSheetPage(sheetRep.id, sheetKind, nextPage, false);
}}
className="w-full rounded-lg border border-border/60 py-2 text-sm text-muted-foreground hover:bg-muted/30 disabled:opacity-50"
>
{sheetLoading ? "Loading…" : "Load more"}
</button>
)}
{sheetItems.length === 0 && !sheetLoading && (
<div className="text-center text-sm text-muted-foreground py-6">
No {sheetKind} in this window.
</div>
)}
</div>
)}
</dialog>
</div>
);
}
@ -306,6 +472,13 @@ function Metric({
);
}
function openSheet(_rep: BossDashboardData["reps"][number]) {
// Wired in Task 14.
function formatAge(ms: number): string {
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const days = Math.floor(hr / 24);
return `${days}d ago`;
}