mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Adds a live presence layer so the Settings → Team members roster and the inbox Assign dropdown show who is actively using the dashboard, idle, or gone. Implements #269. How it works - New `member_presence` table + `touch_presence` SECURITY DEFINER RPC (migration 024). Account-scoped SELECT RLS mirrors the read-only roster; the RPC derives account_id server-side, never client-supplied. - The active client heartbeats 'online'/'away' every ~30s; 'offline' is derived by viewers from staleness, so a closed tab resolves to offline without an unreliable unload write. - Pure, unit-tested derivation/formatting in src/lib/presence.ts. - `usePresence` reads the table, subscribes to Realtime (subscribe-then- merge so events landing mid-fetch aren't clobbered), and re-derives offline on a 15s tick. - Roster: avatar badge + tooltip + "N online · N away · N offline" summary, with an aria-label so AT/keyboard users get presence too. Inbox Assign dropdown: matching dot before each teammate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
64 lines
2.3 KiB
TypeScript
64 lines
2.3 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { AuthProvider, useAuth } from "@/hooks/use-auth";
|
|
import { Sidebar } from "@/components/layout/sidebar";
|
|
import { Header } from "@/components/layout/header";
|
|
import { PresenceHeartbeat } from "@/components/presence/presence-heartbeat";
|
|
|
|
// Auth-gated dashboard shell. Extracted from the layout so the layout
|
|
// itself can stay a server component and export metadata (noindex) —
|
|
// client components can't export Next's metadata object.
|
|
|
|
function DashboardShellInner({ children }: { children: React.ReactNode }) {
|
|
const { user, loading } = useAuth();
|
|
const router = useRouter();
|
|
|
|
// Sidebar drawer state — only used on mobile. On lg+ the sidebar is
|
|
// always visible and this stays at `false` (ignored by the component).
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
const closeSidebar = useCallback(() => setSidebarOpen(false), []);
|
|
|
|
useEffect(() => {
|
|
if (!loading && !user) {
|
|
router.push("/login");
|
|
}
|
|
}, [user, loading, router]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex h-screen items-center justify-center bg-background">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!user) return null;
|
|
|
|
return (
|
|
<div className="flex h-screen overflow-hidden bg-background">
|
|
{/* Reports this tab's online/away presence once we know a user is
|
|
signed in. Headless — renders nothing. */}
|
|
<PresenceHeartbeat />
|
|
<Sidebar open={sidebarOpen} onClose={closeSidebar} />
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
<Header onOpenSidebar={() => setSidebarOpen(true)} />
|
|
{/* Thinner horizontal padding on mobile so cards have room to breathe. */}
|
|
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DashboardShell({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<AuthProvider>
|
|
<DashboardShellInner>{children}</DashboardShellInner>
|
|
</AuthProvider>
|
|
);
|
|
}
|