feat(platform): multi-tenancy con credenciales por negocio y sincronización por id

El backend Postgres de `platform/` asumía un solo negocio con un solo token del
CRM. Este cambio lo convierte en una plataforma multi-cuenta y añade la
sincronización selectiva de las cinco entidades del encargo.

## Multi-tenancy

El `locationId` ya era por negocio, pero el token vivía en la variable de entorno
`CRM_TOKEN`, una sola para todo el proceso. Con dos negocios eso usaba el token
del primero contra la subcuenta del segundo: 401 en el mejor caso, escritura en
la subcuenta equivocada en el peor.

- `lib/crypto.ts` — AES-256-GCM para los tokens. Autenticado a propósito: una
  fila manipulada hace que el descifrado FALLE, en vez de devolver basura que
  acabaríamos mandando como credencial al CRM. La clave maestra vive en
  `CRM_MASTER_KEY`, fuera de la base.
- `crm/ctx.ts` — `CrmCtx { businessId, locationId, token }` sustituye al
  `locationId: string` suelto que viajaba por once firmas. Es un objeto y no dos
  parámetros porque dos `string` seguidos se cruzan sin que el compilador diga
  nada, y cruzarlos aquí manda el token de un cliente a la subcuenta de otro. Es
  el único sitio donde el token existe descifrado, y solo en memoria.
- `crm/client.ts` — `CrmOptions.token` pasa a ser OBLIGATORIO, sin valor por
  defecto: olvidarlo es ahora un error de compilación. El estrangulador pasa a
  ser por token y aprende la cuota de las cabeceras `x-ratelimit-*`, que declaran
  100 peticiones por 10 s — el cliente iba 6,5x por debajo con una estimación.
- Migración 003: credencial cifrada, calendario y la red de seguridad de mensajes
  POR NEGOCIO. Como variable global decidía por todas las cuentas a la vez.

Lo único de la credencial que sale del servidor es la huella de 6 caracteres.

## Consola de superadministración

`/api/admin`, solo para el rol `admin`: alta de cuentas con su dueña en una
transacción, vínculo, desvínculo y suspensión. Las credenciales se COMPRUEBAN
contra el CRM antes de guardarse — un token sin validar traslada el fallo al
primer intento de sincronizar, lejos de donde se cometió. El error distingue
«token inválido» de «subcuenta inexistente» de «token de otra subcuenta».

Pantalla en `/admin/cuentas`, verificada en navegador: el campo del token es de
contraseña y viene vacío, porque no hay valor que traer.

## Sincronización por identificador

`POST /api/crm/sync/:entidad/:id` para contacto, conversación, mensaje, cita y
servicio. La dirección la decide la entidad: las tres primeras se TRAEN porque el
CRM es su dueño; las dos últimas se EMPUJAN, porque el calendario del CRM tiene
una sola cita en dos años y su catálogo de servicios está vacío.

- `crm/conversations.ts` — lectura por id de conversaciones y mensajes sueltos.
- `crm/syncConversations.ts` — el espejo persistido. Las tablas existían desde
  002_crm.sql y nadie escribía en ellas: la bandeja consultaba el CRM en vivo.
- `crm/calendars.ts` — escritura de citas al calendario. `isoConDesplazamiento`
  escribe la hora de pared del negocio con su desplazamiento; `toISOString()`
  habría movido la hora que el CRM enseña en su interfaz.
- `crm/services.ts` — publicación de servicios al catálogo.

## Verificado contra la subcuenta real, no deducido

Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van
en un ciclo crear → releer → borrar → confirmar borrado, con la limpieza en un
`finally`, y antes se comprobó que el borrado existe: preguntar si se puede
deshacer ANTES de escribir en el CRM de un cliente, no después. La subcuenta
quedó como estaba.

47 hallazgos medidos en `crm/HALLAZGOS.md`, y la referencia de endpoints en
`crm/API.md`, con la lista explícita de dónde la documentación oficial falla.

110 pruebas de plataforma en verde, typecheck limpio, build correcto. El backend
de demo de `server/` no se ha tocado y sigue con sus 43 pruebas.

## Deuda conocida, dicha sin rodeos

- La bandeja de mensajes todavía lee en vivo del CRM, no del espejo.
- La autenticación sigue siendo el id del usuario en texto plano, también para el
  rol admin. Esta consola crea cuentas y guarda credenciales de clientes encima
  de esa base: no debe quedar expuesta a internet hasta endurecerla.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
AgendaPro Dev
2026-08-30 15:07:20 -06:00
co-authored by Claude Opus 5
parent dcbf750c09
commit 6d67b23e55
95 changed files with 16132 additions and 41 deletions
+9
View File
@@ -10,12 +10,15 @@ import { ClientsPage } from "./pages/ClientsPage";
import { TicketsPage } from "./pages/TicketsPage";
import { ClientDetailPage } from "./pages/ClientDetailPage";
import { CashPage } from "./pages/CashPage";
import { DayClosePage } from "./pages/DayClosePage";
import { MessagesPage } from "./pages/MessagesPage";
import { NotificationsPage } from "./pages/NotificationsPage";
import { SettingsPage } from "./pages/SettingsPage";
import { MyPerformancePage } from "./pages/MyPerformancePage";
import { AdminOverviewPage } from "./pages/admin/AdminOverviewPage";
import { AdminBusinessesPage } from "./pages/admin/AdminBusinessesPage";
import { AdminBusinessDetailPage } from "./pages/admin/AdminBusinessDetailPage";
import PlatformAccountsPage from "./pages/admin/PlatformAccountsPage";
import BookingPage from "./pages/public/BookingPage";
// Las dos páginas públicas van aparte del bundle principal por dos razones que
@@ -103,6 +106,7 @@ function AppRoutes() {
{user && isAdmin && (
<Route element={<AdminShell />}>
<Route path="/admin" element={<AdminOverviewPage />} />
<Route path="/admin/cuentas" element={<PlatformAccountsPage />} />
<Route path="/admin/businesses" element={<AdminBusinessesPage />} />
<Route path="/admin/businesses/:id" element={<AdminBusinessDetailPage />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
@@ -121,6 +125,11 @@ function AppRoutes() {
<Route path="/clients" element={<ClientsPage />} />
<Route path="/clients/:id" element={<ClientDetailPage />} />
<Route path="/me" element={<MyPerformancePage />} />
{/* Sin restricción de rol a propósito: el cierre de día es operación,
no finanzas, y la empleada tiene que poder resolver sus citas. El
servidor devuelve 403 si intenta resolver una cita ajena. */}
<Route path="/cierre-dia" element={<DayClosePage />} />
<Route path="/mensajes" element={<MessagesPage />} />
{user.role === "owner" && <Route path="/employees" element={<EmployeesPage />} />}
{user.role === "owner" && <Route path="/services" element={<ServicesPage />} />}
{user.role === "owner" && <Route path="/tickets" element={<TicketsPage />} />}
+4 -1
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import {
import { Link2,
Building2,
LayoutDashboard,
LogOut,
@@ -27,6 +27,9 @@ interface NavItem {
const NAV: NavItem[] = [
{ to: "/admin", label: "Resumen", icon: LayoutDashboard, end: true },
{ to: "/admin/businesses", label: "Negocios", icon: Building2 },
// Cuentas de la plataforma Postgres, con su vínculo a Bucéfalo CRM. Convive
// con «Negocios», que es la consola del backend de demo.
{ to: "/admin/cuentas", label: "Cuentas y CRM", icon: Link2 },
];
export function AdminShell() {
+4
View File
@@ -2,6 +2,8 @@ import { Suspense, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import {
CalendarDays,
CalendarCheck,
MessageSquare,
LayoutDashboard,
Users,
Scissors,
@@ -40,6 +42,8 @@ const NAV: NavItem[] = [
{ to: "/calendar", label: "Calendario", icon: CalendarDays },
{ to: "/clients", label: "Clientes", icon: Users },
{ to: "/me", label: "Mi desempeño", icon: BarChart3, employeeOnly: true },
{ to: "/cierre-dia", label: "Cierre de día", icon: CalendarCheck },
{ to: "/mensajes", label: "Mensajes", icon: MessageSquare },
{ to: "/employees", label: "Empleados", icon: UserCircle, ownerOnly: true },
{ to: "/services", label: "Servicios", icon: Scissors, ownerOnly: true },
{ to: "/cash", label: "Caja", icon: Wallet, ownerOnly: true },
+174
View File
@@ -0,0 +1,174 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { RefreshCw, CheckCircle2, AlertTriangle, Link2, Users } from "lucide-react";
import { api } from "../lib/api";
import { Spinner } from "./ui";
/**
* El panel de sincronización con Bucéfalo CRM, para la pantalla de clientes.
*
* Muestra tres cosas y ninguna es decorativa: cuántas clientas están realmente
* ancladas al CRM, **cuántas son contactables** —que es el techo de utilidad de
* toda la agenda, y hoy son 6 de cada 10— y qué quedó sin sincronizar.
*/
export function CrmSyncPanel() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["crm-status"],
queryFn: () => api.crm.status(),
});
const sync = useMutation({
mutationFn: () => api.crm.syncContacts(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["crm-status"] });
qc.invalidateQueries({ queryKey: ["clients"] });
},
});
const conectar = useMutation({
mutationFn: () => api.crm.connect(),
onSuccess: () => qc.invalidateQueries({ queryKey: ["crm-status"] }),
});
const flush = useMutation({
mutationFn: () => api.crm.flushOutbox(),
onSuccess: () => qc.invalidateQueries({ queryKey: ["crm-status"] }),
});
if (isLoading) return null;
if (!data?.connected) {
return (
<div className="flex flex-wrap items-center gap-3 rounded-2xl border border-dashed border-slate-300 bg-white p-4">
<Link2 className="h-5 w-5 shrink-0 text-slate-400" />
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-slate-900">Sin conectar a Bucéfalo CRM</p>
<p className="text-sm text-slate-500">
Conecta la subcuenta para traer los contactos con su atribución.
</p>
</div>
<button
type="button"
className="btn-primary tap-target"
disabled={conectar.isPending}
onClick={() => conectar.mutate()}
>
{conectar.isPending ? "Conectando…" : "Conectar"}
</button>
{conectar.isError && (
<p className="basis-full text-sm text-red-600">{(conectar.error as Error).message}</p>
)}
</div>
);
}
const s = data.stats;
const pendientes = (data.outbox?.pendiente ?? 0) + (data.outbox?.indeterminado ?? 0);
const fallidos = data.outbox?.fallido ?? 0;
const pctContactables = s && s.clientes ? Math.round((s.contactables / s.clientes) * 100) : 0;
return (
<div className="space-y-3 rounded-2xl bg-white p-4 shadow-soft">
{/* El botón baja a su propia fila en teléfono: compitiendo por el ancho
con el texto se salía de la tarjeta y lo pisaba. */}
<div className="flex flex-wrap items-center gap-3">
<Users className="h-5 w-5 shrink-0 text-brand-600" />
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-slate-900">Bucéfalo CRM</p>
<p className="truncate text-sm text-slate-500">
{data.last_sync_at
? `Última sincronización: ${new Date(data.last_sync_at).toLocaleString("es-MX")}`
: "Todavía no se ha sincronizado"}
</p>
</div>
<button
type="button"
className="btn-primary tap-target inline-flex basis-full items-center justify-center gap-2 sm:basis-auto"
disabled={sync.isPending}
onClick={() => sync.mutate()}
>
<RefreshCw className={`h-4 w-4 ${sync.isPending ? "animate-spin" : ""}`} />
{sync.isPending ? "Sincronizando…" : "Sincronizar contactos"}
</button>
</div>
{s && (
<div className="grid grid-cols-3 gap-2">
<Dato label="Clientas" valor={s.clientes} />
<Dato label="Ancladas al CRM" valor={s.sincronizados} />
{/* Es el dato incómodo y por eso está a la vista: una agenda no puede
avisarle a quien no dejó teléfono. */}
<Dato
label="Contactables"
valor={`${s.contactables} · ${pctContactables}%`}
tono={pctContactables < 70 ? "text-amber-600" : "text-emerald-600"}
/>
</div>
)}
{sync.isPending && (
<p className="flex items-center gap-2 text-sm text-slate-500">
<Spinner /> Trayendo contactos del CRM. Con ~3 200 tarda unos 25 segundos.
</p>
)}
{sync.isSuccess && (
<p className="flex items-center gap-2 text-sm text-emerald-700">
<CheckCircle2 className="h-4 w-4" />
{sync.data.created} nuevas y {sync.data.updated} actualizadas, de{" "}
{sync.data.fetched} leídas del CRM.
</p>
)}
{sync.isError && (
<p className="flex items-start gap-2 text-sm text-red-600">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
{(sync.error as Error).message}
</p>
)}
{(pendientes > 0 || fallidos > 0) && (
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-amber-50 p-3">
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-600" />
<p className="min-w-0 flex-1 text-sm text-amber-900">
{pendientes > 0 && `${pendientes} cambio(s) sin enviar al CRM. `}
{fallidos > 0 && `${fallidos} fallido(s).`}
</p>
<button
type="button"
className="tap-target rounded-lg border border-amber-300 px-3 text-sm font-bold text-amber-900 disabled:opacity-50"
disabled={flush.isPending}
onClick={() => flush.mutate()}
>
{flush.isPending ? "Enviando…" : "Reintentar"}
</button>
</div>
)}
{data.allow_duplicate_opp === false && (
<p className="text-xs leading-relaxed text-slate-500">
La subcuenta tiene desactivado «permitir oportunidades duplicadas», así que cada
clienta tiene <strong>una</strong> oportunidad que se recicla en cada cita. Para que
cada cita estrene la suya hay que activar ese ajuste en el CRM.
</p>
)}
</div>
);
}
function Dato({
label,
valor,
tono = "text-slate-900",
}: {
label: string;
valor: number | string;
tono?: string;
}) {
return (
<div className="rounded-xl bg-slate-50 p-3">
<div className="text-[10px] font-bold uppercase tracking-wide text-slate-400">{label}</div>
<div className={`text-lg font-extrabold ${tono}`}>{valor}</div>
</div>
);
}
+107
View File
@@ -1,4 +1,7 @@
import type {
PlatformAccount,
CrmEntidad,
CrmSyncUnoResult,
Appointment,
Business,
Client,
@@ -12,6 +15,15 @@ import type {
Ticket,
User,
CategorySlice,
AttendanceResult,
DayCloseSummary,
DayClosure,
PaymentMethod,
CrmStatus,
CrmSyncResult,
ConversationSummary,
ConversationMessage,
SendMessageResult,
} from "../../shared/types";
const BASE = "/api";
@@ -72,6 +84,36 @@ export const api = {
request<any>(`/admin/businesses/${id}/seed-template`, { method: "POST", body: JSON.stringify(data) }),
resetDemo: (id: number, data: any) =>
request<any>(`/admin/businesses/${id}/reset-demo`, { method: "POST", body: JSON.stringify(data) }),
// ── Consola de plataforma del backend Postgres ───────────────────────
// Conviven con las de arriba a propósito: el frontend habla con el
// backend que tenga configurado, y cada uno expone su propia superficie.
accounts: () => request<{ businesses: PlatformAccount[] }>("/admin/businesses"),
createAccount: (b: {
name: string;
owner_email: string;
owner_name: string;
owner_password: string;
timezone?: string;
industry?: string;
}) =>
request<{ business: PlatformAccount; owner: { id: number; email: string } }>(
"/admin/businesses",
{ method: "POST", body: JSON.stringify(b) }
),
linkCrm: (id: number, b: { location_id: string; token: string; label?: string }) =>
request<{ ok: true; location_id: string; label: string | null }>(
`/admin/businesses/${id}/crm`,
{ method: "PUT", body: JSON.stringify(b) }
),
unlinkCrm: (id: number) =>
request<{ ok: true }>(`/admin/businesses/${id}/crm`, { method: "DELETE" }),
updateAccount: (id: number, b: { status?: string; name?: string; timezone?: string }) =>
request<{ business: PlatformAccount }>(`/admin/businesses/${id}`, {
method: "PATCH",
body: JSON.stringify(b),
}),
},
business: {
get: () => request<{ business: Business }>("/business"),
@@ -97,6 +139,7 @@ export const api = {
},
clients: {
list: (q?: string) => request<{ clients: Client[] }>(`/clients${q ? `?q=${encodeURIComponent(q)}` : ""}`),
get: (id: number) => request<{ client: Client }>(`/clients/${id}`),
create: (data: any) => request<{ client: Client }>("/clients", { method: "POST", body: JSON.stringify(data) }),
update: (id: number, data: any) =>
request<{ client: Client }>(`/clients/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
@@ -118,6 +161,70 @@ export const api = {
body: JSON.stringify(data),
}),
remove: (id: number) => request<{ ok: boolean }>(`/appointments/${id}`, { method: "DELETE" }),
/**
* El toque de asistencia. Un solo POST resuelve la cita: si vino, además
* crea la visita. El importe es opcional a propósito — pedirlo antes de
* poder marcar convertiría un toque en un formulario.
*/
attendance: (
id: number,
body: { attended: boolean; total_charged?: number; payment_method?: PaymentMethod }
) =>
request<AttendanceResult>(`/appointments/${id}/attendance`, {
method: "POST",
body: JSON.stringify(body),
}),
cancel: (id: number, body: { cancelled_by: "client" | "business"; reason?: string }) =>
request<{ appointment: Appointment }>(`/appointments/${id}/cancel`, {
method: "POST",
body: JSON.stringify(body),
}),
},
crm: {
status: () => request<CrmStatus>("/crm/status"),
connect: () => request<{ connection: unknown }>("/crm/connect", { method: "POST", body: "{}" }),
syncContacts: () =>
request<CrmSyncResult>("/crm/sync/contacts", { method: "POST", body: "{}" }),
flushOutbox: () =>
request<{ tomadas: number; confirmadas: number; fallidas: number; indeterminadas: number }>(
"/crm/outbox/flush",
{ method: "POST", body: "{}" }
),
outbox: () => request<{ items: any[]; resumen: any }>("/crm/outbox"),
/** Sincroniza UNA entidad por su identificador. */
syncOne: (entidad: CrmEntidad, id: string) =>
request<CrmSyncUnoResult>(`/crm/sync/${entidad}/${encodeURIComponent(id)}`, {
method: "POST",
}),
/** Espeja las conversaciones recientes con sus mensajes. */
syncConversations: (limite = 50) =>
request<{ conversaciones: number; mensajes: number }>("/crm/sync/conversations", {
method: "POST",
body: JSON.stringify({ limite }),
}),
},
messages: {
list: (limit = 20) =>
request<{ total: number; conversations: ConversationSummary[] }>(
`/messages?limit=${limit}`
),
thread: (conversationId: string) =>
request<{ messages: ConversationMessage[] }>(`/messages/${conversationId}`),
send: (body: { client_id?: number; crm_contact_id?: string; subject: string; body: string }) =>
request<SendMessageResult>("/messages/send", {
method: "POST",
body: JSON.stringify(body),
}),
},
dayClose: {
get: (date?: string) =>
request<DayCloseSummary>(`/day-close${date ? `?date=${date}` : ""}`),
close: (date: string) =>
request<{ closure: DayClosure }>(`/day-close`, {
method: "POST",
body: JSON.stringify({ date }),
}),
},
dashboard: {
overview: (range?: number) =>
+60 -2
View File
@@ -13,6 +13,7 @@ import {
Pencil,
} from "lucide-react";
import { api } from "../lib/api";
import type { Client } from "../../shared/types";
import { PageHeader, Avatar, Spinner, EmptyState, Badge } from "../components/ui";
import { Modal } from "../components/Modal";
import {
@@ -28,11 +29,14 @@ export function ClientDetailPage() {
const clientId = Number(id);
const [editing, setEditing] = useState(false);
// Se pide la ficha por su id y no se busca dentro de la lista: la lista
// devuelve 50 filas, y con 3 200 clientas sincronizadas del CRM la ficha
// sencillamente no aparecía.
const { data, isLoading } = useQuery({
queryKey: ["clients", clientId],
queryFn: () => api.clients.list().then((r) => ({ ...r, target: r.clients.find((c) => c.id === clientId) })),
queryFn: () => api.clients.get(clientId),
});
const client = data?.target;
const client = data?.client;
const { data: appts } = useQuery({
queryKey: ["clients", clientId, "appts"],
queryFn: () => api.appointments.list({ client_id: clientId, limit: 50 }),
@@ -104,6 +108,8 @@ export function ClientDetailPage() {
)}
</div>
<AtribucionCard client={client} />
<div className="card p-5">
<h4 className="mb-3 text-xs font-bold uppercase tracking-wider text-slate-500">Resumen</h4>
<div className="grid grid-cols-2 gap-3">
@@ -241,3 +247,55 @@ function ClientEditModal({ open, client, onClose }: { open: boolean; client: any
</Modal>
);
}
/**
* De dónde vino la clienta.
*
* Es dato del CRM y **de solo lectura**: allá la atribución se escribe en el
* alta y es inmutable después, así que un formulario aquí prometería algo que
* no se puede cumplir. Solo se pinta si hay algo que contar — una tarjeta vacía
* en cada ficha es ruido.
*/
function AtribucionCard({ client }: { client: Client }) {
const filas: [string, string | null | undefined][] = [
["Fuente", client.attr_session_source],
["Medio", client.attr_medium],
["Campaña", client.attr_campaign],
["utm_source", client.attr_utm_source],
["utm_medium", client.attr_utm_medium],
["Contenido", client.attr_utm_content],
["Anuncio", client.attr_ad_id],
["Origen en el CRM", client.crm_source],
];
const conValor = filas.filter(([, v]) => v);
if (!conValor.length && !client.crm_contact_id) return null;
return (
<div className="card p-5">
<h4 className="mb-3 text-xs font-bold uppercase tracking-wider text-slate-500">
Adquisición
</h4>
{conValor.length ? (
<dl className="space-y-1.5 text-xs">
{conValor.map(([k, v]) => (
<div key={k} className="flex gap-2">
<dt className="w-28 shrink-0 text-slate-400">{k}</dt>
<dd className="min-w-0 break-words font-medium text-slate-700">{v}</dd>
</div>
))}
</dl>
) : (
<p className="text-xs text-slate-500">
El CRM no registró de dónde vino esta clienta.
</p>
)}
{client.crm_synced_at && (
<p className="mt-3 border-t border-slate-100 pt-2 text-[11px] text-slate-400">
{/* La fecha de sincronización se muestra siempre: presentar dato de un
espejo como si fuera de ahora mismo es cómo se pierde la confianza. */}
Sincronizado del CRM el {formatDate(client.crm_synced_at)}
</p>
)}
</div>
);
}
+5
View File
@@ -5,6 +5,7 @@ import { Search, Users, Mail, Phone, DollarSign, Calendar, ChevronRight, Sparkle
import { api } from "../lib/api";
import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui";
import { formatCurrency, formatDate, formatRelative } from "../lib/format";
import { CrmSyncPanel } from "../components/CrmSyncPanel";
export function ClientsPage() {
const navigate = useNavigate();
@@ -31,6 +32,10 @@ export function ClientsPage() {
subtitle="Busca, edita y revisa el historial de tus clientes."
/>
<div className="px-5 pt-4 sm:px-7">
<CrmSyncPanel />
</div>
<div className="px-5 pt-4 sm:px-7">
<div className="relative max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
+166
View File
@@ -0,0 +1,166 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CalendarCheck, CheckCircle2, Lock, XCircle } from "lucide-react";
import { api } from "../lib/api";
import { PageHeader, EmptyState, Spinner } from "../components/ui";
import { formatTime } from "../lib/format";
/**
* El cierre de día.
*
* Es la pieza por la que existe el proyecto: hoy el negocio no tiene el dato de
* si la clienta vino. La regla de diseño es que registrar sea el camino más
* corto para trabajar, no una tarea añadida al final — de ahí que la resolución
* sea **un solo toque** por cita, sin diálogo intermedio ni formulario, y que el
* importe sea opcional.
*/
export function DayClosePage() {
const [date, setDate] = useState(() => {
const now = new Date();
// Fecha local del navegador, que es la del spa: el `toISOString()` de aquí
// devolvería el día UTC y a partir de las 18:00 mostraría el día siguiente.
const p = (n: number) => String(n).padStart(2, "0");
return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}`;
});
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["day-close", date],
queryFn: () => api.dayClose.get(date),
});
const attendance = useMutation({
mutationFn: (v: { id: number; attended: boolean }) =>
api.appointments.attendance(v.id, { attended: v.attended }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["day-close", date] }),
});
const close = useMutation({
mutationFn: () => api.dayClose.close(date),
onSuccess: () => qc.invalidateQueries({ queryKey: ["day-close", date] }),
});
const pendientes = data?.unresolved.length ?? 0;
return (
<div className="flex h-full flex-col">
<PageHeader
title="Cierre de día"
subtitle="Marca si cada clienta vino o no vino. Es el dato que hoy no queda registrado en ninguna parte."
actions={
<input
type="date"
className="input w-full sm:w-auto"
value={date}
onChange={(e) => setDate(e.target.value)}
aria-label="Día a cerrar"
/>
}
/>
<div className="flex-1 overflow-y-auto px-5 pb-8 pt-5 sm:px-7">
{isLoading || !data ? (
<div className="flex justify-center py-16 text-slate-400">
<Spinner className="h-6 w-6" />
</div>
) : (
<div className="space-y-6">
{/* Tres columnas desde el teléfono, no `sm:grid-cols-3`: apiladas
ocupaban media pantalla y empujaban la lista fuera de la vista,
que es justo lo que la empleada viene a tocar. */}
<div className="grid grid-cols-3 gap-2 sm:gap-3">
<Metric label="Asistieron" value={data.attended} tone="text-emerald-600" />
<Metric label="No asistieron" value={data.no_show} tone="text-amber-600" />
<Metric label="Canceladas" value={data.cancelled} tone="text-slate-500" />
</div>
{pendientes === 0 ? (
<div className="rounded-2xl bg-white shadow-soft">
<EmptyState
icon={data.closed_at ? Lock : CalendarCheck}
title="No queda ninguna cita sin resolver"
description={
data.closed_at
? "Este día ya está cerrado."
: "Ya puedes cerrar el día."
}
/>
</div>
) : (
<ul className="space-y-2">
{data.unresolved.map((a) => (
<li
key={a.id}
className="flex flex-wrap items-center gap-3 rounded-2xl bg-white p-3 shadow-soft"
>
{/* `basis-full` en teléfono: compitiendo por el ancho con
los dos botones, el servicio se truncaba a «Exte…» y la
empleada no podía saber a qué cita decía que sí. */}
<div className="min-w-0 basis-full sm:flex-1 sm:basis-auto">
<p className="truncate text-sm font-bold text-slate-900">{a.client_name}</p>
<p className="truncate text-sm text-slate-500">
{formatTime(a.start_at)} · {a.service_name} · {a.employee_name}
</p>
</div>
<div className="flex flex-1 gap-2 sm:flex-none">
<button
type="button"
className="tap-target inline-flex flex-1 items-center justify-center gap-1.5 rounded-xl bg-emerald-600 px-3 text-sm font-bold text-white disabled:opacity-50 sm:flex-none"
disabled={attendance.isPending}
onClick={() => attendance.mutate({ id: a.id, attended: true })}
>
<CheckCircle2 className="h-4 w-4" /> Vino
</button>
<button
type="button"
className="tap-target inline-flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-slate-300 px-3 text-sm font-bold text-slate-700 disabled:opacity-50 sm:flex-none"
disabled={attendance.isPending}
onClick={() => attendance.mutate({ id: a.id, attended: false })}
>
<XCircle className="h-4 w-4" /> No vino
</button>
</div>
</li>
))}
</ul>
)}
{attendance.isError && (
<p className="text-sm text-red-600">{(attendance.error as Error).message}</p>
)}
<div className="flex flex-wrap items-center gap-3 border-t border-slate-200 pt-4">
<button
type="button"
className="btn-primary inline-flex items-center gap-2 disabled:opacity-50"
disabled={pendientes > 0 || !!data.closed_at || close.isPending}
onClick={() => close.mutate()}
>
{data.closed_at ? <Lock className="h-4 w-4" /> : <CalendarCheck className="h-4 w-4" />}
{data.closed_at ? "Día cerrado" : "Cerrar el día"}
</button>
{/* El botón no se esconde cuando falta trabajo: se explica. */}
{pendientes > 0 && (
<span className="text-sm text-slate-500">
Faltan {pendientes} cita{pendientes === 1 ? "" : "s"} por resolver.
</span>
)}
{close.isError && (
<span className="text-sm text-red-600">{(close.error as Error).message}</span>
)}
</div>
</div>
)}
</div>
</div>
);
}
function Metric({ label, value, tone }: { label: string; value: number; tone: string }) {
return (
<div className="rounded-2xl bg-white p-4 shadow-soft">
<div className="text-[10px] font-bold uppercase tracking-wide text-slate-400">{label}</div>
<div className={`text-2xl font-extrabold ${tone}`}>{value}</div>
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Send, MessageSquare, Mail, AlertTriangle, ArrowLeft } from "lucide-react";
import { api } from "../lib/api";
import { PageHeader, EmptyState, Spinner } from "../components/ui";
import type { ConversationSummary } from "../../shared/types";
/**
* Bandeja de mensajes.
*
* El CRM sigue siendo el sistema de mensajería omnicanal: aquí se lee y se
* responde, pero el histórico vive allá. La razón de que esta pantalla exista
* es tener la ficha de la clienta y su agenda **al lado** del hilo — ese «al
* lado» es lo único que haría que alguien deje de contestar en otra app.
*
* Hoy solo el correo está conectado en la subcuenta. WhatsApp y SMS no, y la
* interfaz lo dice en vez de ofrecer un botón que falla.
*/
export function MessagesPage() {
const [abierta, setAbierta] = useState<ConversationSummary | null>(null);
const { data, isLoading, isError, error } = useQuery({
queryKey: ["conversations"],
queryFn: () => api.messages.list(25),
});
return (
<div className="flex h-full flex-col">
<PageHeader
title="Mensajes"
subtitle="Conversaciones del CRM. Hoy solo se puede responder por correo."
/>
<div className="flex-1 overflow-y-auto px-5 pb-8 pt-5 sm:px-7">
{isLoading ? (
<div className="flex justify-center py-16 text-slate-400">
<Spinner className="h-6 w-6" />
</div>
) : isError ? (
<div className="rounded-2xl bg-white p-6 shadow-soft">
<EmptyState
icon={AlertTriangle}
title="No se pudo leer la bandeja"
description={(error as Error).message}
/>
</div>
) : abierta ? (
<Hilo conversacion={abierta} onVolver={() => setAbierta(null)} />
) : !data?.conversations.length ? (
<div className="rounded-2xl bg-white shadow-soft">
<EmptyState icon={MessageSquare} title="No hay conversaciones" />
</div>
) : (
<ul className="space-y-2">
{data.conversations.map((c) => (
<li key={c.crm_conversation_id}>
<button
type="button"
className="flex w-full items-center gap-3 rounded-2xl bg-white p-3 text-left shadow-soft"
onClick={() => setAbierta(c)}
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-slate-900">
{c.contact_name}
{c.unread_count > 0 && (
<span className="ml-2 rounded-full bg-brand-600 px-2 py-0.5 text-[10px] font-bold text-white">
{c.unread_count}
</span>
)}
</p>
<p className="truncate text-sm text-slate-500">
{c.last_message_body || <em>sin texto</em>}
</p>
</div>
{c.last_message_type && (
<span className="shrink-0 rounded-lg bg-slate-100 px-2 py-1 text-[10px] font-bold uppercase text-slate-500">
{String(c.last_message_type).replace(/^TYPE_/, "")}
</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
}
function Hilo({
conversacion,
onVolver,
}: {
conversacion: ConversationSummary;
onVolver: () => void;
}) {
const qc = useQueryClient();
const [asunto, setAsunto] = useState("");
const [cuerpo, setCuerpo] = useState("");
const { data, isLoading } = useQuery({
queryKey: ["thread", conversacion.crm_conversation_id],
queryFn: () => api.messages.thread(conversacion.crm_conversation_id),
});
const enviar = useMutation({
mutationFn: () =>
api.messages.send({
client_id: conversacion.client?.id,
crm_contact_id: conversacion.crm_contact_id ?? undefined,
subject: asunto,
body: cuerpo,
}),
onSuccess: () => {
setAsunto("");
setCuerpo("");
qc.invalidateQueries({ queryKey: ["thread", conversacion.crm_conversation_id] });
},
});
return (
<div className="space-y-4">
<button
type="button"
className="tap-target inline-flex items-center gap-2 text-sm font-bold text-slate-600"
onClick={onVolver}
>
<ArrowLeft className="h-4 w-4" /> Volver a la bandeja
</button>
<div className="rounded-2xl bg-white p-4 shadow-soft">
<p className="text-sm font-bold text-slate-900">{conversacion.contact_name}</p>
{conversacion.client ? (
<a
className="text-sm text-brand-600 underline"
href={`/clients/${conversacion.client.id}`}
>
Ver ficha de la clienta
</a>
) : (
<p className="text-sm text-slate-500">
Sin ficha local. Sincroniza los contactos para enlazarla.
</p>
)}
</div>
{isLoading ? (
<div className="flex justify-center py-8 text-slate-400">
<Spinner className="h-5 w-5" />
</div>
) : (
<ul className="space-y-2">
{(data?.messages ?? []).map((m) => (
<li
key={m.id}
className={`max-w-[85%] rounded-2xl p-3 text-sm shadow-soft ${
m.direction === "outbound"
? "ml-auto bg-brand-50 text-slate-900"
: "bg-white text-slate-900"
}`}
>
<p className="whitespace-pre-wrap break-words">{m.body || <em>sin texto</em>}</p>
<p className="mt-1 text-[10px] uppercase tracking-wide text-slate-400">
{m.channel} {m.status ? `· ${m.status}` : ""}
</p>
</li>
))}
</ul>
)}
<div className="space-y-2 rounded-2xl bg-white p-4 shadow-soft">
<p className="flex items-center gap-2 text-sm font-bold text-slate-900">
<Mail className="h-4 w-4" /> Responder por correo
</p>
<input
className="input"
placeholder="Asunto"
value={asunto}
onChange={(e) => setAsunto(e.target.value)}
/>
<textarea
className="textarea"
rows={4}
placeholder="Escribe tu mensaje…"
value={cuerpo}
onChange={(e) => setCuerpo(e.target.value)}
/>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
className="btn-primary tap-target inline-flex items-center gap-2 disabled:opacity-50"
disabled={!asunto.trim() || !cuerpo.trim() || enviar.isPending}
onClick={() => enviar.mutate()}
>
<Send className="h-4 w-4" />
{enviar.isPending ? "Enviando…" : "Enviar"}
</button>
{enviar.isSuccess && (
<span className="text-sm text-emerald-700">
{/* El CRM acusa encolado, no entrega: la interfaz no promete más. */}
En camino a {enviar.data.sent_to}.
{enviar.data.aviso && ` ${enviar.data.aviso}`}
</span>
)}
{enviar.isError && (
<span className="text-sm text-red-600">{(enviar.error as Error).message}</span>
)}
</div>
<p className="text-xs text-slate-500">
WhatsApp y SMS todavía no están conectados en esta subcuenta.
</p>
</div>
</div>
);
}
+371
View File
@@ -0,0 +1,371 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Building2, Link2, Unlink, Plus, AlertCircle, CheckCircle2 } from "lucide-react";
import { api } from "../../lib/api";
import { Spinner } from "../../components/ui";
import { Modal } from "../../components/Modal";
import { formatDate } from "../../lib/format";
import type { PlatformAccount } from "../../../shared/types";
/**
* La consola de cuentas de la plataforma.
*
* Dos reglas de esta pantalla no son opcionales:
*
* 1. **El campo del token nunca se rellena con un valor existente**, porque no
* hay valor existente que traer: el servidor no lo devuelve nunca. Lo único
* que se muestra es la huella de 6 caracteres, para poder distinguir un token
* de otro y ver si alguien lo rotó.
* 2. **Los errores muestran el mensaje del servidor**, no uno fijo. El servidor
* distingue «el token no vale» de «la subcuenta no existe» de «el token es de
* otra subcuenta», y esa diferencia es justo lo que necesita quien vincula.
*/
export default function PlatformAccountsPage() {
const qc = useQueryClient();
const [alta, setAlta] = useState(false);
const [vinculando, setVinculando] = useState<PlatformAccount | null>(null);
const { data, isLoading, isError, error } = useQuery({
queryKey: ["admin", "accounts"],
queryFn: () => api.admin.accounts(),
});
if (isLoading) {
return (
<div className="flex justify-center py-16">
<Spinner />
</div>
);
}
if (isError) {
return (
<div className="flex flex-col items-center gap-2 py-16 text-center">
<AlertCircle className="h-6 w-6 text-rose-400" />
<p className="text-sm font-medium text-slate-700">
{error instanceof Error ? error.message : "No pudimos cargar las cuentas."}
</p>
</div>
);
}
const cuentas = data?.businesses ?? [];
return (
<div className="space-y-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold text-slate-900">Cuentas de la plataforma</h1>
<p className="text-sm text-slate-500">
{cuentas.length} {cuentas.length === 1 ? "cuenta" : "cuentas"} ·{" "}
{cuentas.filter((c) => c.token_fingerprint).length} vinculadas a Bucéfalo CRM
</p>
</div>
<button type="button" className="btn-primary tap-target" onClick={() => setAlta(true)}>
<Plus className="h-4 w-4" /> Nueva cuenta
</button>
</header>
<div className="overflow-x-auto rounded-2xl border border-slate-200 bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<th className="px-4 py-3">Negocio</th>
<th className="px-4 py-3">Clientas</th>
<th className="px-4 py-3">Bucéfalo CRM</th>
<th className="px-4 py-3">Última sincronización</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{cuentas.map((c) => (
<tr key={c.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-3">
<div className="flex items-center gap-2 font-semibold text-slate-900">
<Building2 className="h-4 w-4 shrink-0 text-slate-400" />
{c.name}
</div>
<div className="text-xs text-slate-500">
/{c.slug} · {c.timezone}
{c.status !== "active" && (
<span className="ml-2 rounded bg-amber-100 px-1.5 py-0.5 font-medium text-amber-700">
{c.status}
</span>
)}
</div>
</td>
<td className="px-4 py-3 tabular-nums text-slate-700">{c.clientes}</td>
<td className="px-4 py-3">
{c.token_fingerprint ? (
<div className="space-y-0.5">
<div className="flex items-center gap-1.5 font-medium text-emerald-700">
<CheckCircle2 className="h-4 w-4" />
{c.crm_label || c.location_id}
</div>
<div className="font-mono text-xs text-slate-500">
token …{c.token_fingerprint}
</div>
</div>
) : (
<span className="text-slate-400">Sin vincular</span>
)}
</td>
<td className="px-4 py-3 text-xs text-slate-500">
{c.last_sync_at ? formatDate(c.last_sync_at) : "—"}
</td>
<td className="px-4 py-3 text-right">
<button
type="button"
className="btn-secondary tap-target"
onClick={() => setVinculando(c)}
>
<Link2 className="h-4 w-4" />
{c.token_fingerprint ? "Cambiar token" : "Vincular CRM"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{alta && (
<ModalAlta
onClose={() => setAlta(false)}
onDone={() => {
setAlta(false);
qc.invalidateQueries({ queryKey: ["admin", "accounts"] });
}}
/>
)}
{vinculando && (
<ModalVinculo
cuenta={vinculando}
onClose={() => setVinculando(null)}
onDone={() => {
setVinculando(null);
qc.invalidateQueries({ queryKey: ["admin", "accounts"] });
}}
/>
)}
</div>
);
}
function ModalAlta({ onClose, onDone }: { onClose: () => void; onDone: () => void }) {
const [f, setF] = useState({
name: "",
owner_name: "",
owner_email: "",
owner_password: "",
timezone: "America/Mexico_City",
});
const mut = useMutation({
mutationFn: () => api.admin.createAccount(f),
onSuccess: onDone,
});
const listo = f.name && f.owner_name && f.owner_email && f.owner_password;
return (
<Modal open title="Nueva cuenta" onClose={onClose}>
<div className="space-y-3">
<Campo id="ac-name" label="Nombre del negocio">
<input
id="ac-name"
className="input"
value={f.name}
onChange={(e) => setF({ ...f, name: e.target.value })}
/>
</Campo>
<Campo id="ac-tz" label="Zona horaria">
<input
id="ac-tz"
className="input"
value={f.timezone}
onChange={(e) => setF({ ...f, timezone: e.target.value })}
/>
</Campo>
<div className="pt-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
Su administradora
</div>
<Campo id="ac-oname" label="Nombre">
<input
id="ac-oname"
className="input"
value={f.owner_name}
onChange={(e) => setF({ ...f, owner_name: e.target.value })}
/>
</Campo>
<Campo id="ac-omail" label="Correo">
<input
id="ac-omail"
type="email"
className="input"
value={f.owner_email}
onChange={(e) => setF({ ...f, owner_email: e.target.value })}
/>
</Campo>
<Campo id="ac-opass" label="Contraseña inicial">
<input
id="ac-opass"
type="password"
className="input"
value={f.owner_password}
onChange={(e) => setF({ ...f, owner_password: e.target.value })}
/>
</Campo>
{mut.isError && (
<p role="alert" className="text-sm font-medium text-rose-600">
{(mut.error as Error).message}
</p>
)}
<div className="flex justify-end gap-2 pt-2">
<button type="button" className="btn-secondary" onClick={onClose}>
Cancelar
</button>
<button
type="button"
className="btn-primary"
disabled={!listo || mut.isPending}
onClick={() => mut.mutate()}
>
{mut.isPending ? "Creando…" : "Crear cuenta"}
</button>
</div>
</div>
</Modal>
);
}
function ModalVinculo({
cuenta,
onClose,
onDone,
}: {
cuenta: PlatformAccount;
onClose: () => void;
onDone: () => void;
}) {
const [f, setF] = useState({
location_id: cuenta.location_id ?? "",
token: "",
label: cuenta.crm_label ?? "",
});
const vincular = useMutation({
mutationFn: () =>
api.admin.linkCrm(cuenta.id, {
location_id: f.location_id.trim(),
token: f.token.trim(),
label: f.label.trim() || undefined,
}),
onSuccess: onDone,
});
const desvincular = useMutation({
mutationFn: () => api.admin.unlinkCrm(cuenta.id),
onSuccess: onDone,
});
return (
<Modal open title={`Vincular «${cuenta.name}» con Bucéfalo CRM`} onClose={onClose}>
<div className="space-y-3">
<Campo id="cr-loc" label="Identificador de la subcuenta (location id)">
<input
id="cr-loc"
className="input font-mono"
value={f.location_id}
onChange={(e) => setF({ ...f, location_id: e.target.value })}
/>
</Campo>
<Campo id="cr-tok" label="Token privado de la subcuenta">
<input
id="cr-tok"
type="password"
className="input font-mono"
autoComplete="off"
value={f.token}
onChange={(e) => setF({ ...f, token: e.target.value })}
/>
</Campo>
<p className="text-xs text-slate-500">
{cuenta.token_fingerprint
? `Hay un token guardado que termina en …${cuenta.token_fingerprint}. Guardar uno nuevo reemplaza el actual; el anterior no se puede recuperar.`
: "Se comprueba contra Bucéfalo CRM antes de guardarse. Se almacena cifrado y no vuelve a salir del servidor."}
</p>
<Campo id="cr-lbl" label="Etiqueta (opcional)">
<input
id="cr-lbl"
className="input"
placeholder="Se toma el nombre de la subcuenta si lo dejas vacío"
value={f.label}
onChange={(e) => setF({ ...f, label: e.target.value })}
/>
</Campo>
{vincular.isError && (
<p role="alert" className="text-sm font-medium text-rose-600">
{(vincular.error as Error).message}
</p>
)}
<div className="flex flex-wrap justify-between gap-2 pt-2">
{cuenta.token_fingerprint ? (
<button
type="button"
className="btn-secondary text-rose-600"
disabled={desvincular.isPending}
onClick={() => desvincular.mutate()}
>
<Unlink className="h-4 w-4" />
{desvincular.isPending ? "Desvinculando…" : "Desvincular"}
</button>
) : (
<span />
)}
<div className="flex gap-2">
<button type="button" className="btn-secondary" onClick={onClose}>
Cancelar
</button>
<button
type="button"
className="btn-primary"
disabled={!f.location_id.trim() || !f.token.trim() || vincular.isPending}
onClick={() => vincular.mutate()}
>
{vincular.isPending ? "Comprobando…" : "Comprobar y guardar"}
</button>
</div>
</div>
</div>
</Modal>
);
}
/** Etiqueta asociada a su control con `htmlFor`. El resto del panel no lo hace
* y es una deuda registrada; no la aumentamos aquí. */
function Campo({
id,
label,
children,
}: {
id: string;
label: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="label" htmlFor={id}>
{label}
</label>
{children}
</div>
);
}