Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica

- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind
- Frontend Homenest (versión alternativa)
- Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp)
- WACRM fork (Next.js 16 + Supabase)
- Hermes + Bridge + Skills (Qwen3.6 via Nan Builders)
- Scripts de migración y operación
- Documentación extensiva en docs/
This commit is contained in:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
import { cn } from "@/lib/utils";
import type { PresenceStatus } from "@/lib/presence";
// Single source of truth for presence colours. Semantic accents
// (emerald / amber / muted), mirroring the role-chip palette already
// used across settings, so they're intentionally not tokenized.
export const PRESENCE_DOT_CLASS: Record<PresenceStatus, string> = {
online: "bg-emerald-500",
away: "bg-amber-500",
offline: "bg-muted-foreground/50",
};
/**
* A small presence dot. Used inline (e.g. the inbox Assign dropdown)
* and, with `asAvatarBadge`-style positioning supplied via className,
* layered onto an avatar. `label` powers the native title/aria for
* the lightweight inline case; the roster wraps it in a real Tooltip.
*/
export function PresenceDot({
status,
label,
className,
}: {
status: PresenceStatus;
label?: string;
className?: string;
}) {
return (
<span
role={label ? "img" : undefined}
aria-label={label}
title={label}
className={cn(
"inline-block size-2 shrink-0 rounded-full",
PRESENCE_DOT_CLASS[status],
className,
)}
/>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { useEffect, useRef } from "react";
import { createClient } from "@/lib/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { HEARTBEAT_MS, IDLE_AFTER_MS, type StoredPresence } from "@/lib/presence";
/**
* PresenceHeartbeat — headless. Mount ONCE per signed-in dashboard tab
* (in the dashboard shell, below the auth gate). Reports this tab's
* presence to the `member_presence` table via the `touch_presence` RPC
* roughly every HEARTBEAT_MS.
*
* The client only ever reports 'online' or 'away':
* - 'away' when the tab is hidden, or no user input for IDLE_AFTER_MS
* - 'online' otherwise
* It keeps heartbeating while away (so the row stays fresh, i.e. not
* offline). When the tab closes the beats simply stop and viewers derive
* 'offline' from staleness — no unreliable unload write needed.
*/
export function PresenceHeartbeat() {
const { accountId } = useAuth();
// 0 = "never recorded"; set on mount so we don't read the clock during
// render (impure). Until the effect runs the tab counts as active.
const lastActivityRef = useRef<number>(0);
useEffect(() => {
// Hold off until the account is known. Beating during the brief
// window on a fresh signup — authed but profile/account row not yet
// created — would make touch_presence raise "No account for caller"
// and log a spurious error. The effect re-runs once accountId lands.
if (!accountId) return;
const supabase = createClient();
let cancelled = false;
let lastBeatAt = 0;
lastActivityRef.current = Date.now();
const markActive = () => {
lastActivityRef.current = Date.now();
};
const currentStatus = (): StoredPresence => {
if (typeof document !== "undefined" && document.hidden) return "away";
if (Date.now() - lastActivityRef.current > IDLE_AFTER_MS) return "away";
return "online";
};
const beat = async () => {
if (cancelled) return;
// Coalesce bursts: a tab refocus fires visibilitychange AND focus
// together, so skip a beat within 1s of the last to avoid two RPCs
// in the same frame. The 30s interval is never affected.
const t = Date.now();
if (t - lastBeatAt < 1_000) return;
lastBeatAt = t;
const { error } = await supabase.rpc("touch_presence", {
p_status: currentStatus(),
});
if (error && !cancelled) {
// Non-fatal: presence is best-effort. Log once per failure so a
// misconfigured RPC is visible without spamming.
console.error("[PresenceHeartbeat] touch_presence failed:", error.message);
}
};
// Activity listeners. `passive` so we never block scroll/input.
const activityEvents: (keyof DocumentEventMap)[] = [
"mousemove",
"keydown",
"pointerdown",
"scroll",
];
activityEvents.forEach((e) =>
document.addEventListener(e, markActive, { passive: true }),
);
// Returning to the tab should beat immediately so a member flips
// back to online without a 30s wait. The debounce in beat() absorbs
// the visibilitychange + focus double-fire.
const onReturn = () => {
if (!document.hidden) markActive();
void beat();
};
document.addEventListener("visibilitychange", onReturn);
window.addEventListener("focus", onReturn);
void beat();
const interval = setInterval(() => void beat(), HEARTBEAT_MS);
return () => {
cancelled = true;
clearInterval(interval);
activityEvents.forEach((e) =>
document.removeEventListener(e, markActive),
);
document.removeEventListener("visibilitychange", onReturn);
window.removeEventListener("focus", onReturn);
};
}, [accountId]);
return null;
}