feat(boss-dashboard): add rep-activity bottom sheet
This commit is contained in:
parent
44f93feb13
commit
9101871fef
1 changed files with 181 additions and 8 deletions
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue