AgendaPro v1.0 - multi-tenant SaaS, mobile calendar, public booking
Multi-tenant scheduling SaaS (AgendaPro-equivalent): - Backend: Node + Express + node:sqlite, multi-tenant (admin/owner/employee), versioned migrations, 4 templates (estetica-spa, barberia, clinica, blank). - Frontend: React 18 + TS + Vite + Tailwind, FullCalendar drag-and-drop, Recharts dashboard, mobile-first (day/list views on mobile). - Features: calendar+services+employees+clients+tickets, dashboard with best employee/service, top tickets, top/frequent clients, commissions, cancellation policy + no-show tracking, public online booking (/b/:slug), cash register (cierre de caja), reminders/notifications center. - Verification: 65/65 e2e API, 58/58 visual (0 overflow, 0 console errors), typecheck clean, vite build OK.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Bell,
|
||||
MessageCircle,
|
||||
Mail,
|
||||
Send,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { PageHeader, Spinner, EmptyState, Badge } from "../components/ui";
|
||||
import { formatRelative, cn } from "../lib/format";
|
||||
|
||||
const CHANNEL = {
|
||||
whatsapp: { icon: MessageCircle, color: "#10b981", label: "WhatsApp" },
|
||||
sms: { icon: MessageCircle, color: "#3b66ff", label: "SMS" },
|
||||
email: { icon: Mail, color: "#a855f7", label: "Email" },
|
||||
};
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
confirmation: "Confirmación",
|
||||
reminder: "Recordatorio",
|
||||
cancellation: "Cancelación",
|
||||
follow_up: "Seguimiento",
|
||||
review: "Reseña",
|
||||
};
|
||||
const STATUS = [
|
||||
{ value: "all", label: "Todas" },
|
||||
{ value: "pending", label: "Pendientes" },
|
||||
{ value: "sent", label: "Enviadas" },
|
||||
];
|
||||
|
||||
export function NotificationsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [status, setStatus] = useState("pending");
|
||||
const { data: stats } = useQuery({ queryKey: ["notifications", "stats"], queryFn: () => api.notifications.stats() });
|
||||
const { data } = useQuery({ queryKey: ["notifications", status], queryFn: () => api.notifications.list(status) });
|
||||
|
||||
const regen = useMutation({
|
||||
mutationFn: () => api.notifications.regenerate(),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["notifications"] }),
|
||||
});
|
||||
const act = (id: number, kind: "send" | "cancel") =>
|
||||
api.notifications[kind](id).then(() => qc.invalidateQueries({ queryKey: ["notifications"] }));
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title="Recordatorios"
|
||||
subtitle="Reduce inasistencias con recordatorios automáticos por WhatsApp, SMS y email."
|
||||
actions={
|
||||
<button onClick={() => regen.mutate()} className="btn btn-secondary" disabled={regen.isPending}>
|
||||
{regen.isPending ? <Spinner /> : <RefreshCw className="h-4 w-4" />} Regenerar
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 space-y-5 px-5 py-5 sm:px-7">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<Kpi icon={Clock} color="#f17616" label="Pendientes" value={String(stats?.pending ?? 0)} />
|
||||
<Kpi icon={CheckCircle2} color="#10b981" label="Enviadas" value={String(stats?.sent ?? 0)} />
|
||||
<Kpi icon={Bell} color="#3b66ff" label="Recordatorios próximos" value={String(stats?.upcoming_reminders ?? 0)} />
|
||||
<Kpi icon={AlertTriangle} color="#ef4444" label="Tasa no-show (30d)" value={`${stats?.no_show_rate ?? 0}%`} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white p-1">
|
||||
{STATUS.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
onClick={() => setStatus(s.value)}
|
||||
className={cn("rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors", status === s.value ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100")}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{!data ? (
|
||||
<div className="flex h-40 items-center justify-center"><Spinner /></div>
|
||||
) : !data.notifications.length ? (
|
||||
<EmptyState icon={Bell} title="Sin notificaciones" description="Regenera los recordatorios o agrega citas próximas." />
|
||||
) : (
|
||||
<div className="divide-y divide-slate-50">
|
||||
{data.notifications.map((n: any) => {
|
||||
const ch = CHANNEL[n.channel as keyof typeof CHANNEL] ?? CHANNEL.whatsapp;
|
||||
const ChIcon = ch.icon;
|
||||
return (
|
||||
<div key={n.id} className="group flex items-start gap-3 px-5 py-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-white" style={{ background: ch.color }}>
|
||||
<ChIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-sm font-bold text-slate-900">{n.client_name ?? "Cliente"}</span>
|
||||
<Badge bg="#eef4ff" fg="#1c36dc">{KIND_LABEL[n.kind] ?? n.kind}</Badge>
|
||||
{n.service_name && <span className="text-[11px] text-slate-500">· {n.service_name}</span>}
|
||||
{n.status === "pending" && <span className="chip bg-amber-50 text-amber-700">pendiente</span>}
|
||||
{n.status === "sent" && <span className="chip bg-emerald-50 text-emerald-700"><CheckCircle2 className="h-3 w-3" /> enviada</span>}
|
||||
{n.status === "canceled" && <span className="chip bg-slate-100 text-slate-500">cancelada</span>}
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-slate-500">{n.message}</p>
|
||||
<div className="mt-0.5 text-[10px] text-slate-400">
|
||||
Programa: {formatRelative(n.send_at)} · {ch.label}
|
||||
</div>
|
||||
</div>
|
||||
{n.status === "pending" && (
|
||||
<div className="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button onClick={() => act(n.id, "send")} className="rounded-lg p-1.5 text-slate-400 hover:bg-emerald-50 hover:text-emerald-600" title="Enviar ahora">
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => act(n.id, "cancel")} className="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600" title="Cancelar">
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-center text-[11px] text-slate-400">
|
||||
Demo: el envío es simulado (no se conecta a WhatsApp/SMS reales). En producción se integraría con la API de WhatsApp Business / Twilio.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Kpi({ icon: Icon, color, label, value }: any) {
|
||||
return (
|
||||
<div className="card relative overflow-hidden p-4">
|
||||
<div className="absolute -right-4 -top-4 h-16 w-16 rounded-full opacity-10" style={{ background: color }} />
|
||||
<Icon className="h-4 w-4" style={{ color }} />
|
||||
<div className="mt-1 truncate text-lg font-extrabold text-slate-900">{value}</div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user