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:
AgendaPro Dev
2026-07-25 13:45:53 -06:00
commit e8d2435cc2
63 changed files with 16539 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
# Dependencies
node_modules/
# Build output
dist/
dist-ssr/
*.tsbuildinfo
# Database (demo data, regenerated by seed)
data/
# Visual audit output (Playwright screenshots)
screenshots/
# Logs
*.log
server-out.log
server-err.log
dev-out.log
dev-err.log
# Environment
.env
.env.local
.env.*.local
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp
# OpenCode runtime state (machine-local: sessions, goals)
.opencode/
+42
View File
@@ -0,0 +1,42 @@
# AgendaPro — Auditoría de funcionalidades (basada en investigación real)
Fuentes: agendapro.com/mx (site + blog), comparativa Wellbe vs AgendaPro, artículo de política de cancelaciones (CEO Julio Guzmán). G2/Capterra/Trustpilot bloquearon scraping (403), pero el blog oficial expone casos de uso y pain points reales.
## Lo que YA tenemos (vs AgendaPro real)
- ✅ Calendario drag & drop (mes/semana/día/lista) con auto-asignación a empleados
- ✅ Servicios, empleados, clientes + historial/ficha
- ✅ Tickets, pagos, propinas
- ✅ Dashboard dueño (mejor empleado/servicio, top tickets, top/frecuentes clientes, ingresos)
- ✅ Multi-tenant SaaS + consola admin + plantillas
- ✅ Reseñas/ratings
## Brechas CRÍTICAS vs AgendaPro real (oportunidades de mejora)
1. **Política de cancelación + no-shows** — AgendaPro destaca esto; en MX la inasistencia es 1535%. Nosotros solo marcamos estado, sin penalización ni depósito. **#1 pain real.**
2. **Comisiones** — cálculo automático por venta/servicio. Nosotros no lo tenemos. Pain operativo grande.
3. **Cierre de caja / flujo de caja** — apertura/cierre diario, ingresos/egresos. Nosotros solo listamos tickets.
4. **Página de reservas online pública (24/7)** — el cliente se auto-agenda. Nuestro mayor gap de adquisición. Reduce ruido de WhatsApp.
5. **Recordatorios automáticos** (WhatsApp/SMS/email) — reducen inasistencias. Nosotros no tenemos centro de notificaciones.
6. **Inventario** — control de productos con alertas de stock bajo.
7. **Lista de espera / waitlist** — para rellenar cancelaciones.
8. **Fidelización / giftcards / membresías** — paquetes y crédito.
9. **Encuestas de satisfacción (NPS)** — aparte de reseñas puntuales.
10. **Multi-sucursal** dentro de un negocio (ya tenemos multi-tenant, no multi-branch).
11. **Sincronización Google Calendar.**
## Pain points reales de los usuarios (del blog/casos)
- No-shows = pérdida directa de ingresos (un salón perdía 15 citas/mes).
- Cierre de caja con descuadre ("menos dinero del que debería haber").
- Cálculo manual de comisiones = error y tiempo.
- Inventario desordenado en salones.
- Cobranza incómoda ("recordatorios de pago").
- Pérdida de clientes por falta de fidelización.
- Saturación de recepción y WhatsApps repetitivos preguntando horario/precio.
## Priorización (impacto × factibilidad) — lo que implementaremos
- **P0 Política de cancelación + no-show** (company) — resuelve el dolor #1.
- **P0 Comisiones** (company/empleado) — dolor operativo top.
- **P0 Página pública de reservas /b/:slug** (cliente) — mayor diferenciador, reduce WhatsApp.
- **P1 Cierre de caja** (company) — control financiero del día.
- **P1 Centro de notificaciones/recordatorios** (company) — reduce no-shows (simulado, sin WhatsApp real).
Perspectivas cubiertas: **cliente** (booking público), **empresa** (caja, comisiones, política, dashboard), **empleado** (sus comisiones, su agenda).
+164
View File
@@ -0,0 +1,164 @@
# AgendaPro
Aplicación web **multi-tenant (SaaS)** para la gestión de negocios de servicios (estética, spa, barbería, clínicas, etc.):
calendario de citas con **arrastrar y soltar**, gestión de **empleados**, **servicios**, **clientes** y **tickets**,
y un **tablero** con ingresos, mejores empleados, servicios más rentables y clientes top.
Tres roles: **Administrador de plataforma** (crea y gestiona todos los negocios desde la consola admin),
**Dueño** (ve su negocio y el tablero) y **Empleado** (agenda citas que se auto-asignan a él).
## Stack
- **Frontend**: React 18 + TypeScript + Vite + Tailwind CSS
- **Calendario**: FullCalendar (drag & drop, resize, day/time grid)
- **Gráficas**: Recharts
- **Backend**: Node.js + Express
- **Base de datos**: SQLite (vía `node:sqlite`, nativo de Node — sin compilación nativa)
## Requisitos
- Node.js **22.5+** (recomendado 24+). Este proyecto usa el módulo nativo `node:sqlite`.
## Instalación
```bash
npm install
```
La base de datos se **siembra automáticamente** con datos demo la primera vez que arranca el servidor.
Para regenerarla desde cero:
```bash
# Borra la DB y vuelve a sembrar
npm run seed
# o, forzando reset desde el servidor:
$env:RESET_DB="1"; npm run dev:server # PowerShell
RESET_DB=1 npm run dev:server # bash
```
## Desarrollo
```bash
npm run dev
```
Abre http://localhost:5173 (el frontend proxya `/api` al backend en el puerto 3000).
## Producción
```bash
npm run build # compila TS y empaqueta el frontend en dist/
npm start # sirve la API y el frontend estático en http://localhost:3000
```
## Tests y auditoría visual
```bash
npm run test:e2e # 22 pruebas end-to-end de la API (login, CRUD, dashboard, auto-asignación, roles, multi-tenant)
npm run test:admin # 17 pruebas del admin (crear negocio + plantilla, aislamiento, reset-demo, delete cascade)
npm run audit:visual # Playwright: navega 10 páginas × 4 viewports (móvil/tablet/desktop/wide),
# mide overflow horizontal, captura errores de consola y guarda capturas en screenshots/
```
La auditoría visual verifica responsividad en 375 / 768 / 1280 / 1536 px, detecta overflow,
errores de consola y confirma que el calendario, el modal de cita y el cambio de sesión funcionan en cada tamaño.
## Cuentas demo
Todas usan la contraseña **`demo1234`**.
| Rol | Email | Nombre |
|----------|----------------------------------|------------------|
| **Admin**| `[email protected]` | Administrador |
| Dueño | `[email protected]` | Daniela Reyes |
| Empleado | `[email protected]` | Valentina Cruz |
| Empleado | `[email protected]` | Mateo Herrera |
| Empleado | `[email protected]` | Sofía Ramírez |
| Empleado | `[email protected]` | Diego Castillo |
| Empleado | `[email protected]` | Isabela Torres |
| Empleado | `[email protected]` | Carolina Méndez |
> En la pantalla de login aparecen botones de acceso rápido a las cuentas demo,
> y desde la barra lateral puedes cambiar de cuenta con **“Ver como…”**.
## Multi-tenant y consola de administrador
AgendaPro es **multi-tenant**: cada negocio es un tenant aislado (todos los datos llevan `business_id`
y las consultas se filtran por el negocio del usuario). El **administrador de plataforma** gestiona
todos los negocios desde `/admin`:
- **Resumen**: KPIs globales (negocios activos, usuarios, ingresos y citas de toda la plataforma).
- **Negocios**: lista buscable con stats por negocio (usuarios, citas, ingresos).
- **Crear negocio** (2 pasos): eliges una **plantilla** → se cargan empleados, servicios y clientes demo
con historial de citas/tickets listos para usar; defines los datos del negocio y la cuenta del dueño.
- **Detalle de negocio**: editar nombre/industria, cambiar **plan** (Prueba/Pro/Free) y **estado**
(Activo/Suspendido), **recargar datos demo** con otra plantilla (preserva las cuentas de usuario)
o **eliminar** el negocio por completo (con cascade de todos sus datos).
**Plantillas disponibles**: `estetica-spa` (Lumière), `barberia` (Urban Cut), `clinica` (Dental Sonrisa)
y `blank` (vacío, para configurar desde cero). Defínelas en `server/lib/templates.ts`.
El aislamiento entre tenants se valida: un dueño solo ve los datos de su negocio, y los empleados
no pueden acceder al tablero ni a la gestión (403).
## Funcionalidades
### Calendario (Dueño y Empleado)
- Vistas de mes / semana / día, con indicador de hora actual.
- **En móvil** se abre por defecto en **vista Día** y se ofrece una **vista Lista** (agenda) —
la vista Semana/Mes queda reservada para tablet/desktop para evitar columnas aplastadas.
- **Arrastra** para reagendar y **redimensiona** para cambiar duración.
- Clic en un hueco o en **Nueva cita** para abrir el formulario.
- Clic en una cita para **editarla**: cambiar servicio, especialista, cliente, hora, precio, notas.
- **Marcar como completada** (genera un ticket con propina y método de pago).
- **Cancelar** o **eliminar** citas.
- Filtra por **empleado** y por **estado** (programada / completada / cancelada).
### Formulario de cita
- **Servicio** desplegable (menú del negocio), con precio y duración.
- **Especialista**: el empleado lo tiene auto-asignado por defecto (filtrado a quienes ofrecen el servicio).
- **Cliente**: buscar **existente** o crear uno **nuevo** al vuelo.
- Duración, precio y notas editables.
### Tablero (Dueño)
- KPIs: ingresos del período, citas programadas, ticket promedio, ocupación y % de cancelación.
- Gráfica de **ingresos diarios** y **distribución por categoría**.
- **Mejores empleados** por ingreso, con rating y utilización.
- **Servicios más rentables**.
- **Tickets más altos** recientes.
- **Top clientes** (por gasto) y **clientes frecuentes** (por visitas).
- Rango de tiempo ajustable (7 / 30 / 60 días).
### Gestión (Dueño)
- **Empleados**: crear, editar, asignar servicios, ver ingresos/citas/rating.
- **Servicios**: catálogo agrupado por categoría, con especialistas asignados.
- **Tickets**: historial de ventas con filtros por método de pago.
### Clientes (Dueño y Empleado)
- Buscador por nombre / teléfono / correo.
- Ficha del cliente con historial de citas, gasto total y visitas.
## Estructura
```
.
├── server/ # API Express + SQLite
│ ├── db.ts # esquema multi-tenant + sistema de migraciones versionado
│ ├── index.ts # app Express (auto-siembla admin + negocio demo en primer arranque)
│ ├── lib/auth.ts # middlewares: authRequired, ownerOnly, adminOnly
│ ├── lib/templates.ts # plantillas: estetica-spa, barberia, clinica, blank
│ ├── routes/ # auth, business, employees, services, clients, appointments, dashboard, admin
│ └── scripts/seed.ts# seedBusiness(businessId, template) reutilizable + script standalone
├── shared/types.ts # tipos TS compartidos cliente/servidor
├── src/ # frontend React
│ ├── components/ # AppShell, AdminShell, Modal, AppointmentModal, ui, DemoSwitcher, admin/CreateBusinessModal
│ ├── lib/ # api, auth, format
│ └── pages/ # Login, Dashboard, Calendar, Employees, Services, Clients, Tickets + admin/*
└── data/ # SQLite (generado, .gitignored)
```
## Notas de seguridad
Es una **demo**. La autenticación usa un token trivial (el id del usuario) sin hashing de contraseña
ni sesiones reales — **no usar en producción** tal cual. Antes de un despliegue real habría que añadir
hashing (bcrypt), JWT firmados, validación estricta con zod, rate-limiting y HTTPS.
+83
View File
@@ -0,0 +1,83 @@
const BASE = "http://localhost:5173/api";
let pass = 0, fail = 0;
async function req(method, path, body, token) {
const headers = { "Content-Type": "application/json" };
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetch(`${BASE}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = text; }
return { status: res.status, json };
}
function check(name, cond, extra = "") {
if (cond) { pass++; console.log(`${name}`); }
else { fail++; console.log(`${name} ${extra}`); }
}
// Admin login
const { json: aLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
check("admin login", aLogin.user?.role === "admin" && aLogin.token, JSON.stringify(aLogin).slice(0, 100));
const A = aLogin.token;
// Existing data preserved (resilient count — other test suites may add appointments)
const { json: list } = await req("GET", "/admin/businesses", null, A);
check("admin lists businesses", list.businesses?.length >= 1, `got ${list.businesses?.length}`);
check("biz1 data intact", list.businesses[0]?.stats?.appointments >= 100, `appts=${list.businesses[0]?.stats?.appointments}`);
// Platform overview
const { json: ov } = await req("GET", "/admin/overview", null, A);
check("platform overview", ov.businesses >= 1 && ov.total_appointments >= 100, JSON.stringify(ov));
// Templates
const { json: tpls } = await req("GET", "/admin/templates", null, A);
check("templates list", tpls.templates?.length === 4, `got ${tpls.templates?.length}`);
// Create a barberia business with template + owner
const { json: created, status: cStatus } = await req("POST", "/admin/businesses", {
name: "Barbería Test",
industry: "Barbería",
template: "barberia",
ownerName: "Test Owner",
ownerEmail: "[email protected]",
ownerPassword: "demo1234",
}, A);
check("create business + template seed", cStatus === 201 && !!created.business?.id, JSON.stringify(created).slice(0, 150));
check("barberia seeded services", created.seeded?.services === 8, `got ${created.seeded?.services}`);
const newBizId = created.business?.id;
// Owner of new business can log in and see only their data (tenant isolation)
const { json: oLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
check("new owner login", oLogin.user?.role === "owner" && oLogin.user?.business_id === newBizId);
const OT = oLogin.token;
const { json: oEmps } = await req("GET", "/employees", null, OT);
check("tenant isolation: new owner sees only their employees", oEmps.employees?.length === 4, `got ${oEmps.employees?.length}`);
const { json: oBiz } = await req("GET", "/business", null, OT);
check("new owner business name", oBiz.business?.name === "Barbería Test");
// Owner1 still sees only Lumière (isolation the other way)
const { json: owner1 } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
const { json: o1Emps } = await req("GET", "/employees", null, owner1.token);
check("owner1 isolation: still 6 employees", o1Emps.employees?.length === 6, `got ${o1Emps.employees?.length}`);
// Admin cannot be blocked, but owner cannot access admin
const { status: forb } = await req("GET", "/admin/businesses", null, owner1.token);
check("owner blocked from admin (403)", forb === 403);
// Update business (plan/status)
const { json: upd } = await req("PATCH", `/admin/businesses/${newBizId}`, { plan: "pro", status: "suspended" }, A);
check("update business plan/status", upd.business?.plan === "pro" && upd.business?.status === "suspended");
// Reset demo
const { json: reset } = await req("POST", `/admin/businesses/${newBizId}/reset-demo`, { template: "blank" }, A);
check("reset to blank template", reset.business?.template === "blank" && reset.stats?.services === 0, JSON.stringify(reset.stats));
// Delete business
const { status: del } = await req("DELETE", `/admin/businesses/${newBizId}`, null, A);
check("delete business", del === 200);
const { json: list2 } = await req("GET", "/admin/businesses", null, A);
check("business removed", list2.businesses?.length === 1, `got ${list2.businesses?.length}`);
// Cascade: owner.test user should be gone
const { status: gone } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
check("deleted business users removed", gone === 401);
console.log(`\n${pass}/${pass + fail} passed${fail ? `, ${fail} FAILED` : ""}`);
process.exit(fail ? 1 : 0);
+214
View File
@@ -0,0 +1,214 @@
// Comprehensive e2e: ALL features (existing + new) must pass before declaring 100% functional.
const BASE = "http://localhost:5173";
let pass = 0, fail = 0;
const failures = [];
async function req(path, init = {}) {
const url = path.startsWith("http") ? path : `${BASE}${path}`;
const res = await fetch(url, init);
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = text; }
return { status: res.status, json, headers: res.headers };
}
function check(name, cond, extra = "") {
if (cond) { pass++; console.log(`${name}`); }
else { fail++; failures.push(name + (extra ? " :: " + extra : "")); console.log(`${name} ${extra}`); }
}
async function login(email, password = "demo1234") {
const r = await req("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }) });
if (r.status !== 200) throw new Error(`login ${email} failed: ${r.status} ${JSON.stringify(r.json)}`);
return r.json;
}
const H = (token) => ({ authorization: `Bearer ${token}`, "Content-Type": "application/json" });
console.log("\n=== HEALTH & AUTH ===");
const h = await req("/api/health"); check("health", h.json?.ok === true);
const me = await req("/api/auth/demo-users"); check("demo-users >= 8", me.json.users.length >= 8);
const owner = await login("[email protected]"); check("owner login", !!owner.token && owner.user.role === "owner");
const empLogin = await login("[email protected]"); check("employee login", empLogin.user.role === "employee" && empLogin.user.employee_id);
const adminLogin = await login("[email protected]"); check("admin login", adminLogin.user.role === "admin");
const t = owner.token, eT = empLogin.token, aT = adminLogin.token;
// /me fix
const meR = await req("/api/auth/me", { headers: H(t) }); check("/me returns owner", meR.json.user.email === "[email protected]");
console.log("\n=== EXISTING: business / employees / services / clients / appointments / dashboard ===");
const biz = await req("/api/business", { headers: H(t) }); check("business loaded", biz.json.business?.name === "Lumière Estética & Spa");
const emps = await req("/api/employees", { headers: H(t) }); check("employees list", emps.json.employees.length === 6 && emps.json.employees[0].stats?.revenue_total >= 0);
const svcs = await req("/api/services", { headers: H(t) }); check("services list", svcs.json.services.length === 12 && svcs.json.services[0].employee_ids);
const cls = await req("/api/clients", { headers: H(t) }); check("clients list", cls.json.clients.length > 0 && cls.json.clients[0].stats?.visits >= 0);
const appts = await req("/api/appointments?limit=5", { headers: H(t) }); check("appointments joined", appts.json.appointments[0]?.service && appts.json.appointments[0]?.client);
const ov = await req("/api/dashboard/overview", { headers: H(t) }); check("overview KPIs", ov.json.revenue_month > 0 && ov.json.appts_upcoming > 0);
const be = await req("/api/dashboard/best-employees", { headers: H(t) }); check("best-employees ranked", be.json.employees.length === 6);
const bs = await req("/api/dashboard/best-services", { headers: H(t) }); check("best-services ranked", bs.json.services.length > 0);
const tt = await req("/api/dashboard/top-tickets", { headers: H(t) }); check("top-tickets", tt.json.tickets.length > 0);
const tc = await req("/api/dashboard/top-clients", { headers: H(t) }); check("top-clients", tc.json.clients.length > 0);
const fc = await req("/api/dashboard/frequent-clients", { headers: H(t) }); check("frequent-clients", fc.json.clients.length > 0);
const rt = await req("/api/dashboard/revenue-trend?days=30", { headers: H(t) }); check("revenue-trend 30 points", rt.json.points.length === 30);
const rb = await req("/api/dashboard/revenue-by-category", { headers: H(t) }); check("revenue-by-category", rb.json.slices.length > 0);
const tk = await req("/api/dashboard/tickets?limit=20", { headers: H(t) }); check("tickets list", tk.json.tickets.length > 0);
console.log("\n=== NEW: COMMISSIONS ===");
const com = await req("/api/dashboard/commissions?range=60", { headers: H(t) });
check("commissions has rows", com.json.rows?.length === 6);
check("commissions total > 0 (after backfill)", com.json.total > 0);
const topEmp = com.json.rows[0];
check("top employee has commission > 0", topEmp.commission > 0 && topEmp.sales > 0);
// Lifecycle creates commission on complete
const createR = await req("/api/appointments", {
method: "POST", headers: H(t),
body: JSON.stringify({ service_id: svcs.json.services[0].id, client_id: cls.json.clients[0].id, start_at: "2026-09-01T10:00:00Z" }),
});
check("create appointment", !!createR.json.appointment?.id);
const completed = await req(`/api/appointments/${createR.json.appointment.id}/complete`, {
method: "POST", headers: H(t), body: JSON.stringify({ tip: 50, payment_method: "card" }),
});
check("complete -> status completed", completed.json.appointment.status === "completed");
// ticket should have commission > 0
const tkAfter = await req(`/api/dashboard/tickets?limit=5`, { headers: H(t) });
const ourTicket = tkAfter.json.tickets.find((t2) => t2.service?.id === svcs.json.services[0].id && t2.amount > 0);
check("new ticket has commission > 0", ourTicket && ourTicket.commission > 0, `ticket=${JSON.stringify(ourTicket)}`);
console.log("\n=== NEW: CANCELLATION POLICY ===");
const settings = await req("/api/settings", { headers: H(t) });
check("settings loaded", settings.json.settings?.cancel_window_hours !== undefined);
check("booking slug set", settings.json.settings?.slug === "lumiere-estetica-spa");
// Update policy (24h, 50% penalty)
const upd = await req("/api/settings", {
method: "PATCH", headers: H(t),
body: JSON.stringify({ cancel_window_hours: 24, cancel_penalty_pct: 50 }),
});
check("update cancellation policy", upd.json.settings.cancel_penalty_pct === 50);
// Policy preview: appointment 1h from now should be within 24h window -> penalty
const nearAppt = await req("/api/appointments", {
method: "POST", headers: H(t),
body: JSON.stringify({ service_id: svcs.json.services[0].id, client_id: cls.json.clients[0].id, start_at: new Date(Date.now() + 60 * 60000).toISOString() }),
});
check("created near-future appt for policy test", !!nearAppt.json.appointment?.id);
const policy = await req(`/api/appointments/${nearAppt.json.appointment.id}/cancellation-policy`, { headers: H(t) });
check("policy preview within window", policy.json.within_window === true && policy.json.penalty_amount > 0);
// Far future appointment should be outside window
const farAppt = await req("/api/appointments", {
method: "POST", headers: H(t),
body: JSON.stringify({ service_id: svcs.json.services[0].id, client_id: cls.json.clients[0].id, start_at: new Date(Date.now() + 48 * 3600000).toISOString() }),
});
const policyFar = await req(`/api/appointments/${farAppt.json.appointment.id}/cancellation-policy`, { headers: H(t) });
check("policy preview outside window (no penalty)", policyFar.json.within_window === false && policyFar.json.penalty_amount === 0);
// Restore default 0% penalty for downstream tests
await req("/api/settings", { method: "PATCH", headers: H(t), body: JSON.stringify({ cancel_penalty_pct: 0 }) });
console.log("\n=== NEW: PUBLIC BOOKING ===");
const pubInfo = await req(`${BASE}/api/public/lumiere-estetica-spa`);
check("public business info", pubInfo.json.business?.name === "Lumière Estética & Spa" && pubInfo.json.services.length === 12 && pubInfo.json.employees.length === 6);
const pubSlots = await req(`${BASE}/api/public/lumiere-estetica-spa/slots?service_id=1&date=2026-08-12`);
check("public slots > 0", pubSlots.json.slots?.length > 0);
const slotIso = pubSlots.json.slots[0].iso;
// Unique phone per run so the test is idempotent across runs
const uniq = Date.now().toString().slice(-8);
// Public book
const book = await req(`${BASE}/api/public/lumiere-estetica-spa/book`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
service_id: 1, employee_id: pubSlots.json.slots[0].employee_id, start_at: slotIso,
client: { name: "Cliente Web Test " + uniq, phone: "+52 55 " + uniq + " 1111", email: `webtest${uniq}@demo.com`, notes: "Online booking test" },
}),
});
check("public booking 201 + client_created", book.status === 201 && book.json.client_created === true && !!book.json.appointment?.id, `status=${book.status} body=${JSON.stringify(book.json).slice(0,160)}`);
// Verify the booking shows up in owner appointments (via the new client's history)
const webClient = (await req("/api/clients?q=" + uniq, { headers: H(t) })).json.clients.find((c) => c.phone && c.phone.includes(uniq));
check("web client created & searchable", !!webClient);
// Booking-disabled gate
await req("/api/settings", { method: "PATCH", headers: H(t), body: JSON.stringify({ booking_enabled: 0 }) });
const pubDis = await req(`${BASE}/api/public/lumiere-estetica-spa`);
check("booking disabled -> 403", pubDis.status === 403);
await req("/api/settings", { method: "PATCH", headers: H(t), body: JSON.stringify({ booking_enabled: 1 }) });
// Invalid slug
const pubBad = await req(`${BASE}/api/public/no-such-slug`);
check("invalid slug -> 404", pubBad.status === 404);
console.log("\n=== NEW: CASH REGISTER ===");
// Defensive: close any leftover open session from previous runs
const sess0 = await req("/api/cash/session", { headers: H(t) });
if (sess0.json.session) {
await req("/api/cash/close", { method: "POST", headers: H(t), body: JSON.stringify({ closing_amount: 0 }) });
console.log(" (cleaned leftover open session)");
}
const sessCheck = await req("/api/cash/session", { headers: H(t) });
check("no open session initially", sessCheck.json.session === null);
const open = await req("/api/cash/open", { method: "POST", headers: H(t), body: JSON.stringify({ opening_amount: 500, note: "Apertura test" }) });
check("open session 201", open.status === 201 && open.json.session?.status === "open", `status=${open.status} body=${JSON.stringify(open.json).slice(0,160)}`);
const open2 = await req("/api/cash/open", { method: "POST", headers: H(t), body: JSON.stringify({ opening_amount: 100 }) });
check("cannot open second session (400)", open2.status === 400);
const entry1 = await req("/api/cash/entries", { method: "POST", headers: H(t), body: JSON.stringify({ type: "income", amount: 300, concept: "Venta producto", payment_method: "cash" }) });
check("add income entry", entry1.status === 201 && entry1.json.entry?.type === "income", `status=${entry1.status} body=${JSON.stringify(entry1.json).slice(0,160)}`);
const entry2 = await req("/api/cash/entries", { method: "POST", headers: H(t), body: JSON.stringify({ type: "expense", amount: 50, concept: "Insumos", payment_method: "cash" }) });
check("add expense entry", entry2.status === 201, `status=${entry2.status}`);
const sess = await req("/api/cash/session", { headers: H(t) });
check("session stats: income=300 expense=50 expected=750", sess.json.stats?.income === 300 && sess.json.stats?.expense === 50, `stats=${JSON.stringify(sess.json.stats)} session=${sess.json.session?.id}`);
const close = await req("/api/cash/close", { method: "POST", headers: H(t), body: JSON.stringify({ closing_amount: 750 }) });
check("close session (perfect cuadre)", close.status === 200 && close.json.expected === 750 && close.json.session.status === "closed", `status=${close.status}`);
const sessAfter = await req("/api/cash/session", { headers: H(t) });
check("no open session after close", sessAfter.json.session === null);
console.log("\n=== NEW: NOTIFICATIONS ===");
const nstats = await req("/api/notifications/stats", { headers: H(t) });
check("notifications stats", nstats.json.pending >= 0 && nstats.json.upcoming_reminders >= 0);
const regen = await req("/api/notifications/regenerate", { method: "POST", headers: H(t) });
check("regenerate notifications", regen.json.ok === true);
const list = await req("/api/notifications?status=pending", { headers: H(t) });
check("notifications list pending", list.json.notifications.length > 0);
const firstN = list.json.notifications[0];
const send = await req(`/api/notifications/${firstN.id}/send`, { method: "POST", headers: H(t) });
check("send notification (simulated)", send.json.ok === true);
const listSent = await req("/api/notifications?status=sent", { headers: H(t) });
check("notification now sent", listSent.json.notifications.some((n) => n.id === firstN.id));
console.log("\n=== MULTI-TENANT ISOLATION ===");
const newBiz = await req("/api/admin/businesses", {
method: "POST", headers: H(aT),
body: JSON.stringify({ name: "Audit Barbería", template: "barberia", ownerName: "Audit Owner", ownerEmail: "[email protected]" }),
});
check("admin creates new biz + template seed", newBiz.status === 201 && newBiz.json.seeded?.services === 8);
const newOwner = await login("[email protected]");
check("new owner login", newOwner.user.role === "owner" && newOwner.user.business_id === newBiz.json.business.id);
const isoEmps = await req("/api/employees", { headers: H(newOwner.token) });
check("isolation: new owner sees only 4 employees", isoEmps.json.employees.length === 4);
const isoBiz = await req("/api/business", { headers: H(newOwner.token) });
check("isolation: new owner sees only their business", isoBiz.json.business.name === "Audit Barbería");
// Owner1 (Lumière) still 6 employees
const o1Emps = await req("/api/employees", { headers: H(t) });
check("isolation: Lumière owner still 6 employees", o1Emps.json.employees.length === 6);
// Owner blocked from admin
const forb = await req("/api/admin/businesses", { headers: H(t) });
check("owner blocked from admin (403)", forb.status === 403);
// Employee auto-assign still works
const empAppt = await req("/api/appointments", {
method: "POST", headers: H(eT),
body: JSON.stringify({ service_id: 1, client_id: cls.json.clients[0].id, start_at: "2026-10-01T10:00:00Z" }),
});
check("employee auto-assign (matches emp_id)", empAppt.json.appointment?.employee_id === empLogin.user.employee_id);
// Cleanup test business
const del = await req(`/api/admin/businesses/${newBiz.json.business.id}`, { method: "DELETE", headers: H(aT) });
check("admin delete business (cascade)", del.status === 200);
const gone = await req("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "[email protected]", password: "demo1234" }) });
check("deleted business users removed", gone.status === 401);
console.log("\n=== AUTH GUARDS ===");
const badLogin = await req("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "[email protected]", password: "wrong" }) });
check("bad login 401", badLogin.status === 401);
const noAuth = await req("/api/employees"); check("no auth 401", noAuth.status === 401);
const empForb = await req("/api/dashboard/overview", { headers: H(eT) });
check("employee blocked from dashboard 403", empForb.status === 403);
const empDash = await req("/api/dashboard/commissions", { headers: H(eT) });
check("employee blocked from commissions 403", empDash.status === 403);
const empCash = await req("/api/cash/session", { headers: H(eT) });
check("employee blocked from cash 403", empCash.status === 403);
console.log(`\n${pass}/${pass + fail} passed`);
if (fail) {
console.log("\nFAILURES:");
failures.forEach((f) => console.log(" •", f));
process.exit(1);
}
process.exit(0);
+60
View File
@@ -0,0 +1,60 @@
const BASE = "http://localhost:5173/api";
let pass = 0, fail = 0;
async function req(method, path, body, token) {
const headers = { "Content-Type": "application/json" };
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetch(`${BASE}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = text; }
return { status: res.status, json };
}
function check(name, cond, extra = "") {
if (cond) { pass++; console.log(`${name}`); }
else { fail++; console.log(`${name} ${extra}`); }
}
// /me now works (the bug we fixed) — login first to get a real token
const { json: login } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
check("login owner", !!login.token && login.user?.role === "owner", JSON.stringify(login).slice(0, 100));
const t = login.token;
const { json: me } = await req("GET", "/auth/me", null, t);
check("/api/auth/me returns user", me.user?.email === "[email protected]", JSON.stringify(me).slice(0, 100));
const checks = [
["business", "/business", (j) => j.business?.name === "Lumière Estética & Spa"],
["employees+stats", "/employees", (j) => j.employees?.length === 6 && !!j.employees[0].stats],
["services", "/services", (j) => j.services?.length === 12 && Array.isArray(j.services[0].employee_ids)],
["clients+stats", "/clients", (j) => j.clients?.length > 0 && j.clients[0].stats?.visits !== undefined],
["appointments joined", "/appointments?limit=5", (j) => j.appointments?.[0]?.service && j.appointments[0].client],
["dashboard overview", "/dashboard/overview", (j) => j.revenue_month > 0],
["best-employees", "/dashboard/best-employees", (j) => j.employees?.length === 6],
["best-services", "/dashboard/best-services", (j) => j.services?.length > 0],
["top-tickets", "/dashboard/top-tickets", (j) => j.tickets?.length > 0],
["top-clients", "/dashboard/top-clients", (j) => j.clients?.length > 0],
["frequent-clients", "/dashboard/frequent-clients", (j) => j.clients?.length > 0],
["revenue-trend", "/dashboard/revenue-trend?days=30", (j) => j.points?.length === 30],
["revenue-by-category", "/dashboard/revenue-by-category", (j) => j.slices?.length > 0],
["tickets list", "/dashboard/tickets", (j) => j.tickets?.length > 0],
];
for (const [name, path, cond] of checks) {
const { json, status } = await req("GET", path, null, t);
check(name, status === 200 && cond(json), `status=${status}`);
}
// Lifecycle
const { json: created } = await req("POST", "/appointments", { service_id: 1, client_id: 1, start_at: "2026-09-15T11:00:00Z" }, t);
check("create appointment", !!created.appointment?.id);
const { json: completed } = await req("POST", `/appointments/${created.appointment.id}/complete`, { tip: 50, payment_method: "cash" }, t);
check("complete -> ticket", completed.appointment?.status === "completed");
const { json: empLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
const { json: empAppt } = await req("POST", "/appointments", { service_id: 5, client_id: 2, start_at: "2026-09-20T10:00:00Z" }, empLogin.token);
check("employee auto-assign", empAppt.appointment?.employee_id === empLogin.user?.employee_id);
const { status: badLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "wrong" });
check("bad login 401", badLogin === 401);
const { status: noAuth } = await req("GET", "/employees");
check("no auth 401", noAuth === 401);
const { status: empForb } = await req("GET", "/dashboard/overview", null, empLogin.token);
check("employee blocked dashboard 403", empForb === 403);
console.log(`\n${pass}/${pass + fail} passed${fail ? `, ${fail} FAILED` : ""}`);
process.exit(fail ? 1 : 0);
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
<meta name="theme-color" content="#3b66ff" />
<meta name="description" content="AgendaPro — Gestión visual de citas, empleados e ingresos para tu negocio." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<title>AgendaPro — Gestión visual de tu negocio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6109
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
{
"name": "agenda-pro",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Visual scheduling and business management app for owners and employees",
"scripts": {
"dev": "concurrently -k -n SERVER,WEB -c blue,magenta \"npm:dev:server\" \"npm:dev:web\"",
"dev:server": "tsx watch server/index.ts",
"dev:web": "vite",
"build": "tsc -b && vite build",
"start": "cross-env NODE_ENV=production tsx server/index.ts",
"typecheck": "tsc -b --noEmit",
"lint": "eslint . --ext ts,tsx",
"seed": "tsx server/scripts/seed.ts",
"test:e2e": "node e2e-test.mjs",
"test:admin": "node admin-test.mjs",
"audit:visual": "node visual-audit.mjs"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/list": "^6.1.21",
"@fullcalendar/react": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@tanstack/react-query": "^5.59.0",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"express": "^4.21.0",
"lucide-react": "^0.451.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@playwright/test": "^1.62.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^22.7.4",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"concurrently": "^9.0.1",
"cross-env": "^7.0.3",
"eslint": "^9.12.0",
"playwright": "^1.62.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"tsx": "^4.19.1",
"typescript": "^5.6.2",
"vite": "^5.4.8"
},
"allowScripts": {
"[email protected]": true,
"[email protected]": true
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#3b66ff"/>
<path d="M9 11h14M9 16h14M9 21h9" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/>
<circle cx="23" cy="21" r="3" fill="#f17616"/>
</svg>

After

Width:  |  Height:  |  Size: 266 B

+320
View File
@@ -0,0 +1,320 @@
import { DatabaseSync } from "node:sqlite";
import { fileURLToPath } from "node:url";
import path from "node:path";
import fs from "node:fs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.resolve(__dirname, "..", "data");
fs.mkdirSync(dataDir, { recursive: true });
const dbPath = path.join(dataDir, "agendapro.db");
if (process.env.RESET_DB === "1" && fs.existsSync(dbPath)) {
fs.unlinkSync(dbPath);
}
export const db = new DatabaseSync(dbPath);
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;");
// Fresh-install schema (multi-tenant). For existing v1 DBs, runMigrations() transforms them.
export const SCHEMA = `
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS businesses (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
industry TEXT NOT NULL,
currency TEXT NOT NULL DEFAULT 'MXN',
currency_symbol TEXT NOT NULL DEFAULT '$',
phone TEXT,
address TEXT,
slug TEXT,
plan TEXT NOT NULL DEFAULT 'trial',
status TEXT NOT NULL DEFAULT 'active',
template TEXT,
trial_ends_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS employees (
id INTEGER PRIMARY KEY,
business_id INTEGER NOT NULL REFERENCES businesses(id),
name TEXT NOT NULL,
email TEXT,
phone TEXT,
color TEXT NOT NULL DEFAULT '#3b66ff',
role TEXT NOT NULL DEFAULT 'specialist',
active INTEGER NOT NULL DEFAULT 1,
rating REAL NOT NULL DEFAULT 5.0,
hire_date TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS services (
id INTEGER PRIMARY KEY,
business_id INTEGER NOT NULL REFERENCES businesses(id),
name TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL DEFAULT 'General',
duration_min INTEGER NOT NULL DEFAULT 60,
price REAL NOT NULL DEFAULT 0,
color TEXT NOT NULL DEFAULT '#3b66ff',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS employee_services (
employee_id INTEGER NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE,
PRIMARY KEY (employee_id, service_id)
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
business_id INTEGER REFERENCES businesses(id),
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin','owner','employee')),
employee_id INTEGER REFERENCES employees(id),
avatar_color TEXT NOT NULL DEFAULT '#3b66ff',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY,
business_id INTEGER NOT NULL REFERENCES businesses(id),
name TEXT NOT NULL,
email TEXT,
phone TEXT,
notes TEXT,
tags TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS appointments (
id INTEGER PRIMARY KEY,
business_id INTEGER NOT NULL REFERENCES businesses(id),
service_id INTEGER NOT NULL REFERENCES services(id),
employee_id INTEGER NOT NULL REFERENCES employees(id),
client_id INTEGER NOT NULL REFERENCES clients(id),
start_at TEXT NOT NULL,
end_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled','completed','cancelled','no_show')),
price REAL NOT NULL DEFAULT 0,
notes TEXT,
created_by_user_id INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_appointments_start ON appointments(start_at);
CREATE INDEX IF NOT EXISTS idx_appointments_employee ON appointments(employee_id);
CREATE INDEX IF NOT EXISTS idx_appointments_client ON appointments(client_id);
CREATE INDEX IF NOT EXISTS idx_appointments_status ON appointments(status);
CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY,
appointment_id INTEGER NOT NULL REFERENCES appointments(id) ON DELETE CASCADE,
client_id INTEGER NOT NULL REFERENCES clients(id),
employee_id INTEGER NOT NULL REFERENCES employees(id),
rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
comment TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY,
business_id INTEGER NOT NULL REFERENCES businesses(id),
appointment_id INTEGER REFERENCES appointments(id),
client_id INTEGER NOT NULL REFERENCES clients(id),
employee_id INTEGER NOT NULL REFERENCES employees(id),
service_id INTEGER NOT NULL REFERENCES services(id),
amount REAL NOT NULL,
tip REAL NOT NULL DEFAULT 0,
payment_method TEXT NOT NULL DEFAULT 'card',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tickets_created ON tickets(created_at);
CREATE INDEX IF NOT EXISTS idx_tickets_employee ON tickets(employee_id);
CREATE INDEX IF NOT EXISTS idx_tickets_client ON tickets(client_id);
`;
export function getMeta(key: string, fallback = ""): string {
const row = db.prepare(`SELECT value FROM meta WHERE key = ?`).get(key) as { value: string } | undefined;
return row?.value ?? fallback;
}
export function setMeta(key: string, value: string) {
db.prepare(`INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(
key,
value
);
}
function tableExists(name: string): boolean {
return !!db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(name);
}
function columnExists(table: string, column: string): boolean {
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
return rows.some((r) => r.name === column);
}
/** Migrate an existing v1 DB (single-tenant) to the multi-tenant v2 schema, preserving all data. */
function migrateV1ToV2() {
if (getMeta("schema_version") >= "2") return;
if (!tableExists("users")) {
setMeta("schema_version", "2");
return;
}
// Detect if users table is already v2 (CHECK includes 'admin')
const usersSql = (db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='users'`).get() as { sql: string } | undefined)?.sql ?? "";
const isAlreadyV2 = usersSql.includes("'admin'");
if (!isAlreadyV2) {
const fkWasOn = db.prepare("PRAGMA foreign_keys").get() as { foreign_keys: number };
db.exec("PRAGMA foreign_keys = OFF;");
try {
db.exec(`
CREATE TABLE IF NOT EXISTS users_v2 (
id INTEGER PRIMARY KEY,
business_id INTEGER REFERENCES businesses(id),
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin','owner','employee')),
employee_id INTEGER REFERENCES employees(id),
avatar_color TEXT NOT NULL DEFAULT '#3b66ff',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
const existing = db
.prepare(`SELECT id, business_id, email, password, name, role, employee_id, avatar_color, created_at FROM users`)
.all() as any[];
const ins = db.prepare(
`INSERT OR IGNORE INTO users_v2 (id, business_id, email, password, name, role, employee_id, avatar_color, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const u of existing) {
ins.run(u.id, u.business_id ?? null, u.email, u.password, u.name, u.role, u.employee_id ?? null, u.avatar_color ?? "#3b66ff", u.created_at);
}
db.exec(`DROP TABLE users; ALTER TABLE users_v2 RENAME TO users;`);
} finally {
db.exec(`PRAGMA foreign_keys = ${fkWasOn.foreign_keys ? "ON" : "OFF"};`);
}
}
// Add new business columns if missing
if (!columnExists("businesses", "slug")) db.exec(`ALTER TABLE businesses ADD COLUMN slug TEXT;`);
if (!columnExists("businesses", "plan")) db.exec(`ALTER TABLE businesses ADD COLUMN plan TEXT NOT NULL DEFAULT 'trial';`);
if (!columnExists("businesses", "status")) db.exec(`ALTER TABLE businesses ADD COLUMN status TEXT NOT NULL DEFAULT 'active';`);
if (!columnExists("businesses", "template")) db.exec(`ALTER TABLE businesses ADD COLUMN template TEXT;`);
if (!columnExists("businesses", "trial_ends_at")) db.exec(`ALTER TABLE businesses ADD COLUMN trial_ends_at TEXT;`);
// Meta table (created by SCHEMA on fresh; ensure here for migrated DBs)
db.exec(`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`);
setMeta("schema_version", "2");
}
export function runMigrations() {
// Ensure base schema exists (no-op on existing tables)
db.exec(SCHEMA);
migrateV1ToV2();
migrateV2ToV3();
}
/** v3: cancellation policy, commissions, cash register, notifications/reminders, booking slug. */
function migrateV2ToV3() {
if (getMeta("schema_version") >= "3") return;
// Business-level settings (cancellation policy + deposit + booking enabled)
const bizCols: [string, string][] = [
["booking_enabled", "INTEGER NOT NULL DEFAULT 1"],
["cancel_window_hours", "INTEGER NOT NULL DEFAULT 24"],
["cancel_penalty_pct", "REAL NOT NULL DEFAULT 0"],
["require_deposit", "INTEGER NOT NULL DEFAULT 0"],
["deposit_pct", "REAL NOT NULL DEFAULT 0"],
["timezone", "TEXT NOT NULL DEFAULT 'America/Mexico_City'"],
];
for (const [col, def] of bizCols) {
if (!columnExists("businesses", col)) db.exec(`ALTER TABLE businesses ADD COLUMN ${col} ${def};`);
}
// Commission rates
if (!columnExists("services", "commission_pct")) db.exec(`ALTER TABLE services ADD COLUMN commission_pct REAL NOT NULL DEFAULT 0;`);
if (!columnExists("employees", "commission_pct")) db.exec(`ALTER TABLE employees ADD COLUMN commission_pct REAL NOT NULL DEFAULT 0;`);
// Commission snapshot on tickets (computed at completion)
if (!columnExists("tickets", "commission")) db.exec(`ALTER TABLE tickets ADD COLUMN commission REAL NOT NULL DEFAULT 0;`);
// Cash register
db.exec(`
CREATE TABLE IF NOT EXISTS cash_sessions (
id INTEGER PRIMARY KEY,
business_id INTEGER NOT NULL REFERENCES businesses(id),
opened_by_user_id INTEGER REFERENCES users(id),
opened_at TEXT NOT NULL DEFAULT (datetime('now')),
closed_at TEXT,
opening_amount REAL NOT NULL DEFAULT 0,
closing_amount REAL,
expected_amount REAL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','closed')),
note TEXT
);
CREATE INDEX IF NOT EXISTS idx_cash_sessions_business ON cash_sessions(business_id);
CREATE INDEX IF NOT EXISTS idx_cash_sessions_status ON cash_sessions(status);
CREATE TABLE IF NOT EXISTS cash_entries (
id INTEGER PRIMARY KEY,
business_id INTEGER NOT NULL REFERENCES businesses(id),
session_id INTEGER REFERENCES cash_sessions(id) ON DELETE CASCADE,
ticket_id INTEGER REFERENCES tickets(id),
type TEXT NOT NULL CHECK (type IN ('income','expense')),
amount REAL NOT NULL,
concept TEXT NOT NULL,
payment_method TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_cash_entries_session ON cash_entries(session_id);
CREATE INDEX IF NOT EXISTS idx_cash_entries_business ON cash_entries(business_id);
`);
// Reminders / notifications center
db.exec(`
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY,
business_id INTEGER NOT NULL REFERENCES businesses(id),
appointment_id INTEGER REFERENCES appointments(id) ON DELETE CASCADE,
client_id INTEGER REFERENCES clients(id),
channel TEXT NOT NULL CHECK (channel IN ('whatsapp','sms','email')),
kind TEXT NOT NULL CHECK (kind IN ('confirmation','reminder','cancellation','follow_up','review')),
send_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed','canceled')),
message TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_notif_send ON notifications(send_at);
CREATE INDEX IF NOT EXISTS idx_notif_business ON notifications(business_id);
CREATE INDEX IF NOT EXISTS idx_notif_status ON notifications(status);
`);
// Ensure every business has a slug (for public booking /b/:slug)
const bizs = db.prepare(`SELECT id, name, slug FROM businesses WHERE slug IS NULL OR slug = ''`).all() as any[];
const slugify = (s: string) =>
s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || `negocio`;
for (const b of bizs) {
let slug = slugify(b.name);
let n = 1;
while (db.prepare(`SELECT id FROM businesses WHERE slug = ? AND id != ?`).get(slug, b.id)) {
slug = `${slugify(b.name)}-${n++}`;
}
db.prepare(`UPDATE businesses SET slug = ? WHERE id = ?`).run(slug, b.id);
}
setMeta("schema_version", "3");
}
runMigrations();
+94
View File
@@ -0,0 +1,94 @@
import express from "express";
import cors from "cors";
import path from "node:path";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { db } from "./db.ts";
import { ensurePlatformAdmin, seedBusiness, getTemplate, DEFAULT_TEMPLATE_KEY } from "./scripts/seed.ts";
import { authRouter } from "./routes/auth.ts";
import { businessRouter } from "./routes/business.ts";
import { employeesRouter } from "./routes/employees.ts";
import { servicesRouter } from "./routes/services.ts";
import { clientsRouter } from "./routes/clients.ts";
import { appointmentsRouter } from "./routes/appointments.ts";
import { dashboardRouter } from "./routes/dashboard.ts";
import { adminRouter } from "./routes/admin.ts";
import { settingsRouter } from "./routes/settings.ts";
import { cashRouter } from "./routes/cash.ts";
import { notificationsRouter, scheduleReminders } from "./routes/notifications.ts";
import { bookingRouter } from "./routes/booking.ts";
import { authRequired } from "./lib/auth.ts";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
// Auto-seed demo data if the database is empty (first run). Preserves existing data.
function ensureSeed() {
ensurePlatformAdmin();
const row = db.prepare(`SELECT COUNT(*) c FROM businesses`).get() as { c: number };
if (row.c === 0) {
console.log("[seed] Base de datos vacía — creando negocio demo y admin…");
const biz = db
.prepare(
`INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status)
VALUES (?, ?, ?, ?, ?, ?, 'trial', 'active') RETURNING id`
)
.get("Lumière Estética & Spa", "Estética y Spa", "MXN", "$", "+52 55 1234 5678", "Av. Reforma 245, CDMX") as { id: number };
seedBusiness({
businessId: biz.id,
template: getTemplate(DEFAULT_TEMPLATE_KEY)!,
ownerEmail: "[email protected]",
ownerName: "Daniela Reyes",
});
}
}
ensureSeed();
// On startup, refresh pending reminders for all active businesses (idempotent).
try {
const bids = db.prepare(`SELECT id FROM businesses WHERE status = 'active'`).all() as { id: number }[];
for (const b of bids) scheduleReminders(b.id);
} catch (e) {
console.warn("[reminders] no se pudieron generar al inicio:", (e as Error).message);
}
const app = express();
app.use(cors());
app.use(express.json({ limit: "1mb" }));
app.use("/api/auth", authRouter);
// Public booking (no auth)
app.use("/api/public", bookingRouter);
// Protected APIs
app.use("/api/business", authRequired, businessRouter);
app.use("/api/employees", authRequired, employeesRouter);
app.use("/api/services", authRequired, servicesRouter);
app.use("/api/clients", authRequired, clientsRouter);
app.use("/api/appointments", authRequired, appointmentsRouter);
app.use("/api/dashboard", authRequired, dashboardRouter);
app.use("/api/admin", authRequired, adminRouter);
app.use("/api/settings", authRequired, settingsRouter);
app.use("/api/cash", authRequired, cashRouter);
app.use("/api/notifications", authRequired, notificationsRouter);
app.get("/api/health", (_req, res) => res.json({ ok: true, ts: Date.now() }));
// Serve built frontend in production
const distDir = path.join(ROOT, "dist");
if (fs.existsSync(distDir)) {
app.use(express.static(distDir));
app.get("*", (req, res, next) => {
if (req.path.startsWith("/api/")) return next();
res.sendFile(path.join(distDir, "index.html"));
});
}
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error("[server error]", err);
res.status(err.status || 500).json({ error: err.message || "Error interno" });
});
const port = Number(process.env.PORT) || 3000;
const host = process.env.HOST || "0.0.0.0";
app.listen(port, host, () => {
console.log(`AgendaPro API en http://${host}:${port}`);
});
+50
View File
@@ -0,0 +1,50 @@
import type { Request, Response, NextFunction } from "express";
import { db } from "../db.ts";
import type { User } from "../../shared/types.ts";
export interface AuthedRequest extends Request {
user?: User;
}
export function authRequired(req: AuthedRequest, res: Response, next: NextFunction) {
const header = req.header("authorization") || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : req.header("x-user-id");
if (!token) {
res.status(401).json({ error: "No autorizado" });
return;
}
const userId = Number(token);
if (!Number.isFinite(userId)) {
res.status(401).json({ error: "Token inválido" });
return;
}
const user = db
.prepare(`SELECT id, business_id, email, name, role, employee_id, avatar_color FROM users WHERE id = ?`)
.get(userId) as User | undefined;
if (!user) {
res.status(401).json({ error: "Usuario no encontrado" });
return;
}
req.user = user;
next();
}
export function ownerOnly(req: AuthedRequest, res: Response, next: NextFunction) {
if (req.user?.role !== "owner") {
res.status(403).json({ error: "Solo el dueño puede realizar esta acción" });
return;
}
next();
}
export function adminOnly(req: AuthedRequest, res: Response, next: NextFunction) {
if (req.user?.role !== "admin") {
res.status(403).json({ error: "Acceso restringido al administrador de la plataforma" });
return;
}
next();
}
export function err(res: Response, status: number, message: string) {
return res.status(status).json({ error: message });
}
+168
View File
@@ -0,0 +1,168 @@
// Predefined business templates for the admin "create business" flow.
// Each template describes the demo seed (services, employees, sample clients)
// that gets loaded into a freshly created tenant.
const C = {
blue: "#3b66ff",
orange: "#f17616",
green: "#10b981",
purple: "#a855f7",
pink: "#ec4899",
teal: "#14b8a6",
amber: "#f59e0b",
indigo: "#6366f1",
};
export interface TemplateService {
name: string;
category: string;
duration_min: number;
price: number;
color: string;
employees: number[]; // indexes into template.employees
}
export interface TemplateEmployee {
name: string;
role: string;
color: string;
}
export interface ClientSeed {
name: string;
tags?: string;
notes?: string;
}
export interface TemplateDef {
key: string;
name: string;
industry: string;
currency: string;
currency_symbol: string;
description: string;
emailDomain: string;
employees: TemplateEmployee[];
services: TemplateService[];
clients: ClientSeed[];
}
const SPA_CLIENTS: ClientSeed[] = [
"Alejandra Pérez","Roberto Díaz","Mariana López","Fernando Ruiz","Gabriela Sánchez",
"Ricardo Vargas","Patricia Moreno","Javier Estrada","Lucía Fernández","Andrés Ortega",
"Daniela Castro","Eduardo Mendoza","Valeria Guzmán","Carlos Iglesias","Fernanda Romero",
].map((n, i) => ({
name: n,
tags: i % 4 === 0 ? "VIP" : i % 3 === 0 ? "Frecuente" : undefined,
notes: i % 2 === 0 ? "Prefiere horario matutino" : undefined,
}));
export const TEMPLATES: TemplateDef[] = [
{
key: "estetica-spa",
name: "Lumière Estética & Spa",
industry: "Estética y Spa",
currency: "MXN",
currency_symbol: "$",
description: "Salón de belleza y spa: cabello, barbería, faciales, masajes y uñas.",
emailDomain: "@lumiere.mx",
employees: [
{ name: "Valentina Cruz", role: "Estilista Senior", color: C.blue },
{ name: "Mateo Herrera", role: "Barbero", color: C.orange },
{ name: "Sofía Ramírez", role: "Facialista", color: C.green },
{ name: "Diego Castillo", role: "Masajista", color: C.purple },
{ name: "Isabela Torres", role: "Manicurista", color: C.pink },
{ name: "Carolina Méndez", role: "Estilista Junior", color: C.teal },
],
services: [
{ name: "Corte y peinado", category: "Cabello", duration_min: 60, price: 350, color: C.blue, employees: [0, 5] },
{ name: "Coloración global", category: "Cabello", duration_min: 120, price: 1200, color: C.blue, employees: [0] },
{ name: "Mechas balayage", category: "Cabello", duration_min: 180, price: 1800, color: C.indigo, employees: [0] },
{ name: "Tratamiento de keratina", category: "Cabello", duration_min: 150, price: 1500, color: C.teal, employees: [0, 5] },
{ name: "Corte caballero", category: "Barbería", duration_min: 30, price: 180, color: C.orange, employees: [1] },
{ name: "Corte + arreglo de barba", category: "Barbería", duration_min: 45, price: 280, color: C.orange, employees: [1] },
{ name: "Limpieza facial profunda", category: "Facial", duration_min: 75, price: 700, color: C.green, employees: [2] },
{ name: "Facial anti-edad", category: "Facial", duration_min: 90, price: 950, color: C.green, employees: [2] },
{ name: "Masaje relajante 60 min", category: "Spa", duration_min: 60, price: 800, color: C.purple, employees: [3] },
{ name: "Masaje descontracturante 90 min", category: "Spa", duration_min: 90, price: 1100, color: C.purple, employees: [3] },
{ name: "Manicura semipermanente", category: "Uñas", duration_min: 60, price: 450, color: C.pink, employees: [4] },
{ name: "Pedicura spa", category: "Uñas", duration_min: 60, price: 550, color: C.pink, employees: [4] },
],
clients: SPA_CLIENTS,
},
{
key: "barberia",
name: "Urban Cut Barbería",
industry: "Barbería",
currency: "MXN",
currency_symbol: "$",
description: "Barbería moderna: cortes, arreglo de barba, tratamientos capilares para caballero.",
emailDomain: "@urbancut.mx",
employees: [
{ name: "Andrés Morales", role: "Barbero Senior", color: C.orange },
{ name: "Tomás Rivas", role: "Barbero", color: C.blue },
{ name: "Bruno Salazar", role: "Barbero Junior", color: C.green },
{ name: "Hugo Beltrán", role: "Especialista en Barba", color: C.amber },
],
services: [
{ name: "Corte clásico", category: "Cortes", duration_min: 30, price: 150, color: C.orange, employees: [0, 1, 2] },
{ name: "Corte + diseño", category: "Cortes", duration_min: 45, price: 220, color: C.orange, employees: [0, 1] },
{ name: "Arreglo de barba", category: "Barba", duration_min: 30, price: 120, color: C.amber, employees: [3] },
{ name: "Corte + barba", category: "Combo", duration_min: 60, price: 280, color: C.indigo, employees: [0, 1, 3] },
{ name: "Rasurado tradicional con toalla", category: "Barba", duration_min: 45, price: 200, color: C.amber, employees: [3] },
{ name: "Tinte de cabello", category: "Color", duration_min: 90, price: 600, color: C.purple, employees: [0] },
{ name: "Tratamiento hidratante capilar", category: "Tratamientos", duration_min: 40, price: 350, color: C.teal, employees: [0, 1] },
{ name: "Cejas masculinas", category: "Detalles", duration_min: 20, price: 80, color: C.pink, employees: [2, 3] },
],
clients: [
"Ricardo Vargas","Javier Estrada","Andrés Ortega","Eduardo Mendoza","Carlos Iglesias",
"Miguel Torres","Sebastián Navarro","Bruno Aguilar","Emilio Domínguez","Rodrigo Herrera",
"Fernando Ruiz","Diego Soto","Hugo Mendoza","Tomás Cisneros","Manuel Vega",
].map((n, i) => ({ name: n, tags: i % 5 === 0 ? "VIP" : i % 3 === 0 ? "Frecuente" : undefined })),
},
{
key: "clinica",
name: "Clínica Dental Sonrisa",
industry: "Salud / Dental",
currency: "MXN",
currency_symbol: "$",
description: "Clínica dental: limpieza, ortodoncia, estética dental y consultas.",
emailDomain: "@sonrisa.mx",
employees: [
{ name: "Dra. Elena Vargas", role: "Dentista General", color: C.blue },
{ name: "Dr. Pablo Núñez", role: "Ortodoncista", color: C.green },
{ name: "Dra. Reina Flores", role: "Estética Dental", color: C.pink },
{ name: "Lic. Martha Ibáñez", role: "Higienista", color: C.teal },
],
services: [
{ name: "Consulta y diagnóstico", category: "Consulta", duration_min: 30, price: 400, color: C.blue, employees: [0] },
{ name: "Limpieza dental profunda", category: "Higiene", duration_min: 60, price: 700, color: C.teal, employees: [3] },
{ name: "Blanqueamiento dental", category: "Estética", duration_min: 90, price: 2500, color: C.pink, employees: [2] },
{ name: "Carillas dentales", category: "Estética", duration_min: 120, price: 4500, color: C.pink, employees: [2] },
{ name: "Consulta de ortodoncia", category: "Ortodoncia", duration_min: 45, price: 600, color: C.green, employees: [1] },
{ name: "Ajuste de brackets", category: "Ortodoncia", duration_min: 60, price: 800, color: C.green, employees: [1] },
{ name: "Extracción simple", category: "Cirugía", duration_min: 60, price: 1200, color: C.orange, employees: [0] },
{ name: "Resina (obturación)", category: "Restauración", duration_min: 45, price: 900, color: C.indigo, employees: [0] },
],
clients: [
"Mariana López","Patricia Moreno","Lucía Fernández","Daniela Castro","Valeria Guzmán",
"Fernanda Romero","Andrea Vega","Natalia Reyes","Camila Soto","Renata Domínguez",
"Alejandra Pérez","Gabriela Sánchez","Roberto Díaz","Carlos Iglesias","Eduardo Mendoza",
].map((n, i) => ({ name: n, tags: i % 4 === 0 ? "VIP" : undefined, notes: i % 2 === 0 ? "Alergia a anestesia local" : undefined })),
},
{
key: "blank",
name: "Nuevo Negocio",
industry: "General",
currency: "MXN",
currency_symbol: "$",
description: "Plantilla vacía: sin servicios ni datos demo. Configura todo desde cero.",
emailDomain: "@demo.mx",
employees: [],
services: [],
clients: [],
},
];
export function getTemplate(key: string): TemplateDef | undefined {
return TEMPLATES.find((t) => t.key === key);
}
export const DEFAULT_TEMPLATE_KEY = "estetica-spa";
+210
View File
@@ -0,0 +1,210 @@
import { Router } from "express";
import { db } from "../db.ts";
import { adminOnly, err, type AuthedRequest } from "../lib/auth.ts";
import { TEMPLATES, getTemplate, type TemplateDef } from "../lib/templates.ts";
import { seedBusiness, clearBusinessData } from "../scripts/seed.ts";
export const adminRouter = Router();
adminRouter.use(adminOnly);
function businessStats(businessId: number) {
const scalar = (sql: string) => (db.prepare(sql).get(businessId) as any)?.v ?? 0;
return {
users: scalar(`SELECT COUNT(*) v FROM users WHERE business_id = ?`),
employees: scalar(`SELECT COUNT(*) v FROM employees WHERE business_id = ?`),
services: scalar(`SELECT COUNT(*) v FROM services WHERE business_id = ?`),
clients: scalar(`SELECT COUNT(*) v FROM clients WHERE business_id = ?`),
appointments: scalar(`SELECT COUNT(*) v FROM appointments WHERE business_id = ?`),
appointments_upcoming: scalar(
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status='scheduled' AND start_at >= datetime('now')`
),
revenue_30d: scalar(
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`
),
tickets: scalar(`SELECT COUNT(*) v FROM tickets WHERE business_id = ?`),
};
}
function businessRow(id: number) {
return db
.prepare(
`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, plan, status, template, trial_ends_at, created_at
FROM businesses WHERE id = ?`
)
.get(id) as any;
}
// Platform overview
adminRouter.get("/overview", (_req: AuthedRequest, res) => {
const scalar = (sql: string) => (db.prepare(sql).get() as any)?.v ?? 0;
res.json({
businesses: scalar(`SELECT COUNT(*) v FROM businesses`),
active_businesses: scalar(`SELECT COUNT(*) v FROM businesses WHERE status='active'`),
trial_businesses: scalar(`SELECT COUNT(*) v FROM businesses WHERE plan='trial'`),
total_users: scalar(`SELECT COUNT(*) v FROM users WHERE role IN ('owner','employee')`),
total_appointments: scalar(`SELECT COUNT(*) v FROM appointments`),
revenue_30d: scalar(`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE created_at >= datetime('now','-30 days')`),
upcoming_appointments: scalar(
`SELECT COUNT(*) v FROM appointments WHERE status='scheduled' AND start_at >= datetime('now')`
),
templates: TEMPLATES.length,
});
});
// List templates
adminRouter.get("/templates", (_req: AuthedRequest, res) => {
const out = TEMPLATES.map((t) => ({
key: t.key,
name: t.name,
industry: t.industry,
description: t.description,
currency: t.currency,
currency_symbol: t.currency_symbol,
employees: t.employees.length,
services: t.services.length,
clients: t.clients.length,
}));
res.json({ templates: out });
});
// List all businesses with stats
adminRouter.get("/businesses", (_req: AuthedRequest, res) => {
const rows = db
.prepare(
`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, plan, status, template, trial_ends_at, created_at
FROM businesses ORDER BY created_at DESC`
)
.all() as any[];
const out = rows.map((b) => ({ business: b, stats: businessStats(b.id) }));
res.json({ businesses: out });
});
// Business detail
adminRouter.get("/businesses/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const b = businessRow(id);
if (!b) return err(res, 404, "Negocio no encontrado");
res.json({ business: b, stats: businessStats(id) });
});
// Create business + owner + template seed
adminRouter.post("/businesses", (req: AuthedRequest, res) => {
const { name, industry, template, ownerName, ownerEmail, ownerPassword, plan, currency, currency_symbol } = req.body ?? {};
if (!name) return err(res, 400, "El nombre del negocio es obligatorio");
if (!ownerEmail) return err(res, 400, "El correo del dueño es obligatorio");
const tpl: TemplateDef | undefined = template ? getTemplate(template) : getTemplate("blank");
if (!tpl) return err(res, 400, "Plantilla no válida");
const email = String(ownerEmail).toLowerCase().trim();
const existing = db.prepare(`SELECT id FROM users WHERE email = ?`).get(email);
if (existing) return err(res, 409, "Ya existe un usuario con ese correo");
const biz = db
.prepare(
`INSERT INTO businesses (name, industry, currency, currency_symbol, plan, status, template)
VALUES (?, ?, ?, ?, ?, 'active', ?) RETURNING id`
)
.get(
name,
industry || tpl.industry,
currency || tpl.currency || "MXN",
currency_symbol || tpl.currency_symbol || "$",
plan || "trial",
tpl.key
) as { id: number };
const result = seedBusiness({
businessId: biz.id,
template: tpl,
ownerEmail: email,
ownerName: ownerName || "Dueño",
ownerPassword: ownerPassword || "demo1234",
withHistory: tpl.key !== "blank",
withUpcoming: tpl.key !== "blank",
});
res.status(201).json({
business: businessRow(biz.id),
stats: businessStats(biz.id),
seeded: result,
message: `Negocio "${name}" creado con plantilla "${tpl.name}"`,
});
});
// Update business
adminRouter.patch("/businesses/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = businessRow(id);
if (!existing) return err(res, 404, "Negocio no encontrado");
const { name, industry, phone, address, slug, plan, status, trial_ends_at } = req.body ?? {};
db.prepare(
`UPDATE businesses SET name=?, industry=?, phone=?, address=?, slug=?, plan=?, status=?, trial_ends_at=? WHERE id=?`
).run(
name ?? existing.name,
industry ?? existing.industry,
phone ?? existing.phone,
address ?? existing.address,
slug ?? existing.slug,
plan ?? existing.plan,
status ?? existing.status,
trial_ends_at ?? existing.trial_ends_at,
id
);
res.json({ business: businessRow(id), stats: businessStats(id) });
});
// Delete business + all its data
adminRouter.delete("/businesses/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = businessRow(id);
if (!existing) return err(res, 404, "Negocio no encontrado");
clearBusinessData(id);
// clearBusinessData preserves user accounts; a full delete removes them too
db.prepare(`DELETE FROM users WHERE business_id = ?`).run(id);
db.prepare(`DELETE FROM businesses WHERE id = ?`).run(id);
res.json({ ok: true });
});
// Load template data into an existing business (clearFirst optional)
adminRouter.post("/businesses/:id/seed-template", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const b = businessRow(id);
if (!b) return err(res, 404, "Negocio no encontrado");
const { template, clearFirst = true, ownerEmail, ownerName, ownerPassword, withHistory = true, withUpcoming = true } = req.body ?? {};
const tpl: TemplateDef | undefined = template ? getTemplate(template) : b.template ? getTemplate(b.template) : getTemplate("blank");
if (!tpl) return err(res, 400, "Plantilla no válida");
const result = seedBusiness({
businessId: id,
template: tpl,
ownerEmail: ownerEmail ?? (db.prepare(`SELECT email FROM users WHERE business_id=? AND role='owner'`).get(id) as any)?.email,
ownerName: ownerName ?? "Dueño",
ownerPassword: ownerPassword ?? "demo1234",
clearFirst: clearFirst !== false,
withHistory,
withUpcoming,
});
res.json({ business: businessRow(id), stats: businessStats(id), seeded: result });
});
// Reset demo data (clear + reseed with current template)
adminRouter.post("/businesses/:id/reset-demo", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const b = businessRow(id);
if (!b) return err(res, 404, "Negocio no encontrado");
const tplKey = req.body?.template || b.template || "blank";
const tpl = getTemplate(tplKey);
if (!tpl) return err(res, 400, "Plantilla no válida");
const owner = db.prepare(`SELECT email, name FROM users WHERE business_id=? AND role='owner'`).get(id) as any;
const result = seedBusiness({
businessId: id,
template: tpl,
ownerEmail: owner?.email,
ownerName: owner?.name ?? "Dueño",
clearFirst: true,
withHistory: tpl.key !== "blank",
withUpcoming: tpl.key !== "blank",
});
res.json({ business: businessRow(id), stats: businessStats(id), seeded: result });
});
+303
View File
@@ -0,0 +1,303 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err } from "../lib/auth.ts";
export const appointmentsRouter = Router();
/** Compute commission for a ticket: service rate, fallback employee rate. */
function computeCommission(businessId: number, serviceId: number, employeeId: number, amount: number): number {
const svc = db.prepare(`SELECT commission_pct p FROM services WHERE id = ? AND business_id = ?`).get(serviceId, businessId) as { p: number } | undefined;
let pct = svc?.p ?? 0;
if (!pct) {
const emp = db.prepare(`SELECT commission_pct p FROM employees WHERE id = ? AND business_id = ?`).get(employeeId, businessId) as { p: number } | undefined;
pct = emp?.p ?? 0;
}
return Math.round(((amount * pct) / 100) * 100) / 100;
}
function joinAppointment(a: any) {
if (!a) return a;
a.service = db
.prepare(`SELECT id, name, category, color, duration_min, price FROM services WHERE id = ?`)
.get(a.service_id);
a.employee = db
.prepare(`SELECT id, name, color, role FROM employees WHERE id = ?`)
.get(a.employee_id);
a.client = db
.prepare(`SELECT id, name, phone, email, tags FROM clients WHERE id = ?`)
.get(a.client_id);
return a;
}
appointmentsRouter.get("/", (req: AuthedRequest, res) => {
const { from, to, employee_id, client_id, status, limit } = req.query;
const conds: string[] = ["business_id = ?"];
const args: any[] = [req.user!.business_id];
if (from) {
conds.push("start_at >= ?");
args.push(String(from));
}
if (to) {
conds.push("start_at <= ?");
args.push(String(to));
}
if (employee_id) {
conds.push("employee_id = ?");
args.push(Number(employee_id));
}
if (client_id) {
conds.push("client_id = ?");
args.push(Number(client_id));
}
if (status) {
conds.push("status = ?");
args.push(String(status));
}
// Employees only see their own appointments (still can see all to plan, but filter by default)
// We let them see all so they can coordinate; UI filters by default.
let sql = `SELECT * FROM appointments WHERE ${conds.join(" AND ")} ORDER BY start_at`;
if (limit) sql += ` LIMIT ${Number(limit)}`;
const rows = db.prepare(sql).all(...args) as any[];
rows.forEach(joinAppointment);
res.json({ appointments: rows });
});
appointmentsRouter.get("/:id", (req: AuthedRequest, res) => {
const a = db
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
.get(Number(req.params.id), req.user!.business_id) as any;
if (!a) return err(res, 404, "Cita no encontrada");
joinAppointment(a);
res.json({ appointment: a });
});
appointmentsRouter.post("/", (req: AuthedRequest, res) => {
const {
service_id,
employee_id,
client_id,
new_client,
start_at,
duration_min,
price_override,
notes,
status,
} = req.body ?? {};
if (!service_id) return err(res, 400, "Selecciona un servicio");
if (!start_at) return err(res, 400, "Selecciona una fecha y hora");
const service = db
.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ?`)
.get(Number(service_id), req.user!.business_id) as any;
if (!service) return err(res, 400, "Servicio no válido");
// Resolve client: existing id, or create a new one from new_client payload
let clientId = client_id ? Number(client_id) : null;
if (!clientId && new_client) {
if (!new_client.name) return err(res, 400, "El nombre del cliente es obligatorio");
const created = db
.prepare(
`INSERT INTO clients (business_id, name, email, phone, notes, tags) VALUES (?, ?, ?, ?, ?, ?) RETURNING id`
)
.get(
req.user!.business_id,
new_client.name,
new_client.email || null,
new_client.phone || null,
new_client.notes || null,
new_client.tags || null
) as { id: number };
clientId = created.id;
}
if (!clientId) return err(res, 400, "Selecciona o crea un cliente");
// Resolve employee: auto-assign to self for employees; default to first eligible for owner
let empId = employee_id ? Number(employee_id) : null;
if (!empId && req.user!.role === "employee" && req.user!.employee_id) {
empId = req.user!.employee_id;
}
if (!empId) {
const eligible = db
.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ? LIMIT 1`)
.get(Number(service_id)) as { id: number } | undefined;
empId = eligible?.id ?? null;
}
if (!empId) return err(res, 400, "No hay empleado asignado a este servicio");
const start = new Date(start_at);
if (isNaN(start.getTime())) return err(res, 400, "Fecha de inicio inválida");
const dur = Number(duration_min) || service.duration_min;
const end = new Date(start.getTime() + dur * 60000);
const endIso = end.toISOString().replace(/\.\d{3}Z$/, "Z");
const startIso = start.toISOString().replace(/\.\d{3}Z$/, "Z");
const finalStatus = status || "scheduled";
const price = price_override !== undefined ? Number(price_override) : service.price;
const r = db
.prepare(
`INSERT INTO appointments
(business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes, created_by_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
)
.get(
req.user!.business_id,
Number(service_id),
empId,
clientId,
startIso,
endIso,
finalStatus,
price,
notes || null,
req.user!.id
) as any;
if (finalStatus === "completed") {
db.prepare(
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method)
VALUES (?, ?, ?, ?, ?, ?, 0, 'card')`
).run(req.user!.business_id, r.id, clientId, empId, Number(service_id), price);
}
joinAppointment(r);
res.status(201).json({ appointment: r });
});
appointmentsRouter.patch("/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id) as any;
if (!existing) return err(res, 404, "Cita no encontrada");
const {
service_id,
employee_id,
client_id,
start_at,
end_at,
duration_min,
status,
price,
notes,
} = req.body ?? {};
let startIso = existing.start_at;
let endIso = existing.end_at;
if (start_at) {
const start = new Date(start_at);
if (isNaN(start.getTime())) return err(res, 400, "Fecha inválida");
startIso = start.toISOString().replace(/\.\d{3}Z$/, "Z");
const svc = service_id
? db.prepare(`SELECT duration_min FROM services WHERE id = ?`).get(Number(service_id)) as any
: { duration_min: null };
const dur = Number(duration_min) || svc?.duration_min || null;
if (end_at) {
endIso = new Date(end_at).toISOString().replace(/\.\d{3}Z$/, "Z");
} else if (dur) {
endIso = new Date(start.getTime() + dur * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
}
} else if (duration_min) {
const start = new Date(existing.start_at);
endIso = new Date(start.getTime() + Number(duration_min) * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
}
const finalStatus = status ?? existing.status;
const finalPrice = price !== undefined ? Number(price) : existing.price;
db.prepare(
`UPDATE appointments
SET service_id = ?, employee_id = ?, client_id = ?, start_at = ?, end_at = ?, status = ?, price = ?, notes = ?, updated_at = datetime('now')
WHERE id = ?`
).run(
service_id !== undefined ? Number(service_id) : existing.service_id,
employee_id !== undefined ? Number(employee_id) : existing.employee_id,
client_id !== undefined ? Number(client_id) : existing.client_id,
startIso,
endIso,
finalStatus,
finalPrice,
notes !== undefined ? notes : existing.notes,
id
);
// If transitioned to completed and no ticket yet, create one
if (finalStatus === "completed" && existing.status !== "completed") {
const hasTicket = db
.prepare(`SELECT id FROM tickets WHERE appointment_id = ?`)
.get(id);
if (!hasTicket) {
const appt = db.prepare(`SELECT * FROM appointments WHERE id = ?`).get(id) as any;
const commission = computeCommission(appt.business_id, appt.service_id, appt.employee_id, appt.price);
db.prepare(
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method, commission)
VALUES (?, ?, ?, ?, ?, ?, 0, 'card', ?)`
).run(appt.business_id, id, appt.client_id, appt.employee_id, appt.service_id, appt.price, commission);
}
}
const updated = db.prepare(`SELECT * FROM appointments WHERE id = ?`).get(id) as any;
joinAppointment(updated);
res.json({ appointment: updated });
});
appointmentsRouter.post("/:id/complete", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const appt = db
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id) as any;
if (!appt) return err(res, 404, "Cita no encontrada");
const { tip = 0, payment_method = "card" } = req.body ?? {};
db.prepare(`UPDATE appointments SET status = 'completed', updated_at = datetime('now') WHERE id = ?`).run(id);
const hasTicket = db.prepare(`SELECT id FROM tickets WHERE appointment_id = ?`).get(id);
if (!hasTicket) {
const commission = computeCommission(appt.business_id, appt.service_id, appt.employee_id, appt.price);
db.prepare(
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method, commission)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(appt.business_id, id, appt.client_id, appt.employee_id, appt.service_id, appt.price, Number(tip), payment_method, commission);
} else {
// ticket already exists (e.g. completed via PATCH) — backfill commission + tip if provided
db.prepare(`UPDATE tickets SET commission = COALESCE(NULLIF(commission,0), ?) WHERE appointment_id = ? AND commission = 0`).run(
computeCommission(appt.business_id, appt.service_id, appt.employee_id, appt.price),
id
);
}
const updated = db.prepare(`SELECT * FROM appointments WHERE id = ?`).get(id) as any;
joinAppointment(updated);
res.json({ appointment: updated });
});
appointmentsRouter.delete("/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT id FROM appointments WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id);
if (!existing) return err(res, 404, "Cita no encontrada");
db.prepare(`DELETE FROM appointments WHERE id = ?`).run(id);
res.json({ ok: true });
});
/** Preview the cancellation policy outcome for an appointment (penalty based on hours left). */
appointmentsRouter.get("/:id/cancellation-policy", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const appt = db.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`).get(id, req.user!.business_id) as any;
if (!appt) return err(res, 404, "Cita no encontrada");
const biz = db
.prepare(`SELECT cancel_window_hours wh, cancel_penalty_pct pen, require_deposit rd, deposit_pct dp FROM businesses WHERE id = ?`)
.get(req.user!.business_id) as any;
const hoursLeft = (new Date(appt.start_at).getTime() - Date.now()) / 3600000;
const withinWindow = hoursLeft < (biz?.wh ?? 24);
const penalty = withinWindow ? Math.round(((appt.price * (biz?.pen ?? 0)) / 100) * 100) / 100 : 0;
res.json({
hours_left: Math.round(hoursLeft),
within_window: withinWindow,
window_hours: biz?.wh ?? 24,
penalty_pct: withinWindow ? biz?.pen ?? 0 : 0,
penalty_amount: penalty,
policy_active: (biz?.pen ?? 0) > 0,
});
});
+32
View File
@@ -0,0 +1,32 @@
import { Router } from "express";
import { db } from "../db.ts";
import { authRequired, type AuthedRequest } from "../lib/auth.ts";
export const authRouter = Router();
authRouter.post("/login", (req, res) => {
const { email, password } = req.body ?? {};
if (!email || !password) return res.status(400).json({ error: "Faltan credenciales" });
const user = db
.prepare(`SELECT id, business_id, email, name, role, employee_id, avatar_color, password FROM users WHERE email = ?`)
.get(String(email).toLowerCase().trim()) as any;
if (!user || user.password !== password) {
return res.status(401).json({ error: "Correo o contraseña incorrectos" });
}
const { password: _pw, ...safe } = user;
res.json({ token: String(user.id), user: safe });
});
authRouter.get("/me", authRequired, (req: AuthedRequest, res) => {
res.json({ user: req.user });
});
authRouter.get("/demo-users", (_req, res) => {
const users = db
.prepare(
`SELECT email, name, role, avatar_color FROM users
ORDER BY CASE role WHEN 'admin' THEN 0 WHEN 'owner' THEN 1 ELSE 2 END, name`
)
.all();
res.json({ users });
});
+154
View File
@@ -0,0 +1,154 @@
import { Router } from "express";
import { db } from "../db.ts";
export const bookingRouter = Router();
function publicBusiness(slug: string) {
return db
.prepare(
`SELECT id, name, industry, currency, currency_symbol, phone, address, timezone,
booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct
FROM businesses WHERE slug = ? AND status = 'active'`
)
.get(slug) as any;
}
// Public business info + services + employees
bookingRouter.get("/:slug", (req, res) => {
const biz = publicBusiness(req.params.slug);
if (!biz) return res.status(404).json({ error: "Negocio no encontrado" });
if (!biz.booking_enabled) return res.status(403).json({ error: "Las reservas online están desactivadas" });
const services = db
.prepare(`SELECT id, name, description, category, duration_min, price, color FROM services WHERE business_id = ? AND active = 1 ORDER BY category, name`)
.all(biz.id);
const employees = db
.prepare(`SELECT id, name, role, color FROM employees WHERE business_id = ? AND active = 1 ORDER BY name`)
.all(biz.id);
res.json({ business: { ...biz, currency_symbol: biz.currency_symbol || "$" }, services, employees });
});
// Available slots for a service + employee on a given date
bookingRouter.get("/:slug/slots", (req, res) => {
const biz = publicBusiness(req.params.slug);
if (!biz || !biz.booking_enabled) return res.status(404).json({ error: "No disponible" });
const serviceId = Number(req.query.service_id);
const employeeId = req.query.employee_id ? Number(req.query.employee_id) : null;
const date = req.query.date as string; // YYYY-MM-DD
if (!serviceId || !date) return res.status(400).json({ error: "service_id y date son obligatorios" });
const service = db.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ? AND active = 1`).get(serviceId, biz.id) as any;
if (!service) return res.status(400).json({ error: "Servicio no válido" });
// candidate employees
let candidates: number[];
if (employeeId) {
const ok = db.prepare(`SELECT 1 FROM employee_services WHERE service_id = ? AND employee_id = ?`).get(serviceId, employeeId);
if (!ok) return res.status(400).json({ error: "El especialista no ofrece este servicio" });
candidates = [employeeId];
} else {
candidates = db.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ?`).all(serviceId).map((r: any) => r.id);
if (candidates.length === 0) {
const any = db.prepare(`SELECT id FROM employees WHERE business_id = ? AND active = 1 LIMIT 1`).get(biz.id) as any;
candidates = any ? [any.id] : [];
}
}
if (candidates.length === 0) return res.json({ slots: [] });
// business hours 09:0020:00, 30-min grid; reject past times
const dayStart = new Date(`${date}T09:00:00`);
const dayEnd = new Date(`${date}T20:00:00`);
const now = new Date();
const slots: { time: string; iso: string; employee_id: number; employee_name: string }[] = [];
const dur = service.duration_min;
// gather existing appointments that day for these employees
const startOfDayIso = `${date}T00:00:00`;
const endOfDayIso = `${date}T23:59:59`;
const existing = db
.prepare(
`SELECT employee_id, start_at, end_at FROM appointments
WHERE business_id = ? AND employee_id IN (${candidates.map(() => "?").join(",")})
AND status IN ('scheduled','completed') AND start_at >= ? AND start_at <= ?`
)
.all(biz.id, ...candidates, startOfDayIso, endOfDayIso) as any[];
for (let t = dayStart.getTime(); t + dur * 60000 <= dayEnd.getTime(); t += 30 * 60000) {
if (t < now.getTime() + 30 * 60000) continue; // skip past / too-soon
const slotStart = t;
const slotEnd = t + dur * 60000;
// find an employee free in this window
for (const empId of candidates) {
const busy = existing.some(
(b) => b.employee_id === empId && slotStart < new Date(b.end_at).getTime() && slotEnd > new Date(b.start_at).getTime()
);
if (!busy) {
const emp = db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any;
slots.push({
time: new Date(t).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", hour12: true }),
iso: new Date(t).toISOString(),
employee_id: empId,
employee_name: emp?.name,
});
break; // one free employee per slot is enough
}
}
if (slots.length >= 40) break;
}
res.json({ slots, service: { id: service.id, name: service.name, price: service.price, duration_min: service.duration_min } });
});
// Create a booking from the public site (no auth): creates/finds client + appointment
bookingRouter.post("/:slug/book", (req, res) => {
const biz = publicBusiness(req.params.slug);
if (!biz || !biz.booking_enabled) return res.status(404).json({ error: "No disponible" });
const { service_id, employee_id, start_at, client } = req.body ?? {};
if (!service_id || !start_at || !client?.name) return res.status(400).json({ error: "Faltan datos (servicio, hora o nombre)" });
const service = db.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ? AND active = 1`).get(Number(service_id), biz.id) as any;
if (!service) return res.status(400).json({ error: "Servicio no válido" });
const start = new Date(start_at);
if (isNaN(start.getTime()) || start.getTime() < Date.now()) return res.status(400).json({ error: "Fecha inválida" });
// resolve employee
let empId = employee_id ? Number(employee_id) : null;
if (!empId) {
const cand = db.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ? LIMIT 1`).get(Number(service_id)) as any;
empId = cand?.id ?? null;
}
if (!empId) return res.status(400).json({ error: "Sin especialista disponible" });
// resolve client (by phone/email or create)
let clientId: number | null = null;
const match = client.phone
? (db.prepare(`SELECT id FROM clients WHERE business_id = ? AND phone = ?`).get(biz.id, client.phone) as any)
: client.email
? (db.prepare(`SELECT id FROM clients WHERE business_id = ? AND email = ?`).get(biz.id, client.email) as any)
: null;
if (match) clientId = match.id;
else {
const created = db
.prepare(`INSERT INTO clients (business_id, name, email, phone, tags) VALUES (?, ?, ?, ?, 'Online') RETURNING id`)
.get(biz.id, client.name, client.email || null, client.phone || null) as any;
clientId = created.id;
}
const endIso = new Date(start.getTime() + service.duration_min * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
const startIso = start.toISOString().replace(/\.\d{3}Z$/, "Z");
const appt = db
.prepare(
`INSERT INTO appointments (business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes)
VALUES (?, ?, ?, ?, ?, ?, 'scheduled', ?, ?) RETURNING *`
)
.get(biz.id, Number(service_id), empId, clientId, startIso, endIso, service.price, client.notes || "Reserva online") as any;
res.status(201).json({
appointment: {
id: appt.id,
start_at: appt.start_at,
end_at: appt.end_at,
price: appt.price,
service_name: service.name,
employee_name: (db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any)?.name,
},
business: { name: biz.name, currency_symbol: biz.currency_symbol },
client_created: !match,
});
});
+16
View File
@@ -0,0 +1,16 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err } from "../lib/auth.ts";
export const businessRouter = Router();
businessRouter.get("/", (req: AuthedRequest, res) => {
const b = db
.prepare(
`SELECT id, name, industry, currency, currency_symbol, phone, address FROM businesses WHERE id = ?`
)
.get(req.user!.business_id);
if (!b) return err(res, 404, "Negocio no encontrado");
res.json({ business: b });
});
+119
View File
@@ -0,0 +1,119 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err, ownerOnly } from "../lib/auth.ts";
export const cashRouter = Router();
cashRouter.use(ownerOnly);
function sessionStats(sessionId: number, businessId: number) {
const row = db
.prepare(
`SELECT
COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END),0) income,
COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END),0) expense,
COUNT(*) n
FROM cash_entries WHERE session_id = ? AND business_id = ?`
)
.get(sessionId, businessId) as { income: number; expense: number; n: number };
const tickets = db
.prepare(
`SELECT COALESCE(SUM(amount),0) s, COUNT(*) n FROM cash_entries WHERE session_id = ? AND business_id = ? AND ticket_id IS NOT NULL`
)
.get(sessionId, businessId) as { s: number; n: number };
return {
income: Math.round(row.income * 100) / 100,
expense: Math.round(row.expense * 100) / 100,
entries: row.n,
ticket_income: Math.round(tickets.s * 100) / 100,
ticket_count: tickets.n,
};
}
cashRouter.get("/session", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const s = db
.prepare(`SELECT * FROM cash_sessions WHERE business_id = ? AND status = 'open' ORDER BY opened_at DESC LIMIT 1`)
.get(bid) as any;
if (!s) return res.json({ session: null });
res.json({ session: s, stats: sessionStats(s.id, bid) });
});
cashRouter.get("/sessions", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const limit = Math.min(60, Number(req.query.limit) || 30);
const rows = db
.prepare(`SELECT * FROM cash_sessions WHERE business_id = ? ORDER BY opened_at DESC LIMIT ?`)
.all(bid, limit) as any[];
const out = rows.map((s) => ({ session: s, stats: sessionStats(s.id, bid) }));
res.json({ sessions: out });
});
cashRouter.post("/open", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const open = db.prepare(`SELECT id FROM cash_sessions WHERE business_id = ? AND status = 'open'`).get(bid);
if (open) return err(res, 400, "Ya hay una caja abierta — ciérrala primero");
const { opening_amount = 0, note } = req.body ?? {};
const s = db
.prepare(
`INSERT INTO cash_sessions (business_id, opened_by_user_id, opening_amount, note) VALUES (?, ?, ?, ?) RETURNING *`
)
.get(bid, req.user!.id, Number(opening_amount) || 0, note || null) as any;
res.status(201).json({ session: s, stats: sessionStats(s.id, bid) });
});
cashRouter.post("/close", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const s = db.prepare(`SELECT * FROM cash_sessions WHERE business_id = ? AND status = 'open'`).get(bid) as any;
if (!s) return err(res, 400, "No hay caja abierta");
const { closing_amount, note } = req.body ?? {};
const stats = sessionStats(s.id, bid);
const expected = Number(s.opening_amount) + stats.income - stats.expense;
db.prepare(`UPDATE cash_sessions SET status='closed', closed_at=datetime('now'), closing_amount=?, expected_amount=?, note=COALESCE(?,note) WHERE id=?`).run(
Number(closing_amount) || 0,
Math.round(expected * 100) / 100,
note || null,
s.id
);
const updated = db.prepare(`SELECT * FROM cash_sessions WHERE id=?`).get(s.id) as any;
res.json({ session: updated, stats, expected: Math.round(expected * 100) / 100 });
});
cashRouter.get("/entries", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const sessionId = req.query.session_id ? Number(req.query.session_id) : null;
let rows: any[];
if (sessionId) {
rows = db.prepare(`SELECT * FROM cash_entries WHERE business_id = ? AND session_id = ? ORDER BY created_at DESC`).all(bid, sessionId);
} else {
const s = db.prepare(`SELECT id FROM cash_sessions WHERE business_id = ? AND status='open'`).get(bid) as any;
rows = s
? db.prepare(`SELECT * FROM cash_entries WHERE business_id = ? AND session_id = ? ORDER BY created_at DESC`).all(bid, s.id)
: [];
}
res.json({ entries: rows });
});
cashRouter.post("/entries", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const s = db.prepare(`SELECT id FROM cash_sessions WHERE business_id = ? AND status='open'`).get(bid) as any;
if (!s) return err(res, 400, "No hay caja abierta");
const { type, amount, concept, payment_method } = req.body ?? {};
if (!["income", "expense"].includes(type)) return err(res, 400, "Tipo inválido (income/expense)");
if (!amount || !concept) return err(res, 400, "Monto y concepto son obligatorios");
const r = db
.prepare(
`INSERT INTO cash_entries (business_id, session_id, type, amount, concept, payment_method) VALUES (?, ?, ?, ?, ?, ?) RETURNING *`
)
.get(bid, s.id, type, Number(amount), concept, payment_method || null) as any;
res.status(201).json({ entry: r, stats: sessionStats(s.id, bid) });
});
cashRouter.delete("/entries/:id", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const id = Number(req.params.id);
const e = db.prepare(`SELECT session_id FROM cash_entries WHERE id = ? AND business_id = ?`).get(id, bid) as any;
if (!e) return err(res, 404, "Movimiento no encontrado");
db.prepare(`DELETE FROM cash_entries WHERE id = ?`).run(id);
res.json({ ok: true, stats: sessionStats(e.session_id, bid) });
});
+111
View File
@@ -0,0 +1,111 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err } from "../lib/auth.ts";
import type { ClientStats } from "../../shared/types.ts";
export const clientsRouter = Router();
function statsFor(clientId: number): ClientStats {
const agg = db
.prepare(
`SELECT
COUNT(*) c,
COALESCE(SUM(amount+tip),0) s,
MAX(created_at) last,
COALESCE(SUM(CASE WHEN amount > 0 THEN 1 ELSE 0 END)*1.0 / NULLIF(COUNT(*),0),0) ar
FROM tickets WHERE client_id = ?`
)
.get(clientId) as { c: number; s: number; last: string | null; ar: number };
const noShow = db
.prepare(`SELECT COUNT(*) c FROM appointments WHERE client_id = ? AND status = 'no_show'`)
.get(clientId) as { c: number };
return {
visits: agg.c,
total_spent: Math.round(agg.s * 100) / 100,
last_visit: agg.last,
avg_ticket: agg.c > 0 ? Math.round((agg.s / agg.c) * 100) / 100 : 0,
no_show_count: noShow.c,
};
}
clientsRouter.get("/", (req: AuthedRequest, res) => {
const q = (req.query.q as string | undefined)?.trim();
let rows: any[];
if (q) {
const like = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
rows = db
.prepare(
`SELECT * FROM clients
WHERE business_id = ? AND (name LIKE ? ESCAPE '\\' OR phone LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\')
ORDER BY name LIMIT 50`
)
.all(req.user!.business_id, like, like, like);
} else {
rows = db
.prepare(`SELECT * FROM clients WHERE business_id = ? ORDER BY name LIMIT 50`)
.all(req.user!.business_id);
}
rows.forEach((r) => (r.stats = statsFor(r.id)));
res.json({ clients: rows });
});
clientsRouter.get("/:id", (req: AuthedRequest, res) => {
const c = db
.prepare(`SELECT * FROM clients WHERE id = ? AND business_id = ?`)
.get(Number(req.params.id), req.user!.business_id) as any;
if (!c) return err(res, 404, "Cliente no encontrado");
c.stats = statsFor(c.id);
res.json({ client: c });
});
clientsRouter.post("/", (req: AuthedRequest, res) => {
const { name, email, phone, notes, tags } = req.body ?? {};
if (!name) return err(res, 400, "El nombre es obligatorio");
const r = db
.prepare(
`INSERT INTO clients (business_id, name, email, phone, notes, tags) VALUES (?, ?, ?, ?, ?, ?) RETURNING *`
)
.get(
req.user!.business_id,
name,
email || null,
phone || null,
notes || null,
tags || null
) as any;
r.stats = statsFor(r.id);
res.status(201).json({ client: r });
});
clientsRouter.patch("/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT * FROM clients WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id) as any;
if (!existing) return err(res, 404, "Cliente no encontrado");
const { name, email, phone, notes, tags } = req.body ?? {};
db.prepare(
`UPDATE clients SET name = ?, email = ?, phone = ?, notes = ?, tags = ? WHERE id = ?`
).run(
name ?? existing.name,
email ?? existing.email,
phone ?? existing.phone,
notes ?? existing.notes,
tags ?? existing.tags,
id
);
const updated = db.prepare(`SELECT * FROM clients WHERE id = ?`).get(id) as any;
updated.stats = statsFor(id);
res.json({ client: updated });
});
clientsRouter.delete("/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT id FROM clients WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id);
if (!existing) return err(res, 404, "Cliente no encontrado");
db.prepare(`DELETE FROM clients WHERE id = ?`).run(id);
res.json({ ok: true });
});
+337
View File
@@ -0,0 +1,337 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { ownerOnly } from "../lib/auth.ts";
export const dashboardRouter = Router();
dashboardRouter.use(ownerOnly);
dashboardRouter.get("/overview", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const scalar = (sql: string, ...args: any[]) => (db.prepare(sql).get(...args) as any)?.v ?? 0;
const revenue_today = scalar(
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND date(created_at) = date('now')`,
bid
);
const revenue_week = scalar(
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-7 days')`,
bid
);
const revenue_month = scalar(
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
bid
);
const revenue_prev = scalar(
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-60 days') AND created_at < datetime('now','-30 days')`,
bid
);
const revenue_change_pct =
revenue_prev > 0 ? Math.round(((revenue_month - revenue_prev) / revenue_prev) * 1000) / 10 : 0;
const appts_today = scalar(
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND date(start_at) = date('now')`,
bid
);
const appts_week = scalar(
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= datetime('now','-7 days')`,
bid
);
const appts_upcoming = scalar(
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'scheduled' AND start_at >= datetime('now')`,
bid
);
const active_clients = scalar(
`SELECT COUNT(DISTINCT client_id) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
bid
);
const avg_ticket = scalar(
`SELECT COALESCE(AVG(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
bid
);
const total_range = scalar(
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
bid
);
const cancels = scalar(
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status IN ('cancelled','no_show') AND created_at >= datetime('now','-30 days')`,
bid
);
const cancel_rate = total_range > 0 ? Math.round((cancels / total_range) * 1000) / 10 : 0;
const completed = scalar(
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'completed' AND created_at >= datetime('now','-30 days')`,
bid
);
const occupancy_pct = total_range > 0 ? Math.round((completed / total_range) * 100) : 0;
res.json({
revenue_today: Math.round(revenue_today * 100) / 100,
revenue_week: Math.round(revenue_week * 100) / 100,
revenue_month: Math.round(revenue_month * 100) / 100,
revenue_change_pct,
appts_today,
appts_week,
appts_upcoming,
active_clients,
avg_ticket: Math.round(avg_ticket * 100) / 100,
cancel_rate,
occupancy_pct,
});
});
dashboardRouter.get("/best-employees", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const range = (req.query.range as string) || "30";
const rows = db
.prepare(
`SELECT e.id, e.name, e.color, e.role,
COUNT(t.id) appts,
COALESCE(SUM(t.amount+t.tip),0) revenue,
COALESCE((SELECT AVG(rating) FROM reviews WHERE employee_id = e.id), e.rating) avg_rating
FROM employees e
LEFT JOIN tickets t ON t.employee_id = e.id AND t.created_at >= datetime('now','-${Number(range)} days')
WHERE e.business_id = ?
GROUP BY e.id
ORDER BY revenue DESC`
)
.all(bid) as any[];
const totalAppts = rows.reduce((a, r) => a + r.appts, 0);
const out = rows.map((r) => ({
employee: { id: r.id, name: r.name, color: r.color, role: r.role },
appointments: r.appts,
revenue: Math.round(r.revenue * 100) / 100,
avg_rating: Math.round(r.avg_rating * 10) / 10,
utilization_pct: totalAppts > 0 ? Math.round((r.appts / totalAppts) * 100) : 0,
}));
res.json({ employees: out });
});
dashboardRouter.get("/best-services", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const range = (req.query.range as string) || "30";
const rows = db
.prepare(
`SELECT s.id, s.name, s.category, s.color,
COUNT(t.id) count,
COALESCE(SUM(t.amount+t.tip),0) revenue
FROM services s
LEFT JOIN tickets t ON t.service_id = s.id AND t.created_at >= datetime('now','-${Number(range)} days')
WHERE s.business_id = ?
GROUP BY s.id
ORDER BY revenue DESC`
)
.all(bid) as any[];
const out = rows.map((r) => ({
service: { id: r.id, name: r.name, category: r.category, color: r.color },
count: r.count,
revenue: Math.round(r.revenue * 100) / 100,
}));
res.json({ services: out });
});
dashboardRouter.get("/top-tickets", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const limit = Math.min(50, Number(req.query.limit) || 10);
const range = (req.query.range as string) || "30";
const rows = db
.prepare(
`SELECT t.*, s.name service_name, s.category service_category, s.color service_color,
e.name employee_name, e.color employee_color,
c.name client_name
FROM tickets t
JOIN services s ON s.id = t.service_id
JOIN employees e ON e.id = t.employee_id
JOIN clients c ON c.id = t.client_id
WHERE t.business_id = ? AND t.created_at >= datetime('now','-${Number(range)} days')
ORDER BY (t.amount + t.tip) DESC
LIMIT ?`
)
.all(bid, limit) as any[];
const out = rows.map((t) => ({
ticket: {
id: t.id,
business_id: t.business_id,
appointment_id: t.appointment_id,
client_id: t.client_id,
employee_id: t.employee_id,
service_id: t.service_id,
amount: t.amount,
tip: t.tip,
payment_method: t.payment_method,
created_at: t.created_at,
service: { id: t.service_id, name: t.service_name, category: t.service_category, color: t.service_color },
employee: { id: t.employee_id, name: t.employee_name, color: t.employee_color },
client: { id: t.client_id, name: t.client_name },
},
}));
res.json({ tickets: out });
});
dashboardRouter.get("/top-clients", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const limit = Math.min(50, Number(req.query.limit) || 10);
const range = (req.query.range as string) || "30";
const rows = db
.prepare(
`SELECT c.id, c.name, c.tags, c.phone,
COUNT(t.id) visits,
COALESCE(SUM(t.amount+t.tip),0) total,
MAX(t.created_at) last_visit
FROM clients c
LEFT JOIN tickets t ON t.client_id = c.id AND t.created_at >= datetime('now','-${Number(range)} days')
WHERE c.business_id = ?
GROUP BY c.id
HAVING visits > 0
ORDER BY total DESC
LIMIT ?`
)
.all(bid, limit) as any[];
const out = rows.map((r) => ({
client: { id: r.id, name: r.name, tags: r.tags, phone: r.phone },
visits: r.visits,
total_spent: Math.round(r.total * 100) / 100,
last_visit: r.last_visit,
}));
res.json({ clients: out });
});
dashboardRouter.get("/frequent-clients", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const limit = Math.min(50, Number(req.query.limit) || 10);
const range = (req.query.range as string) || "30";
const rows = db
.prepare(
`SELECT c.id, c.name, c.tags, c.phone,
COUNT(t.id) visits,
COALESCE(SUM(t.amount+t.tip),0) total,
MAX(t.created_at) last_visit
FROM clients c
JOIN tickets t ON t.client_id = c.id AND t.created_at >= datetime('now','-${Number(range)} days')
WHERE c.business_id = ?
GROUP BY c.id
ORDER BY visits DESC, total DESC
LIMIT ?`
)
.all(bid, limit) as any[];
const out = rows.map((r) => ({
client: { id: r.id, name: r.name, tags: r.tags, phone: r.phone },
visits: r.visits,
total_spent: Math.round(r.total * 100) / 100,
last_visit: r.last_visit,
}));
res.json({ clients: out });
});
dashboardRouter.get("/revenue-trend", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const days = Math.min(120, Number(req.query.days) || 30);
const rows = db
.prepare(
`SELECT date(created_at) date, COALESCE(SUM(amount+tip),0) revenue, COUNT(*) appts
FROM tickets
WHERE business_id = ? AND created_at >= datetime('now','-${days} days')
GROUP BY date(created_at)
ORDER BY date ASC`
)
.all(bid) as any[];
// Fill missing days with 0
const map = new Map(rows.map((r) => [r.date, r]));
const out: { date: string; revenue: number; appts: number }[] = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const key = d.toISOString().slice(0, 10);
const row = map.get(key);
out.push({
date: key,
revenue: row ? Math.round(row.revenue * 100) / 100 : 0,
appts: row ? row.appts : 0,
});
}
res.json({ points: out });
});
dashboardRouter.get("/revenue-by-category", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const range = (req.query.range as string) || "30";
const rows = db
.prepare(
`SELECT s.category, COUNT(t.id) count, COALESCE(SUM(t.amount+t.tip),0) revenue
FROM tickets t
JOIN services s ON s.id = t.service_id
WHERE t.business_id = ? AND t.created_at >= datetime('now','-${Number(range)} days')
GROUP BY s.category
ORDER BY revenue DESC`
)
.all(bid) as any[];
const out = rows.map((r) => ({
category: r.category,
count: r.count,
revenue: Math.round(r.revenue * 100) / 100,
}));
res.json({ slices: out });
});
dashboardRouter.get("/tickets", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const limit = Math.min(200, Number(req.query.limit) || 50);
const rows = db
.prepare(
`SELECT t.*, s.name service_name, s.category service_category, s.color service_color,
e.name employee_name, e.color employee_color,
c.name client_name
FROM tickets t
JOIN services s ON s.id = t.service_id
JOIN employees e ON e.id = t.employee_id
JOIN clients c ON c.id = t.client_id
WHERE t.business_id = ?
ORDER BY t.created_at DESC
LIMIT ?`
)
.all(bid, limit) as any[];
const out = rows.map((t) => ({
id: t.id,
business_id: t.business_id,
appointment_id: t.appointment_id,
client_id: t.client_id,
employee_id: t.employee_id,
service_id: t.service_id,
amount: t.amount,
tip: t.tip,
commission: t.commission,
payment_method: t.payment_method,
created_at: t.created_at,
service: { id: t.service_id, name: t.service_name, category: t.service_category, color: t.service_color },
employee: { id: t.employee_id, name: t.employee_name, color: t.employee_color },
client: { id: t.client_id, name: t.client_name },
}));
res.json({ tickets: out });
});
dashboardRouter.get("/commissions", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const range = (req.query.range as string) || "30";
const rows = db
.prepare(
`SELECT e.id, e.name, e.color, e.role,
COUNT(t.id) sales,
COALESCE(SUM(t.amount + t.tip), 0) revenue,
COALESCE(SUM(t.commission), 0) commission
FROM employees e
LEFT JOIN tickets t ON t.employee_id = e.id AND t.created_at >= datetime('now','-${Number(range)} days')
WHERE e.business_id = ?
GROUP BY e.id
ORDER BY commission DESC`
)
.all(bid) as any[];
const out = rows.map((r) => ({
employee: { id: r.id, name: r.name, color: r.color, role: r.role },
sales: r.sales,
revenue: Math.round(r.revenue * 100) / 100,
commission: Math.round(r.commission * 100) / 100,
}));
const total = out.reduce((a, r) => a + r.commission, 0);
res.json({ rows: out, total: Math.round(total * 100) / 100 });
});
+136
View File
@@ -0,0 +1,136 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err, ownerOnly } from "../lib/auth.ts";
import type { EmployeeStats } from "../../shared/types.ts";
export const employeesRouter = Router();
function attachServices(emp: any) {
const ids = db
.prepare(`SELECT service_id FROM employee_services WHERE employee_id = ?`)
.all(emp.id)
.map((r: any) => r.service_id);
emp.service_ids = ids;
return emp;
}
function computeStats(employeeId: number, businessId: number): EmployeeStats {
const completed = db
.prepare(
`SELECT COUNT(*) c, COALESCE(SUM(price),0) s FROM appointments WHERE employee_id = ? AND status = 'completed'`
)
.get(employeeId) as { c: number; s: number };
const total = db
.prepare(`SELECT COUNT(*) c FROM appointments WHERE employee_id = ?`)
.get(employeeId) as { c: number };
const rating = db
.prepare(
`SELECT COALESCE((SELECT AVG(rating) FROM reviews WHERE employee_id = ?), (SELECT rating FROM employees WHERE id = ?)) r`
)
.get(employeeId, employeeId) as { r: number };
const topEmp = db
.prepare(
`SELECT COUNT(*) c FROM appointments WHERE employee_id = ? AND status IN ('completed','scheduled')`
)
.get(employeeId) as { c: number };
const totalBiz = db
.prepare(
`SELECT COUNT(*) c FROM appointments WHERE business_id = ? AND status IN ('completed','scheduled')`
)
.get(businessId) as { c: number };
const utilization = totalBiz.c > 0 ? Math.round((topEmp.c / totalBiz.c) * 100) : 0;
return {
appointments_total: total.c,
appointments_completed: completed.c,
revenue_total: completed.s,
avg_rating: Math.round((rating.r ?? 5) * 10) / 10,
utilization_pct: utilization,
};
}
employeesRouter.get("/", (req: AuthedRequest, res) => {
const rows = db
.prepare(`SELECT * FROM employees WHERE business_id = ? ORDER BY active DESC, name`)
.all(req.user!.business_id) as any[];
const out = rows.map((r) => {
attachServices(r);
r.stats = computeStats(r.id, req.user!.business_id as number);
return r;
});
res.json({ employees: out });
});
employeesRouter.get("/:id", (req: AuthedRequest, res) => {
const emp = db.prepare(`SELECT * FROM employees WHERE id = ? AND business_id = ?`).get(
Number(req.params.id),
req.user!.business_id
) as any;
if (!emp) return err(res, 404, "Empleado no encontrado");
attachServices(emp);
emp.stats = computeStats(emp.id, req.user!.business_id as number);
res.json({ employee: emp });
});
employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
const { name, role, email, phone, color, service_ids } = req.body ?? {};
if (!name) return err(res, 400, "El nombre es obligatorio");
const r = db
.prepare(
`INSERT INTO employees (business_id, name, role, email, phone, color) VALUES (?, ?, ?, ?, ?, ?) RETURNING *`
)
.get(
req.user!.business_id,
name,
role || "Especialista",
email || null,
phone || null,
color || "#3b66ff"
) as any;
if (Array.isArray(service_ids)) {
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
for (const sid of service_ids) ins.run(r.id, Number(sid));
}
attachServices(r);
r.stats = computeStats(r.id, req.user!.business_id as number);
res.status(201).json({ employee: r });
});
employeesRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT * FROM employees WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id as number) as any;
if (!existing) return err(res, 404, "Empleado no encontrado");
const { name, role, email, phone, color, active, service_ids } = req.body ?? {};
const merged = {
name: name ?? existing.name,
role: role ?? existing.role,
email: email ?? existing.email,
phone: phone ?? existing.phone,
color: color ?? existing.color,
active: active === undefined ? existing.active : active ? 1 : 0,
};
db.prepare(
`UPDATE employees SET name = ?, role = ?, email = ?, phone = ?, color = ?, active = ? WHERE id = ?`
).run(merged.name, merged.role, merged.email, merged.phone, merged.color, merged.active, id);
if (Array.isArray(service_ids)) {
db.prepare(`DELETE FROM employee_services WHERE employee_id = ?`).run(id);
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
for (const sid of service_ids) ins.run(id, Number(sid));
}
const updated = db.prepare(`SELECT * FROM employees WHERE id = ?`).get(id) as any;
attachServices(updated);
updated.stats = computeStats(updated.id, req.user!.business_id as number);
res.json({ employee: updated });
});
employeesRouter.delete("/:id", ownerOnly, (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT id FROM employees WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id as number);
if (!existing) return err(res, 404, "Empleado no encontrado");
db.prepare(`DELETE FROM employees WHERE id = ?`).run(id);
res.json({ ok: true });
});
+147
View File
@@ -0,0 +1,147 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err } from "../lib/auth.ts";
export const notificationsRouter = Router();
/** Build a human message for an appointment reminder/confirmation. */
function buildMessage(biz: any, appt: any, kind: string): string {
const when = new Date(appt.start_at).toLocaleString("es-MX", {
weekday: "long",
day: "numeric",
month: "long",
hour: "2-digit",
minute: "2-digit",
timeZone: biz.timezone || "America/Mexico_City",
});
const svc = appt.service_name || "tu servicio";
const emp = appt.employee_name ? ` con ${appt.employee_name}` : "";
if (kind === "confirmation")
return `¡Hola ${appt.client_name}! Tu cita en ${biz.name} está confirmada: ${svc}${emp}, ${when}.`;
if (kind === "cancellation")
return `Hola ${appt.client_name}, tu cita en ${biz.name} (${svc}) fue cancelada. Para reagendar responde a este mensaje.`;
if (kind === "review")
return `¡Gracias por tu visita a ${biz.name}, ${appt.client_name}! ¿Nos calificas? Tu opinión nos ayuda a mejorar.`;
return `Recordatorio ${biz.name}: tienes ${svc}${emp} el ${when}. ¡Te esperamos!`;
}
/** (Re)generate pending notifications for the upcoming appointments of a business. */
export function scheduleReminders(businessId: number) {
const biz = db.prepare(`SELECT * FROM businesses WHERE id = ?`).get(businessId) as any;
if (!biz) return;
// upcoming scheduled appointments in next 7 days
const appts = db
.prepare(
`SELECT a.id, a.start_at, a.status, a.client_id, c.name client_name, c.phone, c.email,
s.name service_name, e.name employee_name
FROM appointments a
JOIN clients c ON c.id = a.client_id
JOIN services s ON s.id = a.service_id
LEFT JOIN employees e ON e.id = a.employee_id
WHERE a.business_id = ? AND a.status = 'scheduled'
AND a.start_at >= datetime('now') AND a.start_at <= datetime('now','+7 days')
ORDER BY a.start_at ASC`
)
.all(businessId) as any[];
// delete pending reminders that no longer have a matching upcoming scheduled appt
db.prepare(
`DELETE FROM notifications WHERE business_id = ? AND status = 'pending' AND kind IN ('reminder','confirmation')`
).run(businessId);
for (const a of appts) {
const start = new Date(a.start_at);
const channel = a.phone ? "whatsapp" : a.email ? "email" : "whatsapp";
// confirmation (immediate)
const confSend = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
db.prepare(
`INSERT INTO notifications (business_id, appointment_id, client_id, channel, kind, send_at, status, message)
VALUES (?, ?, ?, ?, 'confirmation', ?, 'pending', ?)`
).run(businessId, a.id, a.client_id, channel, confSend, buildMessage(biz, a, "confirmation"));
// reminder 24h before
const reminderAt = new Date(start.getTime() - 24 * 3600000);
if (reminderAt.getTime() > Date.now()) {
db.prepare(
`INSERT INTO notifications (business_id, appointment_id, client_id, channel, kind, send_at, status, message)
VALUES (?, ?, ?, ?, 'reminder', ?, 'pending', ?)`
).run(
businessId,
a.id,
a.client_id,
channel,
reminderAt.toISOString().replace(/\.\d{3}Z$/, "Z"),
buildMessage(biz, a, "reminder")
);
}
}
}
notificationsRouter.get("/", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const status = req.query.status as string | undefined;
let rows: any[];
if (status && status !== "all") {
rows = db
.prepare(
`SELECT n.*, c.name client_name, c.phone, c.email, s.name service_name
FROM notifications n
LEFT JOIN clients c ON c.id = n.client_id
LEFT JOIN appointments a ON a.id = n.appointment_id
LEFT JOIN services s ON s.id = a.service_id
WHERE n.business_id = ? AND n.status = ?
ORDER BY n.send_at DESC LIMIT 200`
)
.all(bid, status);
} else {
rows = db
.prepare(
`SELECT n.*, c.name client_name, c.phone, c.email, s.name service_name
FROM notifications n
LEFT JOIN clients c ON c.id = n.client_id
LEFT JOIN appointments a ON a.id = n.appointment_id
LEFT JOIN services s ON s.id = a.service_id
WHERE n.business_id = ?
ORDER BY n.send_at DESC LIMIT 200`
)
.all(bid);
}
res.json({ notifications: rows });
});
notificationsRouter.post("/regenerate", (req: AuthedRequest, res) => {
scheduleReminders(req.user!.business_id as number);
res.json({ ok: true });
});
/** "Send" a notification — in this demo it simulates sending (marks as sent). */
notificationsRouter.post("/:id/send", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const id = Number(req.params.id);
const n = db.prepare(`SELECT * FROM notifications WHERE id = ? AND business_id = ?`).get(id, bid) as any;
if (!n) return err(res, 404, "Notificación no encontrada");
db.prepare(`UPDATE notifications SET status='sent' WHERE id = ?`).run(id);
res.json({ ok: true, simulated: true });
});
notificationsRouter.post("/:id/cancel", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const id = Number(req.params.id);
const n = db.prepare(`SELECT id FROM notifications WHERE id = ? AND business_id = ?`).get(id, bid);
if (!n) return err(res, 404, "Notificación no encontrada");
db.prepare(`UPDATE notifications SET status='canceled' WHERE id = ?`).run(id);
res.json({ ok: true });
});
notificationsRouter.get("/stats", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const scalar = (sql: string) => (db.prepare(sql).get(bid) as any)?.v ?? 0;
res.json({
pending: scalar(`SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='pending'`),
sent: scalar(`SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='sent'`),
upcoming_reminders: scalar(
`SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='pending' AND kind='reminder' AND send_at >= datetime('now')`
),
no_show_rate: scalar(
`SELECT COALESCE(ROUND(100.0 * SUM(CASE WHEN status='no_show' THEN 1 ELSE 0 END) / NULLIF(COUNT(*),0),1),0) v FROM appointments WHERE business_id = ? AND created_at >= datetime('now','-30 days')`
),
});
});
+113
View File
@@ -0,0 +1,113 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err, ownerOnly } from "../lib/auth.ts";
export const servicesRouter = Router();
function attachEmployees(svc: any) {
const ids = db
.prepare(`SELECT employee_id FROM employee_services WHERE service_id = ?`)
.all(svc.id)
.map((r: any) => r.employee_id);
svc.employee_ids = ids;
return svc;
}
servicesRouter.get("/", (req: AuthedRequest, res) => {
const { employee_id, active } = req.query;
let rows: any[];
if (employee_id) {
rows = db
.prepare(
`SELECT s.* FROM services s
JOIN employee_services es ON es.service_id = s.id
WHERE s.business_id = ? AND es.employee_id = ?
ORDER BY s.category, s.name`
)
.all(req.user!.business_id, Number(employee_id));
} else {
rows = db
.prepare(
`SELECT * FROM services WHERE business_id = ? ORDER BY category, name`
)
.all(req.user!.business_id);
}
if (active === "true") rows = rows.filter((r) => r.active === 1);
rows.forEach(attachEmployees);
res.json({ services: rows });
});
servicesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
const { name, category, description, duration_min, price, color, employee_ids, active } = req.body ?? {};
if (!name) return err(res, 400, "El nombre es obligatorio");
const r = db
.prepare(
`INSERT INTO services (business_id, name, category, description, duration_min, price, color, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
)
.get(
req.user!.business_id,
name,
category || "General",
description || null,
Number(duration_min) || 60,
Number(price) || 0,
color || "#3b66ff",
active === false ? 0 : 1
) as any;
if (Array.isArray(employee_ids)) {
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
for (const eid of employee_ids) ins.run(Number(eid), r.id);
}
attachEmployees(r);
res.status(201).json({ service: r });
});
servicesRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id) as any;
if (!existing) return err(res, 404, "Servicio no encontrado");
const { name, category, description, duration_min, price, color, active, employee_ids } = req.body ?? {};
const merged = {
name: name ?? existing.name,
category: category ?? existing.category,
description: description ?? existing.description,
duration_min: duration_min === undefined ? existing.duration_min : Number(duration_min),
price: price === undefined ? existing.price : Number(price),
color: color ?? existing.color,
active: active === undefined ? existing.active : active ? 1 : 0,
};
db.prepare(
`UPDATE services SET name = ?, category = ?, description = ?, duration_min = ?, price = ?, color = ?, active = ? WHERE id = ?`
).run(
merged.name,
merged.category,
merged.description,
merged.duration_min,
merged.price,
merged.color,
merged.active,
id
);
if (Array.isArray(employee_ids)) {
db.prepare(`DELETE FROM employee_services WHERE service_id = ?`).run(id);
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
for (const eid of employee_ids) ins.run(Number(eid), id);
}
const updated = db.prepare(`SELECT * FROM services WHERE id = ?`).get(id) as any;
attachEmployees(updated);
res.json({ service: updated });
});
servicesRouter.delete("/:id", ownerOnly, (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT id FROM services WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id);
if (!existing) return err(res, 404, "Servicio no encontrado");
db.prepare(`DELETE FROM services WHERE id = ?`).run(id);
res.json({ ok: true });
});
+57
View File
@@ -0,0 +1,57 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err, ownerOnly } from "../lib/auth.ts";
export const settingsRouter = Router();
const FIELDS = [
"name",
"industry",
"phone",
"address",
"slug",
"booking_enabled",
"cancel_window_hours",
"cancel_penalty_pct",
"require_deposit",
"deposit_pct",
"timezone",
] as const;
settingsRouter.get("/", (req: AuthedRequest, res) => {
const b = db
.prepare(
`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, booking_enabled,
cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone
FROM businesses WHERE id = ?`
)
.get(req.user!.business_id) as any;
if (!b) return err(res, 404, "Negocio no encontrado");
res.json({ settings: b });
});
settingsRouter.patch("/", ownerOnly, (req: AuthedRequest, res) => {
const existing = db.prepare(`SELECT * FROM businesses WHERE id = ?`).get(req.user!.business_id) as any;
if (!existing) return err(res, 404, "Negocio no encontrado");
const body = req.body ?? {};
const merged: any = {};
for (const f of FIELDS) {
if (body[f] !== undefined) merged[f] = body[f];
}
// slug uniqueness check
if (merged.slug !== undefined) {
merged.slug = String(merged.slug).trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
if (!merged.slug) return err(res, 400, "El slug no puede quedar vacío");
const clash = db.prepare(`SELECT id FROM businesses WHERE slug = ? AND id != ?`).get(merged.slug, req.user!.business_id);
if (clash) return err(res, 409, "Esa URL ya está en uso");
}
const sets = Object.keys(merged).map((k) => `${k} = @${k}`);
if (sets.length === 0) return res.json({ settings: existing });
merged.id = req.user!.business_id;
db.prepare(`UPDATE businesses SET ${sets.join(", ")} WHERE id = @id`).run(merged);
const updated = db
.prepare(`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone FROM businesses WHERE id = ?`)
.get(req.user!.business_id);
res.json({ settings: updated });
});
+324
View File
@@ -0,0 +1,324 @@
import { db } from "../db.ts";
import { pathToFileURL } from "node:url";
import { TEMPLATES, getTemplate, DEFAULT_TEMPLATE_KEY, type TemplateDef } from "../lib/templates.ts";
// ---- helpers ----
function mulberry32(seed: number) {
let a = seed;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function isoOffsetDays(days: number, hour = 10, minute = 0) {
const d = new Date();
d.setHours(hour, minute, 0, 0);
d.setDate(d.getDate() + days);
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
}
function addMinutes(iso: string, mins: number) {
const d = new Date(iso);
d.setMinutes(d.getMinutes() + mins);
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
}
const PAYMENT_METHODS = ["card", "cash", "transfer"];
const REVIEW_COMMENTS = [
"Excelente servicio, muy profesional.",
"Me encantó el resultado, volveré pronto.",
"Súper recomendable, ambiente agradable.",
"Muy puntual y atento(a).",
"Buen trabajo pero podría mejorar la puntualidad.",
"El mejor servicio que he recibido.",
"",
"",
];
export interface SeedBusinessOptions {
businessId: number;
template: TemplateDef;
ownerEmail?: string;
ownerName?: string;
ownerPassword?: string;
ownerAvatarColor?: string;
clearFirst?: boolean;
withHistory?: boolean; // past appointments + tickets (default true)
withUpcoming?: boolean; // upcoming appointments (default true)
seed?: number; // deterministic PRNG seed
}
/** Clear business demo data EXCEPT user accounts (owner/employees survive resets).
* User employee_id links are nulled to avoid dangling FKs; seedBusiness re-links them. */
export function clearBusinessData(businessId: number) {
db.exec("PRAGMA foreign_keys = OFF;");
db.prepare(`DELETE FROM reviews WHERE appointment_id IN (SELECT id FROM appointments WHERE business_id = ?)`).run(businessId);
db.prepare(`DELETE FROM tickets WHERE business_id = ?`).run(businessId);
db.prepare(`DELETE FROM appointments WHERE business_id = ?`).run(businessId);
db.prepare(
`DELETE FROM employee_services WHERE employee_id IN (SELECT id FROM employees WHERE business_id = ?) OR service_id IN (SELECT id FROM services WHERE business_id = ?)`
).run(businessId, businessId);
db.prepare(`DELETE FROM services WHERE business_id = ?`).run(businessId);
db.prepare(`DELETE FROM clients WHERE business_id = ?`).run(businessId);
// detach employee users from soon-to-be-deleted employees, then delete employees
db.prepare(`UPDATE users SET employee_id = NULL WHERE business_id = ? AND role = 'employee'`).run(businessId);
db.prepare(`DELETE FROM employees WHERE business_id = ?`).run(businessId);
db.exec("PRAGMA foreign_keys = ON;");
}
/**
* Seed a business with template demo data. Idempotent per call when clearFirst=true.
* Creates owner + employee users, services, clients, and procedural appointments/tickets/reviews.
*/
export function seedBusiness(opts: SeedBusinessOptions) {
const {
businessId,
template,
ownerEmail,
ownerName,
ownerPassword = "demo1234",
ownerAvatarColor = "#3b66ff",
clearFirst = true,
withHistory = true,
withUpcoming = true,
seed = 20260725 + businessId * 7,
} = opts;
if (clearFirst) clearBusinessData(businessId);
const rnd = mulberry32(seed);
const pick = <T>(arr: T[]): T => arr[Math.floor(rnd() * arr.length)];
const pickN = <T>(arr: T[], n: number): T[] => {
const copy = [...arr];
const out: T[] = [];
for (let i = 0; i < n && copy.length; i++) out.push(copy.splice(Math.floor(rnd() * copy.length), 1)[0]);
return out;
};
const rint = (min: number, max: number) => Math.floor(rnd() * (max - min + 1)) + min;
// stamp template + currency on the business
db.prepare(`UPDATE businesses SET template = ?, currency = ?, currency_symbol = ? WHERE id = ?`).run(
template.key,
template.currency,
template.currency_symbol,
businessId
);
// ---- employees ----
const empIds: number[] = [];
for (const e of template.employees) {
const r = db
.prepare(
`INSERT INTO employees (business_id, name, role, color, phone, email, hire_date)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`
)
.get(
businessId,
e.name,
e.role,
e.color,
`+52 55 ${rint(1000, 9999)} ${rint(1000, 9999)}`,
e.name.toLowerCase().replace(/\s+/g, ".") + template.emailDomain,
isoOffsetDays(-rint(180, 900), 9)
) as { id: number };
empIds.push(r.id);
}
// ---- services ----
const svcIds: number[] = [];
for (const s of template.services) {
// derive a demo commission rate by price tier (higher-priced services → higher commission)
const commissionPct = s.price >= 1500 ? 15 : s.price >= 800 ? 12 : s.price >= 400 ? 10 : 8;
const r = db
.prepare(
`INSERT INTO services (business_id, name, description, category, duration_min, price, color, commission_pct)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`
)
.get(businessId, s.name, `Servicio profesional de ${s.category.toLowerCase()}.`, s.category, s.duration_min, s.price, s.color, commissionPct) as {
id: number;
};
svcIds.push(r.id);
}
for (let i = 0; i < template.services.length; i++) {
const s = template.services[i];
const sid = svcIds[i];
for (const ei of s.employees) {
if (empIds[ei]) db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`).run(empIds[ei], sid);
}
}
// ---- clients ----
const clientIds: number[] = [];
const clientTags = ["VIP", "Frecuente", "Nuevo", "Referido"];
for (const c of template.clients) {
const r = db
.prepare(
`INSERT INTO clients (business_id, name, email, phone, tags, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`
)
.get(
businessId,
c.name,
c.name.toLowerCase().replace(/\s+/g, ".") + "@email.com",
`+52 55 ${rint(1000, 9999)} ${rint(1000, 9999)}`,
c.tags ?? pickN(clientTags, rint(0, 2)).join(","),
c.notes ?? pick(["Prefiere horario matutino", "Alergia a cierto producto", "Cliente puntual", ""]),
isoOffsetDays(-rint(1, 540))
) as { id: number };
clientIds.push(r.id);
}
if (clientIds.length === 0) {
// blank template: create a couple of placeholder clients so appointments work if added later
for (const name of ["Cliente Demo", "Cliente Ejemplo"]) {
const r = db
.prepare(`INSERT INTO clients (business_id, name, created_at) VALUES (?, ?, ?) RETURNING id`)
.get(businessId, name, isoOffsetDays(-30)) as { id: number };
clientIds.push(r.id);
}
}
// ---- owner + employee users (UPSERT: preserve existing accounts so tokens survive resets) ----
if (ownerEmail) {
db.prepare(
`INSERT INTO users (business_id, email, password, name, role, avatar_color)
VALUES (?, ?, ?, ?, 'owner', ?)
ON CONFLICT(email) DO UPDATE SET business_id = excluded.business_id, name = excluded.name, role = 'owner'`
).run(businessId, ownerEmail.toLowerCase(), ownerPassword, ownerName ?? "Dueño", ownerAvatarColor);
}
for (let i = 0; i < template.employees.length; i++) {
const e = template.employees[i];
const empEmail = e.name.toLowerCase().replace(/\s+/g, ".") + template.emailDomain;
// re-link existing employee user to the freshly created employee record
db.prepare(
`INSERT INTO users (business_id, email, password, name, role, employee_id, avatar_color)
VALUES (?, ?, ?, ?, 'employee', ?, ?)
ON CONFLICT(email) DO UPDATE SET business_id = excluded.business_id, name = excluded.name, role = 'employee', employee_id = excluded.employee_id, avatar_color = excluded.avatar_color`
).run(businessId, empEmail, "demo1234", e.name, empIds[i], e.color);
}
// ---- appointments + tickets + reviews ----
let pastCount = 0;
let upCount = 0;
const makeAppt = (dayOffset: number, svcIdx: number, status: string, createdBy: number | null) => {
const svc = template.services[svcIdx];
if (!svc) return null;
const empLocalIdx = pick(svc.employees);
const empId = empIds[empLocalIdx];
const svcId = svcIds[svcIdx];
const clientId = pick(clientIds);
const hour = pick([9, 10, 11, 12, 13, 14, 15, 16, 17, 18]);
const minute = pick([0, 30]);
const start = isoOffsetDays(dayOffset, hour, minute);
const end = addMinutes(start, svc.duration_min);
const appt = db
.prepare(
`INSERT INTO appointments
(business_id, service_id, employee_id, client_id, start_at, end_at, status, price, created_by_user_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`
)
.get(businessId, svcId, empId, clientId, start, end, status, svc.price, createdBy, start) as { id: number };
if (status === "completed") {
const tip = rnd() < 0.4 ? pick([20, 50, 50, 100, 100, 150]) : 0;
db.prepare(
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(businessId, appt.id, clientId, empId, svcId, svc.price, tip, pick(PAYMENT_METHODS), end);
if (rnd() < 0.35) {
db.prepare(
`INSERT INTO reviews (appointment_id, client_id, employee_id, rating, comment, created_at)
VALUES (?, ?, ?, ?, ?, ?)`
).run(appt.id, clientId, empId, rint(3, 5), pick(REVIEW_COMMENTS), end);
}
}
return appt.id;
};
// resolve owner user id for created_by
const ownerRow = ownerEmail
? (db.prepare(`SELECT id FROM users WHERE email = ?`).get(ownerEmail.toLowerCase()) as { id: number } | undefined)
: undefined;
if (withHistory) {
for (let dayOffset = -60; dayOffset <= -1; dayOffset++) {
const wd = new Date();
wd.setDate(wd.getDate() + dayOffset);
if (wd.getDay() === 0) continue; // skip Sundays
const n = rint(0, 4);
for (let k = 0; k < n; k++) {
let status = "completed";
const roll = rnd();
if (roll < 0.08) status = "cancelled";
else if (roll < 0.12) status = "no_show";
if (makeAppt(dayOffset, rint(0, template.services.length - 1), status, ownerRow?.id ?? null)) pastCount++;
}
}
}
if (withUpcoming) {
for (let dayOffset = 1; dayOffset <= 14; dayOffset++) {
const n = rint(0, 5);
for (let k = 0; k < n; k++) {
if (makeAppt(dayOffset, rint(0, template.services.length - 1), "scheduled", ownerRow?.id ?? null)) upCount++;
}
}
// a few today
for (let k = 0; k < 4; k++) {
const done = rnd() < 0.4;
if (makeAppt(0, rint(0, template.services.length - 1), done ? "completed" : "scheduled", ownerRow?.id ?? null)) upCount++;
}
}
// recompute ratings
db.exec(`
UPDATE employees SET rating = COALESCE(
(SELECT AVG(r.rating) FROM reviews r WHERE r.employee_id = employees.id),
4.7 + (employees.id % 4) * 0.07
) WHERE business_id = ${businessId};
`);
// backfill commission on tickets generated during seeding (services already have commission_pct)
db.exec(`
UPDATE tickets SET commission = ROUND(tickets.amount * COALESCE(s.commission_pct,0) / 100.0, 2)
FROM services s WHERE s.id = tickets.service_id
AND tickets.business_id = ${businessId}
AND (tickets.commission IS NULL OR tickets.commission = 0)
AND COALESCE(s.commission_pct,0) > 0;
`);
return { employees: empIds.length, services: svcIds.length, clients: clientIds.length, pastCount, upCount };
}
export function ensurePlatformAdmin() {
const exists = db.prepare(`SELECT id FROM users WHERE role = 'admin'`).get();
if (exists) return;
db.prepare(
`INSERT INTO users (business_id, email, password, name, role, avatar_color)
VALUES (NULL, '[email protected]', 'demo1234', 'Administrador', 'admin', '#0f172a')`
).run();
}
// Run directly as a script: ensure admin + default demo business, preserving existing data.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
ensurePlatformAdmin();
// Ensure default demo business exists (don't touch existing data)
let biz = db.prepare(`SELECT id FROM businesses WHERE name = ?`).get("Lumière Estética & Spa") as { id: number } | undefined;
if (!biz) {
biz = db
.prepare(`INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status) VALUES (?, ?, ?, ?, ?, ?, 'trial', 'active') RETURNING id`)
.get("Lumière Estética & Spa", "Estética y Spa", "MXN", "$", "+52 55 1234 5678", "Av. Reforma 245, CDMX") as { id: number };
seedBusiness({ businessId: biz.id, template: getTemplate(DEFAULT_TEMPLATE_KEY)!, ownerEmail: "[email protected]", ownerName: "Daniela Reyes" });
console.log(`[seed] Created default business #${biz.id} with estetica-spa template + admin user.`);
} else {
console.log(`[seed] Default business #${biz.id} already exists — data preserved.`);
}
// expose templates info
const all = db.prepare(`SELECT id, name, industry, plan, status, template FROM businesses`).all();
console.log(`[seed] Businesses: ${JSON.stringify(all)}`);
}
export { TEMPLATES, getTemplate, DEFAULT_TEMPLATE_KEY };
+171
View File
@@ -0,0 +1,171 @@
export type Role = "admin" | "owner" | "employee";
export type AppointmentStatus = "scheduled" | "completed" | "cancelled" | "no_show";
export interface Business {
id: number;
name: string;
industry: string;
currency: string;
currency_symbol: string;
phone: string | null;
address: string | null;
slug?: string | null;
plan?: string;
status?: string;
template?: string | null;
trial_ends_at?: string | null;
created_at?: string;
}
export interface User {
id: number;
business_id: number | null;
email: string;
name: string;
role: Role;
employee_id: number | null;
avatar_color: string;
}
export interface Employee {
id: number;
business_id: number;
name: string;
email: string | null;
phone: string | null;
color: string;
role: string;
active: 0 | 1;
rating: number;
hire_date: string | null;
service_ids?: number[];
stats?: EmployeeStats;
}
export interface EmployeeStats {
appointments_total: number;
appointments_completed: number;
revenue_total: number;
avg_rating: number;
utilization_pct: number;
}
export interface Service {
id: number;
business_id: number;
name: string;
description: string | null;
category: string;
duration_min: number;
price: number;
color: string;
active: 0 | 1;
employee_ids?: number[];
}
export interface Client {
id: number;
business_id: number;
name: string;
email: string | null;
phone: string | null;
notes: string | null;
tags: string | null;
created_at: string;
stats?: ClientStats;
}
export interface ClientStats {
visits: number;
total_spent: number;
last_visit: string | null;
avg_ticket: number;
no_show_count: number;
}
export interface Appointment {
id: number;
business_id: number;
service_id: number;
employee_id: number;
client_id: number;
start_at: string;
end_at: string;
status: AppointmentStatus;
price: number;
notes: string | null;
created_by_user_id: number | null;
created_at: string;
updated_at: string;
// joined fields
service?: Service;
employee?: Pick<Employee, "id" | "name" | "color" | "role">;
client?: Pick<Client, "id" | "name" | "phone" | "email" | "tags">;
}
export interface Ticket {
id: number;
business_id: number;
appointment_id: number | null;
client_id: number;
employee_id: number;
service_id: number;
amount: number;
tip: number;
payment_method: string;
created_at: string;
service?: Pick<Service, "id" | "name" | "category" | "color">;
employee?: Pick<Employee, "id" | "name" | "color">;
client?: Pick<Client, "id" | "name">;
}
export interface DashboardOverview {
revenue_today: number;
revenue_week: number;
revenue_month: number;
revenue_change_pct: number;
appts_today: number;
appts_week: number;
appts_upcoming: number;
active_clients: number;
avg_ticket: number;
cancel_rate: number;
occupancy_pct: number;
}
export interface RankedEmployee {
employee: Pick<Employee, "id" | "name" | "color" | "role">;
appointments: number;
revenue: number;
avg_rating: number;
utilization_pct: number;
}
export interface RankedService {
service: Pick<Service, "id" | "name" | "category" | "color">;
count: number;
revenue: number;
}
export interface RankedClient {
client: Pick<Client, "id" | "name" | "tags" | "phone">;
visits: number;
total_spent: number;
last_visit: string | null;
}
export interface TopTicket {
ticket: Ticket;
}
export interface RevenuePoint {
date: string;
revenue: number;
appts: number;
}
export interface CategorySlice {
category: string;
revenue: number;
count: number;
}
+84
View File
@@ -0,0 +1,84 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, useAuth } from "./lib/auth";
import { LoginPage } from "./pages/LoginPage";
import { AppShell } from "./components/AppShell";
import { AdminShell } from "./components/AdminShell";
import { DashboardPage } from "./pages/DashboardPage";
import { CalendarPage } from "./pages/CalendarPage";
import { EmployeesPage } from "./pages/EmployeesPage";
import { ServicesPage } from "./pages/ServicesPage";
import { ClientsPage } from "./pages/ClientsPage";
import { TicketsPage } from "./pages/TicketsPage";
import { ClientDetailPage } from "./pages/ClientDetailPage";
import { CashPage } from "./pages/CashPage";
import { NotificationsPage } from "./pages/NotificationsPage";
import { SettingsPage } from "./pages/SettingsPage";
import { AdminOverviewPage } from "./pages/admin/AdminOverviewPage";
import { AdminBusinessesPage } from "./pages/admin/AdminBusinessesPage";
import { AdminBusinessDetailPage } from "./pages/admin/AdminBusinessDetailPage";
import BookingPage from "./pages/public/BookingPage";
function Protected() {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-[#f6f7fb]">
<div className="flex flex-col items-center gap-3 text-slate-500">
<div className="h-9 w-9 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
<span className="text-sm font-medium">Cargando AgendaPro</span>
</div>
</div>
);
}
if (!user) return <LoginPage />;
const isAdmin = user.role === "admin";
return (
<Routes>
{/* Admin routes */}
{isAdmin && (
<Route element={<AdminShell />}>
<Route path="/" element={<Navigate to="/admin" replace />} />
<Route path="/admin" element={<AdminOverviewPage />} />
<Route path="/admin/businesses" element={<AdminBusinessesPage />} />
<Route path="/admin/businesses/:id" element={<AdminBusinessDetailPage />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
)}
{/* Business routes */}
{!isAdmin && (
<Route element={<AppShell />}>
<Route path="/" element={<Navigate to={user.role === "owner" ? "/dashboard" : "/calendar"} replace />} />
{user.role === "owner" ? (
<Route path="/dashboard" element={<DashboardPage />} />
) : (
<Route path="/dashboard" element={<Navigate to="/calendar" replace />} />
)}
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/clients" element={<ClientsPage />} />
<Route path="/clients/:id" element={<ClientDetailPage />} />
{user.role === "owner" && <Route path="/employees" element={<EmployeesPage />} />}
{user.role === "owner" && <Route path="/services" element={<ServicesPage />} />}
{user.role === "owner" && <Route path="/tickets" element={<TicketsPage />} />}
{user.role === "owner" && <Route path="/cash" element={<CashPage />} />}
{user.role === "owner" && <Route path="/notifications" element={<NotificationsPage />} />}
{user.role === "owner" && <Route path="/settings" element={<SettingsPage />} />}
{/* Block business users from admin */}
<Route path="/admin/*" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to={user.role === "owner" ? "/dashboard" : "/calendar"} replace />} />
</Route>
)}
</Routes>
);
}
export default function App() {
return (
<Routes>
<Route path="/b/:slug" element={<BookingPage />} />
<Route path="*" element={<AuthProvider><Protected /></AuthProvider>} />
</Routes>
);
}
+141
View File
@@ -0,0 +1,141 @@
import { useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import {
Building2,
LayoutDashboard,
LogOut,
Menu,
X,
ShieldCheck,
Plus,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../lib/auth";
import { initials, cn } from "../lib/format";
interface NavItem {
to: string;
label: string;
icon: typeof Building2;
end?: boolean;
}
const NAV: NavItem[] = [
{ to: "/admin", label: "Resumen", icon: LayoutDashboard, end: true },
{ to: "/admin/businesses", label: "Negocios", icon: Building2 },
];
export function AdminShell() {
const { user, logout } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
if (!user) return null;
const SidebarContent = (
<>
<div className="flex items-center gap-2.5 px-2 py-1">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-slate-800 to-slate-950 text-white shadow-soft">
<ShieldCheck className="h-5 w-5" />
</div>
<div className="leading-tight">
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaPro</div>
<div className="text-[11px] font-medium text-slate-500">Consola Admin</div>
</div>
</div>
<nav className="mt-5 flex-1 space-y-1 px-2">
{NAV.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
cn(
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-semibold transition-colors",
isActive ? "bg-slate-900 text-white" : "text-slate-600 hover:bg-slate-100 hover:text-slate-900"
)
}
>
<item.icon className="h-[18px] w-[18px]" />
{item.label}
</NavLink>
))}
<button
onClick={() => {
navigate("/admin/businesses?new=1");
setMobileOpen(false);
}}
className="group mt-2 flex w-full items-center gap-3 rounded-xl border border-dashed border-slate-300 px-3 py-2.5 text-sm font-semibold text-slate-500 transition-colors hover:border-brand-400 hover:bg-brand-50 hover:text-brand-700"
>
<Plus className="h-[18px] w-[18px]" />
Nuevo negocio
</button>
</nav>
<div className="mt-auto px-2 pb-3">
<div className="rounded-xl bg-amber-50 px-3 py-2 text-[11px] font-medium text-amber-700">
Modo administrador de plataforma. Puedes crear y gestionar todos los negocios.
</div>
<div className="mt-2 flex items-center gap-3 rounded-xl p-2">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-900 text-xs font-bold text-white">
{initials(user.name)}
</div>
<div className="min-w-0 flex-1 leading-tight">
<div className="truncate text-sm font-bold text-slate-900">{user.name}</div>
<div className="text-[11px] font-medium text-slate-500">Admin · {user.email}</div>
</div>
<button
onClick={logout}
title="Cerrar sesión"
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
>
<LogOut className="h-[18px] w-[18px]" />
</button>
</div>
</div>
</>
);
return (
<div className="flex h-screen overflow-hidden bg-[#f6f7fb]">
<aside className="hidden w-64 shrink-0 flex-col border-r border-slate-200 bg-white py-4 lg:flex">
{SidebarContent}
</aside>
{mobileOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm animate-fade-in" onClick={() => setMobileOpen(false)} />
<aside className="absolute left-0 top-0 flex h-full w-72 flex-col bg-white py-4 shadow-2xl animate-slide-up">
<button
onClick={() => setMobileOpen(false)}
className="absolute right-3 top-4 rounded-lg p-1.5 text-slate-400 hover:bg-slate-100"
>
<X className="h-5 w-5" />
</button>
{SidebarContent}
</aside>
</div>
)}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-3 border-b border-slate-200 bg-white/80 px-4 py-3 backdrop-blur lg:hidden">
<button onClick={() => setMobileOpen(true)} className="rounded-lg p-2 text-slate-600 hover:bg-slate-100">
<Menu className="h-5 w-5" />
</button>
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-slate-900 text-white">
<ShieldCheck className="h-4 w-4" />
</div>
<span className="text-sm font-extrabold">Admin</span>
</div>
</header>
<main className="flex-1 overflow-y-auto">
<Outlet key={location.pathname} />
</main>
</div>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
import { useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import {
CalendarDays,
LayoutDashboard,
Users,
Scissors,
Ticket as TicketIcon,
UserCircle,
LogOut,
Menu,
X,
Sparkles,
Wallet,
Bell,
Settings,
} from "lucide-react";
import { useAuth } from "../lib/auth";
import { initials, cn } from "../lib/format";
import { DemoSwitcher } from "./DemoSwitcher";
interface NavItem {
to: string;
label: string;
icon: typeof CalendarDays;
ownerOnly?: boolean;
}
const NAV: NavItem[] = [
{ to: "/dashboard", label: "Tablero", icon: LayoutDashboard, ownerOnly: true },
{ to: "/calendar", label: "Calendario", icon: CalendarDays },
{ to: "/clients", label: "Clientes", icon: Users },
{ to: "/employees", label: "Empleados", icon: UserCircle, ownerOnly: true },
{ to: "/services", label: "Servicios", icon: Scissors, ownerOnly: true },
{ to: "/cash", label: "Caja", icon: Wallet, ownerOnly: true },
{ to: "/tickets", label: "Tickets", icon: TicketIcon, ownerOnly: true },
{ to: "/notifications", label: "Recordatorios", icon: Bell, ownerOnly: true },
{ to: "/settings", label: "Configuración", icon: Settings, ownerOnly: true },
];
export function AppShell() {
const { user, business, logout } = useAuth();
const location = useLocation();
const [mobileOpen, setMobileOpen] = useState(false);
if (!user) return null;
const isOwner = user.role === "owner";
const items = NAV.filter((n) => !n.ownerOnly || isOwner);
const SidebarContent = (
<>
<div className="flex items-center gap-2.5 px-2 py-1">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-soft">
<Sparkles className="h-5 w-5" />
</div>
<div className="leading-tight">
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaPro</div>
<div className="text-[11px] font-medium text-slate-500">{business?.name ?? "Tu negocio"}</div>
</div>
</div>
<nav className="mt-5 flex-1 space-y-1 px-2">
{items.map((item) => (
<NavLink
key={item.to}
to={item.to}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
cn(
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-semibold transition-colors",
isActive
? "bg-brand-50 text-brand-700"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900"
)
}
>
{({ isActive }) => (
<>
<item.icon
className={cn("h-[18px] w-[18px]", isActive ? "text-brand-600" : "text-slate-400 group-hover:text-slate-600")}
/>
{item.label}
</>
)}
</NavLink>
))}
</nav>
<div className="mt-auto space-y-2 px-2 pb-3">
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-2.5">
<DemoSwitcher />
</div>
<div className="flex items-center gap-3 rounded-xl p-2">
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style={{ background: user.avatar_color }}
>
{initials(user.name)}
</div>
<div className="min-w-0 flex-1 leading-tight">
<div className="truncate text-sm font-bold text-slate-900">{user.name}</div>
<div className="text-[11px] font-medium capitalize text-slate-500">
{isOwner ? "Dueño" : "Empleado"}
</div>
</div>
<button
onClick={logout}
title="Cerrar sesión"
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
>
<LogOut className="h-[18px] w-[18px]" />
</button>
</div>
</div>
</>
);
return (
<div className="flex h-screen overflow-hidden bg-[#f6f7fb]">
{/* Desktop sidebar */}
<aside className="hidden w-64 shrink-0 flex-col border-r border-slate-200 bg-white py-4 lg:flex">
{SidebarContent}
</aside>
{/* Mobile sidebar */}
{mobileOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm animate-fade-in" onClick={() => setMobileOpen(false)} />
<aside className="absolute left-0 top-0 flex h-full w-72 flex-col bg-white py-4 shadow-2xl animate-slide-up">
<button
onClick={() => setMobileOpen(false)}
className="absolute right-3 top-4 rounded-lg p-1.5 text-slate-400 hover:bg-slate-100"
>
<X className="h-5 w-5" />
</button>
{SidebarContent}
</aside>
</div>
)}
{/* Main */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-3 border-b border-slate-200 bg-white/80 px-4 py-3 backdrop-blur lg:hidden">
<button
onClick={() => setMobileOpen(true)}
className="rounded-lg p-2 text-slate-600 hover:bg-slate-100"
>
<Menu className="h-5 w-5" />
</button>
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-brand-500 to-brand-700 text-white">
<Sparkles className="h-4 w-4" />
</div>
<span className="text-sm font-extrabold">AgendaPro</span>
</div>
</header>
<main className="flex-1 overflow-y-auto">
<Outlet key={location.pathname} />
</main>
</div>
</div>
);
}
+593
View File
@@ -0,0 +1,593 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Search,
UserPlus,
Check,
Trash2,
XCircle,
CheckCircle2,
Save,
Clock,
DollarSign,
Stethoscope,
} from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "../lib/auth";
import type { Appointment, Service } from "../../shared/types";
import { Modal } from "./Modal";
import { Spinner, Avatar } from "./ui";
import { formatCurrency, statusLabel, statusColor, cn } from "../lib/format";
interface Props {
open: boolean;
onClose: () => void;
appointment: Appointment | null;
draftSlot: { start: string; end: string } | null;
}
const TIME_SUGGESTIONS = [
"08:00","08:30","09:00","09:30","10:00","10:30","11:00","11:30",
"12:00","12:30","13:00","13:30","14:00","14:30","15:00","15:30",
"16:00","16:30","17:00","17:30","18:00","18:30","19:00","19:30","20:00",
];
function toLocalInput(iso: string) {
const d = new Date(iso);
if (isNaN(d.getTime())) return { date: "", time: "10:00" };
const pad = (n: number) => String(n).padStart(2, "0");
return {
date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`,
time: `${pad(d.getHours())}:${pad(d.getMinutes())}`,
};
}
export function AppointmentModal({ open, onClose, appointment, draftSlot }: Props) {
const { user } = useAuth();
const qc = useQueryClient();
const isOwner = user?.role === "owner";
const isEditing = !!appointment;
const [serviceId, setServiceId] = useState<number | null>(null);
const [employeeId, setEmployeeId] = useState<number | null>(user?.employee_id ?? null);
const [clientId, setClientId] = useState<number | null>(null);
const [clientMode, setClientMode] = useState<"existing" | "new">("existing");
const [newClient, setNewClient] = useState({ name: "", phone: "", email: "", notes: "" });
const [search, setSearch] = useState("");
const [dateInput, setDateInput] = useState("");
const [timeInput, setTimeInput] = useState("10:00");
const [duration, setDuration] = useState(60);
const [price, setPrice] = useState(0);
const [notes, setNotes] = useState("");
const [status, setStatus] = useState<string>("scheduled");
const [tip, setTip] = useState(0);
const [payment, setPayment] = useState("card");
const { data: servicesData } = useQuery({ queryKey: ["services"], queryFn: () => api.services.list() });
const { data: employeesData } = useQuery({ queryKey: ["employees"], queryFn: () => api.employees.list() });
const services = servicesData?.services ?? [];
const employees = employeesData?.employees ?? [];
const selectedService = useMemo<Service | undefined>(
() => services.find((s) => s.id === serviceId),
[services, serviceId]
);
// Eligible employees for the selected service
const eligibleEmployees = useMemo(() => {
if (!selectedService) return employees;
const ids = new Set(selectedService.employee_ids ?? []);
return employees.filter((e) => ids.has(e.id) && e.active);
}, [employees, selectedService]);
// Hydrate state when opening
useEffect(() => {
if (!open) return;
if (appointment) {
setServiceId(appointment.service_id);
setEmployeeId(appointment.employee_id);
setClientId(appointment.client_id);
setClientMode("existing");
const d = toLocalInput(appointment.start_at);
setDateInput(d.date);
setTimeInput(d.time);
setDuration(
Math.max(
15,
Math.round(
(new Date(appointment.end_at).getTime() - new Date(appointment.start_at).getTime()) / 60000
)
)
);
setPrice(appointment.price);
setNotes(appointment.notes ?? "");
setStatus(appointment.status);
} else if (draftSlot) {
setServiceId(null);
setEmployeeId(user?.employee_id ?? null);
setClientId(null);
setClientMode("existing");
setNewClient({ name: "", phone: "", email: "", notes: "" });
setSearch("");
const d = toLocalInput(draftSlot.start);
setDateInput(d.date);
setTimeInput(d.time);
setDuration(Math.max(15, Math.round((new Date(draftSlot.end).getTime() - new Date(draftSlot.start).getTime()) / 60000)) || 60);
setPrice(0);
setNotes("");
setStatus("scheduled");
}
setTip(0);
setPayment("card");
}, [open, appointment, draftSlot, user]);
// When service changes, sync duration/price and employee default
useEffect(() => {
if (!selectedService) return;
setDuration(selectedService.duration_min);
setPrice(selectedService.price);
if (!isEditing) {
// For employees creating: keep their employee_id if eligible
if (user?.role === "employee" && user.employee_id && (selectedService.employee_ids ?? []).includes(user.employee_id)) {
setEmployeeId(user.employee_id);
} else if (eligibleEmployees.length > 0 && !eligibleEmployees.find((e) => e.id === employeeId)) {
setEmployeeId(eligibleEmployees[0].id);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedService]);
const { data: searchResults, isFetching: searching } = useQuery({
queryKey: ["clients", "search", search],
queryFn: () => api.clients.list(search),
enabled: search.length > 0 && clientMode === "existing",
});
const startIso = useMemo(() => {
if (!dateInput) return null;
const [h, m] = timeInput.split(":").map(Number);
const d = new Date(`${dateInput}T00:00:00`);
d.setHours(h || 10, m || 0, 0, 0);
return d.toISOString();
}, [dateInput, timeInput]);
const createMut = useMutation({
mutationFn: (payload: any) => api.appointments.create(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["appointments"] });
qc.invalidateQueries({ queryKey: ["clients"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
qc.invalidateQueries({ queryKey: ["employees"] });
onClose();
},
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: any }) => api.appointments.update(id, payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["appointments"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
qc.invalidateQueries({ queryKey: ["employees"] });
onClose();
},
});
const completeMut = useMutation({
mutationFn: ({ id, tip, payment }: { id: number; tip: number; payment: string }) =>
api.appointments.complete(id, { tip, payment_method: payment }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["appointments"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
qc.invalidateQueries({ queryKey: ["employees"] });
qc.invalidateQueries({ queryKey: ["clients"] });
onClose();
},
});
const deleteMut = useMutation({
mutationFn: (id: number) => api.appointments.remove(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["appointments"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
onClose();
},
});
const canSave = !!serviceId && !!dateInput && !!timeInput && (clientId || (clientMode === "new" && newClient.name.trim())) && !!employeeId;
const handleSave = () => {
if (!canSave || !startIso) return;
const payload: any = {
service_id: serviceId,
employee_id: employeeId,
duration_min: duration,
price_override: price,
start_at: startIso,
notes: notes || undefined,
status,
};
if (clientMode === "existing") payload.client_id = clientId;
else payload.new_client = newClient;
if (isEditing && appointment) {
updateMut.mutate({ id: appointment.id, payload });
} else {
createMut.mutate(payload);
}
};
const busy = createMut.isPending || updateMut.isPending || completeMut.isPending || deleteMut.isPending;
const errorMsg = (createMut.error || updateMut.error || completeMut.error || deleteMut.error) as any;
const st = statusColor(status);
return (
<Modal
open={open}
onClose={onClose}
title={isEditing ? "Editar cita" : "Nueva cita"}
subtitle={
isEditing
? appointment
? `${appointment.client?.name} · ${appointment.service?.name}`
: undefined
: "Completa los datos para agendar"
}
size="lg"
>
<div className="space-y-5">
{/* Service + Employee */}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="label flex items-center gap-1.5">
<Stethoscope className="h-3.5 w-3.5 text-slate-400" /> Servicio
</label>
<select
className="select"
value={serviceId ?? ""}
onChange={(e) => setServiceId(e.target.value ? Number(e.target.value) : null)}
>
<option value="">Selecciona un servicio</option>
{[...services]
.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name))
.map((s) => (
<option key={s.id} value={s.id} disabled={!s.active}>
{s.category} · {s.name} {formatCurrency(s.price)} ({s.duration_min}min)
</option>
))}
</select>
{selectedService && (
<div className="mt-1.5 flex items-center gap-2 text-xs text-slate-500">
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-semibold text-white"
style={{ background: selectedService.color }}
>
{selectedService.category}
</span>
<span>{selectedService.duration_min} min · {formatCurrency(selectedService.price)}</span>
</div>
)}
</div>
<div>
<label className="label flex items-center gap-1.5">
<UserPlus className="h-3.5 w-3.5 text-slate-400" /> Especialista
</label>
<select
className="select"
value={employeeId ?? ""}
onChange={(e) => setEmployeeId(Number(e.target.value))}
>
<option value="">Sin asignar</option>
{(selectedService && (selectedService.employee_ids ?? []).length > 0 ? eligibleEmployees : employees).map(
(e) => (
<option key={e.id} value={e.id} disabled={!e.active}>
{e.name} {e.role}
{!isOwner && e.id === user?.employee_id ? " (tú)" : ""}
</option>
)
)}
</select>
{!isOwner && (
<p className="mt-1.5 text-xs text-brand-600">
La cita se asigna automáticamente a ti.
</p>
)}
</div>
</div>
{/* Client */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="label mb-0">Cliente</label>
<div className="flex rounded-lg border border-slate-200 p-0.5">
<button
onClick={() => setClientMode("existing")}
className={cn(
"rounded-md px-2.5 py-1 text-xs font-semibold transition-colors",
clientMode === "existing" ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
)}
>
Existente
</button>
<button
onClick={() => setClientMode("new")}
className={cn(
"rounded-md px-2.5 py-1 text-xs font-semibold transition-colors",
clientMode === "new" ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
)}
>
<UserPlus className="mr-1 inline h-3 w-3" /> Nuevo
</button>
</div>
</div>
{clientMode === "existing" ? (
<div>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
className="input pl-9"
placeholder="Buscar por nombre, teléfono o correo…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setClientId(null);
}}
/>
{searching && <Spinner className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" />}
</div>
{clientId && (
<div className="mt-2 flex items-center gap-2 rounded-xl bg-brand-50 px-3 py-2">
<Avatar name={searchResults?.clients.find((c) => c.id === clientId)?.name ?? "?"} size={28} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-slate-800">
{searchResults?.clients.find((c) => c.id === clientId)?.name}
</div>
</div>
<Check className="h-4 w-4 text-brand-600" />
</div>
)}
{!clientId && search && searchResults && (
<div className="mt-2 max-h-48 overflow-y-auto rounded-xl border border-slate-100">
{searchResults.clients.length === 0 ? (
<div className="px-3 py-4 text-center text-sm text-slate-500">
Sin resultados.{" "}
<button onClick={() => setClientMode("new")} className="font-semibold text-brand-600">
Crear nuevo cliente
</button>
</div>
) : (
searchResults.clients.slice(0, 12).map((c) => (
<button
key={c.id}
onClick={() => {
setClientId(c.id);
setSearch(c.name);
}}
className="flex w-full items-center gap-2.5 border-b border-slate-50 px-3 py-2 text-left transition-colors last:border-0 hover:bg-slate-50"
>
<Avatar name={c.name} size={28} color="#94a3b8" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-slate-800">{c.name}</div>
<div className="truncate text-[11px] text-slate-500">
{c.phone ?? c.email ?? "Sin contacto"}
</div>
</div>
{c.tags && (
<span className="chip bg-slate-100 text-slate-600">{c.tags.split(",")[0]}</span>
)}
</button>
))
)}
</div>
)}
{!clientId && !search && (
<p className="mt-1 text-xs text-slate-400">Escribe para buscar un cliente existente.</p>
)}
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="label">Nombre completo *</label>
<input
className="input"
value={newClient.name}
onChange={(e) => setNewClient({ ...newClient, name: e.target.value })}
placeholder="Nombre del cliente"
/>
</div>
<div>
<label className="label">Teléfono</label>
<input
className="input"
value={newClient.phone}
onChange={(e) => setNewClient({ ...newClient, phone: e.target.value })}
placeholder="+52 …"
/>
</div>
<div>
<label className="label">Correo</label>
<input
className="input"
value={newClient.email}
onChange={(e) => setNewClient({ ...newClient, email: e.target.value })}
placeholder="[email protected]"
/>
</div>
</div>
)}
</div>
{/* Date / Time / Duration / Price */}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="label flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-slate-400" /> Fecha
</label>
<input type="date" className="input" value={dateInput} onChange={(e) => setDateInput(e.target.value)} />
</div>
<div>
<label className="label">Hora</label>
<select className="select" value={timeInput} onChange={(e) => setTimeInput(e.target.value)}>
{TIME_SUGGESTIONS.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div>
<label className="label">Duración (min)</label>
<div className="flex flex-wrap gap-1.5">
{[30, 45, 60, 75, 90, 120, 150, 180].map((d) => (
<button
key={d}
type="button"
onClick={() => setDuration(d)}
className={cn(
"rounded-lg border px-2.5 py-1 text-xs font-semibold transition-colors",
duration === d
? "border-brand-500 bg-brand-50 text-brand-700"
: "border-slate-200 text-slate-600 hover:bg-slate-50"
)}
>
{d}
</button>
))}
</div>
</div>
<div>
<label className="label flex items-center gap-1.5">
<DollarSign className="h-3.5 w-3.5 text-slate-400" /> Precio
</label>
<input
type="number"
min={0}
step={10}
className="input"
value={price}
onChange={(e) => setPrice(Number(e.target.value))}
/>
</div>
</div>
{/* Notes */}
<div>
<label className="label">Notas internas</label>
<textarea
className="textarea"
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Preferencias, indicaciones del servicio…"
/>
</div>
{/* Status (edit only) */}
{isEditing && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold text-slate-500">Estado:</span>
<span className="chip" style={{ background: st.bg, color: st.fg }}>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: st.dot }} />
{statusLabel(status)}
</span>
<select
className="select ml-auto w-auto"
value={status}
onChange={(e) => setStatus(e.target.value)}
disabled={appointment?.status === "completed"}
>
<option value="scheduled">Programada</option>
<option value="completed">Completada</option>
<option value="cancelled">Cancelada</option>
<option value="no_show">No asistió</option>
</select>
</div>
)}
{/* Complete form (if editing and not completed) */}
{isEditing && appointment?.status !== "completed" && (
<div className="rounded-xl border border-emerald-100 bg-emerald-50/50 p-3">
<div className="flex items-center gap-2 text-xs font-bold text-emerald-700">
<CheckCircle2 className="h-4 w-4" /> Marcar como completada y cobrar
</div>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<div>
<label className="label">Propina</label>
<input
type="number"
min={0}
step={10}
className="input"
value={tip}
onChange={(e) => setTip(Number(e.target.value))}
/>
</div>
<div>
<label className="label">Método de pago</label>
<select className="select" value={payment} onChange={(e) => setPayment(e.target.value)}>
<option value="card">Tarjeta</option>
<option value="cash">Efectivo</option>
<option value="transfer">Transferencia</option>
</select>
</div>
</div>
</div>
)}
{errorMsg && (
<div className="rounded-lg bg-rose-50 px-3 py-2 text-sm font-medium text-rose-700">
{errorMsg.message}
</div>
)}
{/* Footer actions */}
<div className="sticky bottom-0 -mx-5 -mb-4 flex flex-wrap items-center gap-2 border-t border-slate-100 bg-white px-5 py-3">
{isEditing && (
<>
<button
onClick={() => deleteMut.mutate(appointment!.id)}
className="btn btn-danger"
disabled={busy}
title="Eliminar cita"
>
<Trash2 className="h-4 w-4" />
</button>
{appointment?.status !== "completed" && (
<button
onClick={() =>
completeMut.mutate({ id: appointment!.id, tip, payment })
}
className="btn btn-secondary"
style={{ background: "#ecfdf5", color: "#047857", borderColor: "#a7f3d0" }}
disabled={busy}
>
<CheckCircle2 className="h-4 w-4" /> Completar
</button>
)}
{appointment?.status !== "cancelled" && appointment?.status !== "completed" && (
<button
onClick={() => {
setStatus("cancelled");
updateMut.mutate({ id: appointment!.id, payload: { status: "cancelled" } });
}}
className="btn btn-ghost"
disabled={busy}
>
<XCircle className="h-4 w-4 text-rose-500" /> Cancelar cita
</button>
)}
</>
)}
<div className="ml-auto flex items-center gap-2">
<button onClick={onClose} className="btn btn-ghost" disabled={busy}>
Cerrar
</button>
<button onClick={handleSave} className="btn btn-primary" disabled={busy || !canSave}>
{busy ? <Spinner /> : <Save className="h-4 w-4" />}
{isEditing ? "Guardar cambios" : "Crear cita"}
</button>
</div>
</div>
</div>
</Modal>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import { ChevronDown, UserCog, Check } from "lucide-react";
import { useAuth } from "../lib/auth";
import { api } from "../lib/api";
export function DemoSwitcher() {
const { user, switchUser } = useAuth();
const [open, setOpen] = useState(false);
const [users, setUsers] = useState<{ email: string; name: string; role: string; avatar_color: string }[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
api.auth.demoUsers().then((r) => setUsers(r.users)).catch(() => {});
}, []);
if (!user) return null;
const pick = async (email: string) => {
setOpen(false);
setLoading(true);
try {
await switchUser(0, email);
} catch {
/* ignore */
} finally {
setLoading(false);
}
};
return (
<div className="relative">
<button
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-100"
disabled={loading}
>
<UserCog className="h-3.5 w-3.5 text-slate-400" />
<span className="flex-1 text-left">Ver como</span>
<ChevronDown className={`h-3.5 w-3.5 text-slate-400 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<>
<div className="fixed inset-0 z-30" onClick={() => setOpen(false)} />
<div className="absolute bottom-full left-0 z-40 mb-1 max-h-72 w-56 overflow-y-auto rounded-xl border border-slate-200 bg-white p-1.5 shadow-card animate-scale-in">
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400">
Cambiar cuenta
</div>
{users.map((u) => (
<button
key={u.email}
onClick={() => pick(u.email)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-slate-50"
>
<div
className="flex h-6 w-6 items-center justify-center rounded-full text-[9px] font-bold text-white"
style={{ background: u.avatar_color }}
>
{u.name.split(" ").map((p) => p[0]).slice(0, 2).join("")}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-bold text-slate-800">{u.name}</div>
<div className="text-[10px] capitalize text-slate-500">
{u.role === "owner" ? "Dueño" : "Empleado"}
</div>
</div>
{u.email === user.email && <Check className="h-3.5 w-3.5 text-brand-500" />}
</button>
))}
</div>
</>
)}
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { cn } from "../lib/format";
interface ModalProps {
open: boolean;
onClose: () => void;
title?: string;
subtitle?: string;
children: React.ReactNode;
size?: "sm" | "md" | "lg";
footer?: React.ReactNode;
}
export function Modal({ open, onClose, title, subtitle, children, size = "md", footer }: ModalProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, onClose]);
if (!open) return null;
const maxW = size === "sm" ? "max-w-md" : size === "lg" ? "max-w-3xl" : "max-w-xl";
return createPortal(
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
<div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm animate-fade-in" onClick={onClose} />
<div
className={cn(
"relative z-10 flex max-h-[92vh] w-full flex-col overflow-hidden rounded-t-2xl bg-white shadow-2xl animate-slide-up sm:rounded-2xl",
maxW
)}
>
{(title || subtitle) && (
<div className="flex items-start justify-between gap-3 border-b border-slate-100 px-5 py-4">
<div className="min-w-0">
{title && <h2 className="text-lg font-bold text-slate-900">{title}</h2>}
{subtitle && <p className="mt-0.5 text-sm text-slate-500">{subtitle}</p>}
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
>
<X className="h-5 w-5" />
</button>
</div>
)}
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
{footer && <div className="border-t border-slate-100 bg-slate-50/60 px-5 py-3">{footer}</div>}
</div>
</div>,
document.body
);
}
@@ -0,0 +1,239 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Building2, UserPlus, Mail, Lock, Sparkles, Check, Wand2, ChevronRight } from "lucide-react";
import { api } from "../../lib/api";
import { Modal } from "../Modal";
import { Spinner } from "../ui";
import { cn } from "../../lib/format";
interface Props {
open: boolean;
onClose: () => void;
onCreated?: () => void;
}
const PLAN_OPTIONS = [
{ value: "trial", label: "Prueba", desc: "14 días" },
{ value: "pro", label: "Pro", desc: "Suscripción activa" },
{ value: "free", label: "Free", desc: "Plan gratuito" },
];
export function CreateBusinessModal({ open, onClose, onCreated }: Props) {
const navigate = useNavigate();
const { data: tplData } = useQuery({ queryKey: ["admin", "templates"], queryFn: () => api.admin.templates(), enabled: open });
const templates = tplData?.templates ?? [];
const [step, setStep] = useState<1 | 2>(1);
const [template, setTemplate] = useState("estetica-spa");
const [name, setName] = useState("");
const [industry, setIndustry] = useState("");
const [ownerName, setOwnerName] = useState("");
const [ownerEmail, setOwnerEmail] = useState("");
const [ownerPassword, setOwnerPassword] = useState("demo1234");
const [plan, setPlan] = useState("trial");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setStep(1);
setError(null);
setName("");
setIndustry("");
setOwnerName("");
setOwnerEmail("");
setOwnerPassword("demo1234");
setPlan("trial");
setTemplate("estetica-spa");
}, [open]);
// Auto-fill name/industry from selected template
useEffect(() => {
if (!open) return;
const t = templates.find((x) => x.key === template);
if (t && !name) setName(t.name === "Nuevo Negocio" ? "" : t.name);
if (t && !industry) setIndustry(t.industry);
}, [template, templates, open]); // eslint-disable-line react-hooks/exhaustive-deps
const mut = useMutation({
mutationFn: () =>
api.admin.createBusiness({
name,
industry: industry || undefined,
template,
ownerName: ownerName || "Dueño",
ownerEmail,
ownerPassword,
plan,
}),
onSuccess: (data: any) => {
onCreated?.();
onClose();
navigate(`/admin/businesses/${data.business.id}`);
},
onError: (e: any) => setError(e.message),
});
const selected = templates.find((t: any) => t.key === template);
const canContinue = !!template;
const canCreate = !!name.trim() && !!ownerEmail.trim() && !!ownerPassword.trim();
return (
<Modal
open={open}
onClose={onClose}
title="Crear nuevo negocio"
subtitle={step === 1 ? "Paso 1 de 2 — Elige una plantilla" : "Paso 2 de 2 — Datos del negocio y dueño"}
size="lg"
footer={
<div className="flex items-center justify-between gap-2">
<div className="text-xs text-slate-500">
{step === 1 ? (
<span className="inline-flex items-center gap-1"><Sparkles className="h-3.5 w-3.5 text-brand-500" /> La plantilla carga datos demo listos para usar.</span>
) : (
<span>Se creará el negocio y la cuenta del dueño.</span>
)}
</div>
<div className="flex items-center gap-2">
{step === 2 && (
<button onClick={() => setStep(1)} className="btn btn-ghost">Atrás</button>
)}
{step === 1 ? (
<button onClick={() => canContinue && setStep(2)} className="btn btn-primary" disabled={!canContinue}>
Continuar <ChevronRight className="h-4 w-4" />
</button>
) : (
<button onClick={() => mut.mutate()} className="btn btn-primary" disabled={!canCreate || mut.isPending}>
{mut.isPending ? <Spinner /> : <Wand2 className="h-4 w-4" />} Crear negocio
</button>
)}
</div>
</div>
}
>
{error && (
<div className="mb-4 rounded-lg bg-rose-50 px-3 py-2 text-sm font-medium text-rose-700">{error}</div>
)}
{step === 1 ? (
<div>
<label className="label">Plantilla de demostración</label>
{!templates.length ? (
<div className="flex h-32 items-center justify-center"><Spinner /></div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{templates.map((t: any) => {
const on = template === t.key;
return (
<button
key={t.key}
type="button"
onClick={() => setTemplate(t.key)}
className={cn(
"relative flex flex-col gap-1 rounded-xl border-2 p-3 text-left transition-all",
on ? "border-brand-500 bg-brand-50/50 shadow-soft" : "border-slate-200 hover:border-slate-300 hover:bg-slate-50"
)}
>
{on && (
<span className="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 text-white">
<Check className="h-3 w-3" />
</span>
)}
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-white text-brand-600 shadow-soft">
<Building2 className="h-4 w-4" />
</div>
<span className="font-bold text-slate-900">{t.name}</span>
</div>
<p className="text-[11px] text-slate-500">{t.description}</p>
<div className="mt-1 flex gap-2 text-[10px] font-semibold text-slate-400">
<span>{t.employees} esp.</span>
<span>·</span>
<span>{t.services} serv.</span>
<span>·</span>
<span>{t.clients} clientes</span>
</div>
</button>
);
})}
</div>
)}
<p className="mt-3 text-[11px] text-slate-400">
Las plantillas crean empleados, servicios y clientes demo, además de citas pasadas y próximas para que el panel tenga datos desde el minuto uno.
<strong> Blank</strong> crea un negocio vacío para configurar desde cero.
</p>
</div>
) : (
<div className="space-y-4">
{selected && (
<div className="flex items-center gap-2 rounded-xl bg-brand-50 px-3 py-2 text-xs">
<Sparkles className="h-3.5 w-3.5 text-brand-500" />
<span className="text-brand-700">
Plantilla: <strong>{selected.name}</strong> {selected.employees} empleados, {selected.services} servicios, {selected.clients} clientes
</span>
<button onClick={() => setStep(1)} className="ml-auto font-semibold text-brand-600 hover:underline">cambiar</button>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="label">Nombre del negocio *</label>
<div className="relative">
<Building2 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input className="input pl-9" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ej. Lumière Estética & Spa" />
</div>
</div>
<div>
<label className="label">Industria / Rubro</label>
<input className="input" value={industry} onChange={(e) => setIndustry(e.target.value)} placeholder="Estética, Barbería, Dental…" />
</div>
<div>
<label className="label">Plan</label>
<div className="flex gap-1.5">
{PLAN_OPTIONS.map((p) => (
<button
key={p.value}
type="button"
onClick={() => setPlan(p.value)}
className={cn(
"flex-1 rounded-lg border px-2 py-1.5 text-xs font-semibold transition-colors",
plan === p.value ? "border-brand-500 bg-brand-50 text-brand-700" : "border-slate-200 text-slate-600 hover:bg-slate-50"
)}
>
{p.label}
<span className="block text-[9px] font-normal text-slate-400">{p.desc}</span>
</button>
))}
</div>
</div>
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50/50 p-3">
<div className="mb-2 flex items-center gap-1.5 text-xs font-bold text-slate-700">
<UserPlus className="h-3.5 w-3.5" /> Cuenta del dueño
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label">Nombre del dueño</label>
<input className="input" value={ownerName} onChange={(e) => setOwnerName(e.target.value)} placeholder="Dueño" />
</div>
<div>
<label className="label">Correo *</label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input className="input pl-9" type="email" value={ownerEmail} onChange={(e) => setOwnerEmail(e.target.value)} placeholder="[email protected]" />
</div>
</div>
<div>
<label className="label">Contraseña</label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input className="input pl-9" value={ownerPassword} onChange={(e) => setOwnerPassword(e.target.value)} placeholder="demo1234" />
</div>
</div>
</div>
</div>
</div>
)}
</Modal>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { Loader2 } from "lucide-react";
export function Spinner({ className = "" }: { className?: string }) {
return <Loader2 className={`h-4 w-4 animate-spin ${className}`} />;
}
export function EmptyState({
icon: Icon,
title,
description,
action,
}: {
icon?: React.ComponentType<{ className?: string }>;
title: string;
description?: string;
action?: React.ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
{Icon && (
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<Icon className="h-6 w-6" />
</div>
)}
<div>
<div className="text-sm font-bold text-slate-900">{title}</div>
{description && <p className="mt-1 text-sm text-slate-500">{description}</p>}
</div>
{action}
</div>
);
}
export function PageHeader({
title,
subtitle,
actions,
}: {
title: string;
subtitle?: string;
actions?: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-3 px-5 pt-5 sm:px-7 sm:pt-7 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-xl font-extrabold tracking-tight text-slate-900 sm:text-2xl">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-slate-500">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
);
}
export function Badge({
children,
bg,
fg,
dot,
}: {
children: React.ReactNode;
bg?: string;
fg?: string;
dot?: string;
}) {
return (
<span className="chip" style={{ background: bg ?? "#f3f4f6", color: fg ?? "#374151" }}>
{dot && <span className="h-1.5 w-1.5 rounded-full" style={{ background: dot }} />}
{children}
</span>
);
}
export function Avatar({
name,
color,
size = 36,
}: {
name: string;
color?: string;
size?: number;
}) {
const init = name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase())
.join("");
return (
<div
className="flex shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style={{ background: color ?? "#3b66ff", width: size, height: size, fontSize: size * 0.35 }}
>
{init}
</div>
);
}
+296
View File
@@ -0,0 +1,296 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
}
* {
-webkit-tap-highlight-color: transparent;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
background: #f6f7fb;
color: #0f172a;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
background-clip: padding-box;
}
/* FullCalendar overrides */
.fc {
--fc-border-color: #eef0f4;
--fc-page-bg-color: #ffffff;
--fc-neutral-bg-color: #fafbfd;
--fc-today-bg-color: #eef4ff;
--fc-now-indicator-color: #f17616;
--fc-button-bg-color: #ffffff;
--fc-button-border-color: #e5e7eb;
--fc-button-text-color: #374151;
--fc-button-hover-bg-color: #f9fafb;
--fc-button-hover-border-color: #d1d5db;
--fc-button-active-bg-color: #3b66ff;
--fc-button-active-border-color: #3b66ff;
font-family: Inter, system-ui, sans-serif;
}
.fc .fc-toolbar-title {
font-size: 1.15rem;
font-weight: 700;
color: #0f172a;
}
.fc .fc-toolbar.fc-header-toolbar {
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
}
.fc .fc-toolbar-chunk {
display: flex;
}
.fc .fc-toolbar-chunk .fc-button-group {
flex-wrap: wrap;
}
@media (max-width: 640px) {
.fc .fc-toolbar-title {
font-size: 1rem;
width: 100%;
text-align: left;
order: -1;
}
.fc .fc-toolbar-chunk {
flex: 1 1 auto;
}
.fc .fc-toolbar-chunk:last-child {
margin-left: auto;
}
.fc .fc-button {
padding: 0.35rem 0.55rem;
font-size: 0.72rem;
}
}
.fc .fc-button {
border-radius: 0.6rem;
padding: 0.4rem 0.75rem;
font-weight: 600;
font-size: 0.8rem;
text-transform: capitalize;
box-shadow: none !important;
}
.fc .fc-button:not(:disabled):hover {
background: #f3f4f6;
}
.fc .fc-button-primary:not(:disabled).fc-button-active {
color: #ffffff;
}
.fc .fc-button:disabled {
opacity: 0.5;
}
.fc .fc-col-header-cell-cushion {
padding: 0.6rem 0;
font-weight: 600;
font-size: 0.72rem;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #6b7280;
}
.fc .fc-daygrid-day-number,
.fc .fc-col-header-cell-cushion {
text-decoration: none;
}
.fc .fc-daygrid-day {
transition: background 0.12s;
}
.fc .fc-daygrid-day:hover {
background: #fafbfd;
}
.fc-event {
border: none !important;
border-radius: 0.45rem !important;
padding: 2px 5px !important;
font-size: 0.74rem !important;
font-weight: 600 !important;
cursor: pointer;
transition: transform 0.12s, box-shadow 0.12s;
}
.fc-event:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.25);
}
.fc-daygrid-event {
margin-top: 2px;
}
.fc .fc-timegrid-slot {
height: 2.4rem;
}
.fc-timegrid-event {
overflow: hidden;
}
/* List view (great on mobile) */
.fc .fc-list {
border: none;
}
.fc .fc-list-day-cushion {
background: #fafbfd;
font-weight: 700;
color: #0f172a;
padding: 0.6rem 0.9rem;
}
.fc .fc-list-event-time {
font-weight: 600;
color: #4b5563;
font-size: 0.78rem;
}
.fc .fc-list-event-title {
font-weight: 600;
color: #0f172a;
}
.fc .fc-list-event:hover td {
background: #f9fafb;
}
.fc .fc-list-empty {
background: transparent;
color: #94a3b8;
padding: 2.5rem 1rem;
font-weight: 500;
}
/* Mobile: prevent squished week/month columns by giving them a min-width.
The card wrapper has overflow-x-auto, so wider grids scroll horizontally. */
@media (max-width: 640px) {
.fc-timeGridWeek-view .fc-scrollgrid-section > table,
.fc-timeGridWeek-view .fc-scrollgrid {
min-width: 640px;
}
.fc-dayGridMonth-view .fc-scrollgrid-section > table,
.fc-dayGridMonth-view .fc-scrollgrid {
min-width: 560px;
}
/* bigger touch targets for events */
.fc-event {
min-height: 28px;
padding: 3px 6px !important;
}
.fc .fc-button {
padding: 0.4rem 0.6rem;
}
}
/* Utility classes */
.card {
background: #ffffff;
border: 1px solid #eef0f4;
border-radius: 1rem;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
font-weight: 600;
border-radius: 0.65rem;
padding: 0.55rem 0.95rem;
font-size: 0.875rem;
transition: all 0.15s;
cursor: pointer;
border: 1px solid transparent;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #3b66ff;
color: #ffffff;
}
.btn-primary:hover:not(:disabled) {
background: #2447f5;
}
.btn-secondary {
background: #ffffff;
color: #374151;
border-color: #e5e7eb;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
border-color: #d1d5db;
}
.btn-ghost {
background: transparent;
color: #4b5563;
}
.btn-ghost:hover:not(:disabled) {
background: #f3f4f6;
}
.btn-danger {
background: #fee2e2;
color: #b91c1c;
}
.btn-danger:hover:not(:disabled) {
background: #fecaca;
}
.input,
.select,
.textarea {
width: 100%;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.65rem;
padding: 0.55rem 0.75rem;
font-size: 0.875rem;
color: #0f172a;
transition: border-color 0.15s, box-shadow 0.15s;
outline: none;
}
.input:focus,
.select:focus,
.textarea:focus {
border-color: #3b66ff;
box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
}
.label {
display: block;
font-size: 0.78rem;
font-weight: 600;
color: #4b5563;
margin-bottom: 0.3rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.55rem;
border-radius: 9999px;
font-size: 0.72rem;
font-weight: 600;
}
+159
View File
@@ -0,0 +1,159 @@
import type {
Appointment,
Business,
Client,
DashboardOverview,
Employee,
RankedClient,
RankedEmployee,
RankedService,
RevenuePoint,
Service,
Ticket,
User,
CategorySlice,
} from "../../shared/types";
const BASE = "/api";
let token: string | null = localStorage.getItem("ap_token");
export function setToken(t: string | null) {
token = t;
if (t) localStorage.setItem("ap_token", t);
else localStorage.removeItem("ap_token");
}
export function getToken() {
return token;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(init?.headers as Record<string, string>),
};
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetch(`${BASE}${path}`, { ...init, headers });
if (!res.ok) {
let message = `Error ${res.status}`;
try {
const body = await res.json();
message = body.error || message;
} catch {
/* ignore */
}
const e = new Error(message) as Error & { status?: number };
e.status = res.status;
throw e;
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const api = {
auth: {
login: (email: string, password: string) =>
request<{ token: string; user: User }>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
}),
demoUsers: () => request<{ users: any[] }>("/auth/demo-users"),
},
admin: {
overview: () => request<any>("/admin/overview"),
templates: () => request<{ templates: any[] }>("/admin/templates"),
businesses: () => request<{ businesses: any[] }>("/admin/businesses"),
business: (id: number) => request<{ business: Business; stats: any }>(`/admin/businesses/${id}`),
createBusiness: (data: any) =>
request<any>("/admin/businesses", { method: "POST", body: JSON.stringify(data) }),
updateBusiness: (id: number, data: any) =>
request<any>(`/admin/businesses/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteBusiness: (id: number) => request<{ ok: boolean }>(`/admin/businesses/${id}`, { method: "DELETE" }),
seedTemplate: (id: number, data: any) =>
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) }),
},
business: {
get: () => request<{ business: Business }>("/business"),
},
employees: {
list: () => request<{ employees: Employee[] }>("/employees"),
create: (data: any) => request<{ employee: Employee }>("/employees", { method: "POST", body: JSON.stringify(data) }),
update: (id: number, data: any) =>
request<{ employee: Employee }>(`/employees/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
remove: (id: number) => request<{ ok: boolean }>(`/employees/${id}`, { method: "DELETE" }),
},
services: {
list: (params?: { employee_id?: number; active?: boolean }) => {
const q = new URLSearchParams();
if (params?.employee_id) q.set("employee_id", String(params.employee_id));
if (params?.active) q.set("active", "true");
return request<{ services: Service[] }>(`/services${q.size ? `?${q}` : ""}`);
},
create: (data: any) => request<{ service: Service }>("/services", { method: "POST", body: JSON.stringify(data) }),
update: (id: number, data: any) =>
request<{ service: Service }>(`/services/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
remove: (id: number) => request<{ ok: boolean }>(`/services/${id}`, { method: "DELETE" }),
},
clients: {
list: (q?: string) => request<{ clients: Client[] }>(`/clients${q ? `?q=${encodeURIComponent(q)}` : ""}`),
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) }),
remove: (id: number) => request<{ ok: boolean }>(`/clients/${id}`, { method: "DELETE" }),
},
appointments: {
list: (params: { from?: string; to?: string; employee_id?: number; client_id?: number; status?: string; limit?: number }) => {
const q = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => v !== undefined && q.set(k, String(v)));
return request<{ appointments: Appointment[] }>(`/appointments?${q}`);
},
create: (data: any) =>
request<{ appointment: Appointment }>("/appointments", { method: "POST", body: JSON.stringify(data) }),
update: (id: number, data: any) =>
request<{ appointment: Appointment }>(`/appointments/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
complete: (id: number, data: { tip?: number; payment_method?: string }) =>
request<{ appointment: Appointment }>(`/appointments/${id}/complete`, {
method: "POST",
body: JSON.stringify(data),
}),
remove: (id: number) => request<{ ok: boolean }>(`/appointments/${id}`, { method: "DELETE" }),
},
dashboard: {
overview: () => request<DashboardOverview>("/dashboard/overview"),
bestEmployees: (range = "30") => request<{ employees: RankedEmployee[] }>(`/dashboard/best-employees?range=${range}`),
bestServices: (range = "30") => request<{ services: RankedService[] }>(`/dashboard/best-services?range=${range}`),
topTickets: (range = "30", limit = 8) =>
request<{ tickets: { ticket: Ticket }[] }>(`/dashboard/top-tickets?range=${range}&limit=${limit}`),
topClients: (range = "30", limit = 6) =>
request<{ clients: RankedClient[] }>(`/dashboard/top-clients?range=${range}&limit=${limit}`),
frequentClients: (range = "30", limit = 6) =>
request<{ clients: RankedClient[] }>(`/dashboard/frequent-clients?range=${range}&limit=${limit}`),
revenueTrend: (days = 30) => request<{ points: RevenuePoint[] }>(`/dashboard/revenue-trend?days=${days}`),
revenueByCategory: (range = "30") =>
request<{ slices: CategorySlice[] }>(`/dashboard/revenue-by-category?range=${range}`),
tickets: (limit = 50) => request<{ tickets: Ticket[] }>(`/dashboard/tickets?limit=${limit}`),
commissions: (range = "30") => request<{ rows: any[]; total: number }>(`/dashboard/commissions?range=${range}`),
},
settings: {
get: () => request<{ settings: any }>("/settings"),
update: (data: any) => request<{ settings: any }>("/settings", { method: "PATCH", body: JSON.stringify(data) }),
},
cash: {
session: () => request<{ session: any; stats: any }>("/cash/session"),
sessions: () => request<{ sessions: any[] }>("/cash/sessions"),
open: (data: any) => request<{ session: any; stats: any }>("/cash/open", { method: "POST", body: JSON.stringify(data) }),
close: (data: any) => request<{ session: any; stats: any; expected: number }>("/cash/close", { method: "POST", body: JSON.stringify(data) }),
entries: (sessionId?: number) =>
request<{ entries: any[] }>(`/cash/entries${sessionId ? `?session_id=${sessionId}` : ""}`),
addEntry: (data: any) => request<{ entry: any; stats: any }>("/cash/entries", { method: "POST", body: JSON.stringify(data) }),
removeEntry: (id: number) => request<{ ok: boolean; stats: any }>(`/cash/entries/${id}`, { method: "DELETE" }),
},
notifications: {
list: (status?: string) => request<{ notifications: any[] }>(`/notifications${status ? `?status=${status}` : ""}`),
stats: () => request<any>("/notifications/stats"),
regenerate: () => request<{ ok: boolean }>("/notifications/regenerate", { method: "POST" }),
send: (id: number) => request<{ ok: boolean }>(`/notifications/${id}/send`, { method: "POST" }),
cancel: (id: number) => request<{ ok: boolean }>(`/notifications/${id}/cancel`, { method: "POST" }),
},
};
+100
View File
@@ -0,0 +1,100 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { api, setToken, getToken } from "./api";
import type { Business, User } from "../../shared/types";
interface AuthState {
user: User | null;
business: Business | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
switchUser: (userId: number, email: string) => Promise<void>;
refreshBusiness: () => Promise<void>;
}
const Ctx = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [business, setBusiness] = useState<Business | null>(null);
const [loading, setLoading] = useState(true);
const loadBusiness = useCallback(async (role?: string) => {
if (role === "admin") return; // admins have no business
try {
const { business } = await api.business.get();
setBusiness(business);
} catch {
/* ignore */
}
}, []);
useEffect(() => {
const t = getToken();
if (!t) {
setLoading(false);
return;
}
// Token is the user id; we need the user object. Quick login isn't re-called,
// so we hydrate by calling /me. Server stores users in db; fetch demo list and match.
(async () => {
try {
const res = await fetch("/api/auth/me", { headers: { authorization: `Bearer ${t}` } });
if (!res.ok) throw new Error();
const { user } = (await res.json()) as { user: User };
setUser(user);
await loadBusiness(user.role);
} catch {
setToken(null);
} finally {
setLoading(false);
}
})();
}, [loadBusiness]);
const login = useCallback(
async (email: string, password: string) => {
const { token, user } = await api.auth.login(email, password);
setToken(token);
setUser(user);
await loadBusiness(user.role);
},
[loadBusiness]
);
const switchUser = useCallback(
async (userId: number, email: string) => {
// Quick demo: try logging in with known demo password
try {
const { token, user } = await api.auth.login(email, "demo1234");
setToken(token);
setUser(user);
await loadBusiness(user.role);
void userId;
} catch (e) {
throw e;
}
},
[loadBusiness]
);
const logout = useCallback(() => {
setToken(null);
setUser(null);
setBusiness(null);
}, []);
const value = useMemo(
() => ({ user, business, loading, login, logout, switchUser, refreshBusiness: loadBusiness }),
[user, business, loading, login, logout, switchUser, loadBusiness]
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useAuth() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
+98
View File
@@ -0,0 +1,98 @@
import { clsx, type ClassValue } from "clsx";
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
export function formatCurrency(amount: number, symbol = "$") {
const n = Number(amount) || 0;
const formatted = n.toLocaleString("es-MX", {
minimumFractionDigits: n % 1 === 0 ? 0 : 2,
maximumFractionDigits: 2,
});
return `${symbol}${formatted}`;
}
export function formatNumber(n: number) {
return (Number(n) || 0).toLocaleString("es-MX");
}
export function formatDate(iso: string | null | undefined, opts: Intl.DateTimeFormatOptions = {}) {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
return d.toLocaleDateString("es-MX", { day: "numeric", month: "short", year: "numeric", ...opts });
}
export function formatTime(iso: string | null) {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
return d.toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit" });
}
export function formatRelative(iso: string | null) {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
const diff = d.getTime() - Date.now();
const absMin = Math.abs(Math.round(diff / 60000));
const rtf = new Intl.RelativeTimeFormat("es-MX", { numeric: "auto" });
if (absMin < 60) return rtf.format(Math.round(diff / 60000), "minute");
if (absMin < 60 * 24) return rtf.format(Math.round(diff / 3600000), "hour");
if (absMin < 60 * 24 * 30) return rtf.format(Math.round(diff / 86400000), "day");
if (absMin < 60 * 24 * 365) return rtf.format(Math.round(diff / (86400000 * 30)), "month");
return rtf.format(Math.round(diff / (86400000 * 365)), "year");
}
export function initials(name: string) {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase())
.join("");
}
export function statusLabel(status: string) {
switch (status) {
case "scheduled":
return "Programada";
case "completed":
return "Completada";
case "cancelled":
return "Cancelada";
case "no_show":
return "No asistió";
default:
return status;
}
}
export function statusColor(status: string) {
switch (status) {
case "scheduled":
return { bg: "#eef4ff", fg: "#1c36dc", dot: "#3b66ff" };
case "completed":
return { bg: "#ecfdf5", fg: "#047857", dot: "#10b981" };
case "cancelled":
return { bg: "#fef2f2", fg: "#b91c1c", dot: "#ef4444" };
case "no_show":
return { bg: "#fff7ed", fg: "#b9440b", dot: "#f97316" };
default:
return { bg: "#f3f4f6", fg: "#374151", dot: "#6b7280" };
}
}
export function paymentLabel(method: string) {
switch (method) {
case "card":
return "Tarjeta";
case "cash":
return "Efectivo";
case "transfer":
return "Transferencia";
default:
return method;
}
}
+119
View File
@@ -0,0 +1,119 @@
const BASE = "/api/public";
export interface PublicBusiness {
id: number;
name: string;
industry: string;
currency_symbol: string;
phone: string | null;
address: string | null;
booking_enabled: boolean;
cancel_window_hours: number;
cancel_penalty_pct: number;
require_deposit: boolean;
deposit_pct: number;
}
export interface PublicService {
id: number;
name: string;
description: string | null;
category: string;
duration_min: number;
price: number;
color: string;
}
export interface PublicEmployee {
id: number;
name: string;
role: string;
color: string;
}
export interface PublicBusinessResponse {
business: PublicBusiness;
services: PublicService[];
employees: PublicEmployee[];
}
export interface PublicSlot {
time: string;
iso: string;
employee_id: number;
employee_name: string;
}
export interface PublicSlotsResponse {
slots: PublicSlot[];
service: Pick<PublicService, "id" | "name" | "price" | "duration_min">;
}
export interface BookClient {
name: string;
email?: string;
phone?: string;
notes?: string;
}
export interface BookPayload {
service_id: number;
employee_id: number;
start_at: string;
client: BookClient;
}
export interface BookResponse {
appointment: {
id: number;
start_at: string;
price: number;
service_name: string;
employee_name: string;
};
business: { name: string; currency_symbol: string };
client_created: boolean;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers as Record<string, string> | undefined),
},
});
if (!res.ok) {
let message = `Error ${res.status}`;
try {
const body = await res.json();
if (body?.error) message = body.error;
} catch {
/* ignore */
}
throw new Error(message);
}
return (await res.json()) as T;
}
export function getBusiness(slug: string): Promise<PublicBusinessResponse> {
return request<PublicBusinessResponse>(`/${slug}`);
}
export function getSlots(
slug: string,
serviceId: number,
date: string,
employeeId?: number
): Promise<PublicSlotsResponse> {
const q = new URLSearchParams({ service_id: String(serviceId), date });
if (employeeId) q.set("employee_id", String(employeeId));
return request<PublicSlotsResponse>(`/${slug}/slots?${q.toString()}`);
}
export function book(slug: string, payload: BookPayload): Promise<BookResponse> {
return request<BookResponse>(`/${slug}/book`, {
method: "POST",
body: JSON.stringify(payload),
});
}
+26
View File
@@ -0,0 +1,26 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App.tsx";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 15_000,
refetchOnWindowFocus: false,
retry: 1,
},
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>
);
+314
View File
@@ -0,0 +1,314 @@
import { useEffect, useMemo, useRef, useState } from "react";
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import listPlugin from "@fullcalendar/list";
import type {
DateSelectArg,
EventClickArg,
EventDropArg,
} from "@fullcalendar/core";
import esLocale from "@fullcalendar/core/locales/es";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CalendarDays, Plus, Filter, RotateCcw } from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "../lib/auth";
import type { Appointment } from "../../shared/types";
import { PageHeader, Spinner, EmptyState } from "../components/ui";
import { AppointmentModal } from "../components/AppointmentModal";
import { statusColor } from "../lib/format";
const STATUS_FILTERS = [
{ value: "all", label: "Todas" },
{ value: "scheduled", label: "Programadas" },
{ value: "completed", label: "Completadas" },
{ value: "cancelled", label: "Canceladas" },
];
export function CalendarPage() {
const { user } = useAuth();
const qc = useQueryClient();
const calRef = useRef<FullCalendar>(null);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Appointment | null>(null);
const [draftSlot, setDraftSlot] = useState<{ start: string; end: string } | null>(null);
const [employeeFilter, setEmployeeFilter] = useState<number | "all">("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [isMobile, setIsMobile] = useState(
typeof window !== "undefined" ? window.matchMedia("(max-width: 640px)").matches : false
);
useEffect(() => {
const mq = window.matchMedia("(max-width: 640px)");
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const isOwner = user?.role === "owner";
// Employees see only their own by default
const effectiveEmployee = !isOwner && user?.employee_id ? user.employee_id : employeeFilter;
const { data: employeesData } = useQuery({
queryKey: ["employees"],
queryFn: () => api.employees.list(),
enabled: isOwner,
});
const rangeRef = useRef<{ from: string; to: string }>({ from: "", to: "" });
const { data, isLoading, refetch } = useQuery({
queryKey: ["appointments", "calendar", effectiveEmployee, statusFilter],
queryFn: async () => {
const cal = calRef.current?.getApi();
const from = cal?.view.activeStart.toISOString() ?? new Date().toISOString();
const to = cal?.view.activeEnd.toISOString() ?? new Date().toISOString();
rangeRef.current = { from, to };
return api.appointments.list({
from,
to,
employee_id: effectiveEmployee === "all" ? undefined : Number(effectiveEmployee),
status: statusFilter === "all" ? undefined : statusFilter,
});
},
});
const events = useMemo(() => {
return (data?.appointments ?? []).map((a) => {
const empColor = a.employee?.color ?? "#3b66ff";
const bg =
a.status === "cancelled"
? "#f3f4f6"
: a.status === "completed"
? `${empColor}22`
: `${empColor}1a`;
const border = a.status === "cancelled" ? "#d1d5db" : empColor;
const fg = a.status === "cancelled" ? "#6b7280" : a.status === "completed" ? empColor : "#1e293b";
return {
id: String(a.id),
start: a.start_at,
end: a.end_at,
title: `${a.client?.name ?? "Cliente"} · ${a.service?.name ?? ""}`,
backgroundColor: bg,
borderColor: border,
textColor: fg,
extendedProps: { appointment: a },
editable: a.status !== "completed",
};
});
}, [data]);
const moveMutation = useMutation({
mutationFn: async ({ id, start, end }: { id: number; start: string; end: string }) =>
api.appointments.update(id, { start_at: start, end_at: end }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["appointments"] }),
});
const onDrop = (arg: EventDropArg) => {
const id = Number(arg.event.id);
const start = arg.event.start!.toISOString();
const end = (arg.event.end ?? new Date(arg.event.start!.getTime() + 60 * 60000)).toISOString();
moveMutation.mutate({ id, start, end });
};
const onResize = (arg: any) => {
const id = Number(arg.event.id);
moveMutation.mutate({
id,
start: arg.event.start!.toISOString(),
end: arg.event.end!.toISOString(),
});
};
const onSelectSlot = (arg: DateSelectArg) => {
setEditing(null);
setDraftSlot({ start: arg.start.toISOString(), end: arg.end.toISOString() });
setModalOpen(true);
};
const onClickEvent = (arg: EventClickArg) => {
arg.jsEvent.preventDefault();
const a = arg.event.extendedProps.appointment as Appointment;
setEditing(a);
setDraftSlot(null);
setModalOpen(true);
};
const onToday = () => calRef.current?.getApi().today();
return (
<div className="flex h-full flex-col">
<PageHeader
title="Calendario"
subtitle={
isOwner
? "Arrastra para reagendar, redimensiona para cambiar duración, clic para editar."
: "Tu agenda personal. Crea citas y se te asignarán automáticamente."
}
actions={
<>
<button onClick={() => refetch()} className="btn btn-secondary" title="Refrescar">
<RotateCcw className="h-4 w-4" />
<span className="hidden sm:inline">Refrescar</span>
</button>
<button
onClick={() => {
setEditing(null);
setDraftSlot({ start: new Date().toISOString(), end: new Date(Date.now() + 60 * 60000).toISOString() });
setModalOpen(true);
}}
className="btn btn-primary"
>
<Plus className="h-4 w-4" />
Nueva cita
</button>
</>
}
/>
<div className="flex flex-wrap items-center gap-3 px-5 pb-3 pt-4 sm:px-7">
{isOwner && (
<div className="flex min-w-0 flex-1 items-center gap-2 sm:flex-none">
<Filter className="h-4 w-4 shrink-0 text-slate-400" />
<select
className="select w-full min-w-0 sm:w-auto sm:min-w-[180px]"
value={employeeFilter === "all" ? "all" : String(employeeFilter)}
onChange={(e) => setEmployeeFilter(e.target.value === "all" ? "all" : Number(e.target.value))}
>
<option value="all">Todos los empleados</option>
{employeesData?.employees.map((e) => (
<option key={e.id} value={e.id}>
{e.name} {e.role}
</option>
))}
</select>
</div>
)}
<div className="flex flex-wrap items-center gap-1 rounded-xl border border-slate-200 bg-white p-1">
{STATUS_FILTERS.map((s) => (
<button
key={s.value}
onClick={() => setStatusFilter(s.value)}
className={`rounded-lg px-2.5 py-1.5 text-xs font-semibold transition-colors ${
statusFilter === s.value ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
}`}
>
{s.label}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 pb-6 sm:px-7">
<div className="card overflow-x-auto p-3 sm:p-4">
{isLoading && !data && (
<div className="flex h-64 items-center justify-center">
<Spinner className="h-6 w-6 text-brand-500" />
</div>
)}
<FullCalendar
ref={calRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin]}
locale={esLocale}
initialView={isMobile ? "timeGridDay" : "timeGridWeek"}
headerToolbar={
isMobile
? { left: "prev,next", center: "title", right: "timeGridDay,listWeek" }
: { left: "prev,next today", center: "title", right: "dayGridMonth,timeGridWeek,timeGridDay,listWeek" }
}
footerToolbar={isMobile ? { center: "today" } : false}
buttonText={{
today: "Hoy",
month: "Mes",
week: "Semana",
day: "Día",
list: "Lista",
}}
views={{
listWeek: {
buttonText: "Lista",
listDayFormat: { weekday: "long", day: "numeric", month: "short" },
listDaySideFormat: false,
noEventsContent: "Sin citas esta semana",
},
timeGridDay: { buttonText: "Día" },
}}
height={isMobile ? "auto" : "auto"}
contentHeight={isMobile ? 560 : 680}
stickyHeaderDates
firstDay={1}
slotMinTime="08:00:00"
slotMaxTime="21:00:00"
slotDuration="00:30:00"
slotLabelInterval="01:00:00"
slotLabelFormat={{
hour: "2-digit",
minute: "2-digit",
hour12: true,
}}
nowIndicator
allDaySlot={false}
selectable
selectMirror
editable
eventResizableFromStart
eventDurationEditable
eventStartEditable
dayMaxEvents={isMobile ? 2 : 3}
eventTimeFormat={{
hour: "2-digit",
minute: "2-digit",
hour12: true,
}}
events={events}
select={onSelectSlot}
eventClick={onClickEvent}
eventDrop={onDrop}
eventResize={onResize}
datesSet={() => refetch()}
eventContent={(arg) => <EventContent arg={arg} />}
/>
{!isLoading && events.length === 0 && (
<div className="mt-2">
<EmptyState
icon={CalendarDays}
title="Sin citas en este rango"
description="Selecciona un horario en el calendario o crea una nueva cita."
/>
</div>
)}
</div>
<button onClick={onToday} className="sr-only">Hoy</button>
</div>
<AppointmentModal
open={modalOpen}
onClose={() => {
setModalOpen(false);
setEditing(null);
setDraftSlot(null);
}}
appointment={editing}
draftSlot={draftSlot}
/>
</div>
);
}
function EventContent({ arg }: { arg: any }) {
const a = arg.event.extendedProps.appointment as Appointment | undefined;
if (!a) return <div>{arg.timeText} {arg.event.title}</div>;
const c = statusColor(a.status);
const isTime = arg.view.type !== "dayGridMonth";
return (
<div className="flex h-full flex-col gap-0.5 overflow-hidden">
<div className="flex items-center gap-1">
<span className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: c.dot }} />
<span className="text-[10px] font-semibold opacity-70">{arg.timeText}</span>
</div>
<div className="truncate font-bold leading-tight">{a.client?.name}</div>
<div className="truncate text-[10px] opacity-80">{a.service?.name}</div>
{isTime && a.employee && (
<div className="mt-auto truncate text-[10px] opacity-70">con {a.employee.name}</div>
)}
</div>
);
}
+260
View File
@@ -0,0 +1,260 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Wallet,
ArrowDownCircle,
ArrowUpCircle,
Lock,
Unlock,
Trash2,
Plus,
TrendingUp,
TrendingDown,
CircleDollarSign,
} from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "../lib/auth";
import { PageHeader, Spinner, EmptyState } from "../components/ui";
import { Modal } from "../components/Modal";
import { formatCurrency, formatTime, formatDate, paymentLabel, cn } from "../lib/format";
export function CashPage() {
const { business } = useAuth();
const qc = useQueryClient();
const sym = business?.currency_symbol ?? "$";
const { data, isLoading, refetch } = useQuery({ queryKey: ["cash", "session"], queryFn: () => api.cash.session() });
const session = data?.session;
const stats = data?.stats;
const { data: entriesData } = useQuery({
queryKey: ["cash", "entries", session?.id],
queryFn: () => api.cash.entries(session?.id),
enabled: !!session,
});
const [openModal, setOpenModal] = useState(false);
const [closeModal, setCloseModal] = useState(false);
const [entryModal, setEntryModal] = useState<"income" | "expense" | null>(null);
const openMut = useMutation({
mutationFn: (d: any) => api.cash.open(d),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["cash"] }); setOpenModal(false); },
});
const closeMut = useMutation({
mutationFn: (d: any) => api.cash.close(d),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["cash"] }); setCloseModal(false); },
});
const addEntryMut = useMutation({
mutationFn: (d: any) => api.cash.addEntry(d),
onSuccess: () => qc.invalidateQueries({ queryKey: ["cash"] }),
});
const removeEntryMut = useMutation({
mutationFn: (id: number) => api.cash.removeEntry(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["cash"] }),
});
if (isLoading) {
return <div className="flex h-full items-center justify-center"><Spinner className="h-6 w-6 text-brand-500" /></div>;
}
if (!session) {
return (
<div className="flex h-full flex-col">
<PageHeader title="Caja" subtitle="Apertura y cierre de caja diario." />
<div className="flex flex-1 items-center justify-center px-5">
<div className="card max-w-md p-8 text-center">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<Lock className="h-7 w-7" />
</div>
<h3 className="text-lg font-extrabold text-slate-900">La caja está cerrada</h3>
<p className="mt-1 text-sm text-slate-500">Abre la caja para registrar ingresos, egresos y el cierre del día.</p>
<button onClick={() => setOpenModal(true)} className="btn btn-primary mx-auto mt-5">
<Unlock className="h-4 w-4" /> Abrir caja
</button>
</div>
</div>
<OpenModal open={openModal} onClose={() => setOpenModal(false)} onOpen={(d: any) => openMut.mutate(d)} pending={openMut.isPending} sym={sym} />
</div>
);
}
const expected = Number(session.opening_amount) + (stats?.income ?? 0) - (stats?.expense ?? 0);
const entries = entriesData?.entries ?? [];
return (
<div className="flex h-full flex-col">
<PageHeader
title="Caja"
subtitle={`Abierta ${formatDate(session.opened_at)} · ${formatTime(session.opened_at)}`}
actions={
<>
<button onClick={() => setEntryModal("income")} className="btn btn-secondary"><Plus className="h-4 w-4" /> Ingreso</button>
<button onClick={() => setEntryModal("expense")} className="btn btn-secondary"><ArrowUpCircle className="h-4 w-4 text-rose-500" /> Egreso</button>
<button onClick={() => setCloseModal(true)} className="btn btn-primary"><Lock className="h-4 w-4" /> Cerrar caja</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={CircleDollarSign} color="#3b66ff" label="Apertura" value={formatCurrency(session.opening_amount, sym)} />
<Kpi icon={TrendingUp} color="#10b981" label="Ingresos" value={formatCurrency(stats?.income ?? 0, sym)} />
<Kpi icon={TrendingDown} color="#ef4444" label="Egresos" value={formatCurrency(stats?.expense ?? 0, sym)} />
<Kpi icon={Wallet} color="#a855f7" label="Esperado en caja" value={formatCurrency(expected, sym)} />
</div>
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<h3 className="text-sm font-bold text-slate-900">Movimientos del día ({entries.length})</h3>
<button onClick={() => refetch()} className="text-xs font-semibold text-brand-600 hover:underline">Refrescar</button>
</div>
{!entries.length ? (
<EmptyState icon={Wallet} title="Sin movimientos" description="Registra un ingreso o egreso, o completa citas para verlas aquí." />
) : (
<div className="divide-y divide-slate-50">
{entries.map((e: any) => {
const income = e.type === "income";
return (
<div key={e.id} className="group flex items-center gap-3 px-5 py-2.5">
<div className={cn("flex h-8 w-8 items-center justify-center rounded-lg", income ? "bg-emerald-50 text-emerald-600" : "bg-rose-50 text-rose-600")}>
{income ? <ArrowDownCircle className="h-4 w-4" /> : <ArrowUpCircle className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-slate-800">{e.concept}</div>
<div className="text-[11px] text-slate-500">
{formatTime(e.created_at)} {e.payment_method ? `· ${paymentLabel(e.payment_method)}` : ""} {e.ticket_id ? "· ticket" : ""}
</div>
</div>
<div className={cn("text-sm font-extrabold", income ? "text-emerald-600" : "text-rose-600")}>
{income ? "+" : ""}{formatCurrency(e.amount, sym)}
</div>
{!e.ticket_id && (
<button
onClick={() => removeEntryMut.mutate(e.id)}
className="rounded-lg p-1.5 text-slate-300 opacity-0 transition-opacity hover:bg-rose-50 hover:text-rose-500 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
);
})}
</div>
)}
</div>
</div>
<EntryModal kind={entryModal} onClose={() => setEntryModal(null)} onSave={(d: any) => addEntryMut.mutate(d)} sym={sym} />
<CloseModal open={closeModal} expected={expected} sym={sym} onClose={() => setCloseModal(false)} onCloseCaja={(d: any) => closeMut.mutate(d)} pending={closeMut.isPending} />
</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>
);
}
function OpenModal({ open, onClose, onOpen, pending, sym }: any) {
const [amount, setAmount] = useState("0");
const [note, setNote] = useState("");
return (
<Modal open={open} onClose={onClose} title="Abrir caja" subtitle="Registra el efectivo inicial en caja." size="sm">
<div className="space-y-3">
<div>
<label className="label">Efectivo inicial</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">{sym}</span>
<input type="number" min={0} className="input pl-7" value={amount} onChange={(e) => setAmount(e.target.value)} />
</div>
</div>
<div><label className="label">Nota (opcional)</label><input className="input" value={note} onChange={(e) => setNote(e.target.value)} /></div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button onClick={onClose} className="btn btn-ghost">Cancelar</button>
<button onClick={() => onOpen({ opening_amount: Number(amount) || 0, note })} className="btn btn-primary" disabled={pending}>
{pending ? <Spinner /> : <Unlock className="h-4 w-4" />} Abrir
</button>
</div>
</Modal>
);
}
function CloseModal({ open, expected, sym, onClose, onCloseCaja, pending }: any) {
const [counted, setCounted] = useState("");
const [note, setNote] = useState("");
const diff = (Number(counted) || 0) - expected;
return (
<Modal open={open} onClose={onClose} title="Cerrar caja" subtitle="Confirma el efectivo contado para el cierre." size="sm">
<div className="space-y-3">
<div className="rounded-xl bg-slate-50 p-3 text-sm">
<div className="flex justify-between"><span className="text-slate-500">Esperado en caja</span><span className="font-bold text-slate-800">{formatCurrency(expected, sym)}</span></div>
</div>
<div>
<label className="label">Efectivo contado</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">{sym}</span>
<input type="number" min={0} className="input pl-7" value={counted} onChange={(e) => setCounted(e.target.value)} placeholder={String(expected)} />
</div>
{counted !== "" && (
<div className={cn("mt-1 text-xs font-semibold", Math.abs(diff) < 0.01 ? "text-emerald-600" : "text-rose-600")}>
{Math.abs(diff) < 0.01 ? "✓ Cuadra perfecto" : diff > 0 ? `Sobrante de ${formatCurrency(diff, sym)}` : `Faltante de ${formatCurrency(-diff, sym)}`}
</div>
)}
</div>
<div><label className="label">Nota (opcional)</label><input className="input" value={note} onChange={(e) => setNote(e.target.value)} /></div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button onClick={onClose} className="btn btn-ghost">Cancelar</button>
<button onClick={() => onCloseCaja({ closing_amount: Number(counted) || expected, note })} className="btn btn-primary" disabled={pending}>
{pending ? <Spinner /> : <Lock className="h-4 w-4" />} Cerrar caja
</button>
</div>
</Modal>
);
}
function EntryModal({ kind, onClose, onSave, sym }: any) {
const [amount, setAmount] = useState("");
const [concept, setConcept] = useState("");
const [method, setMethod] = useState("cash");
useEffect(() => { if (kind) { setAmount(""); setConcept(""); } }, [kind]);
if (!kind) return null;
const income = kind === "income";
return (
<Modal open={!!kind} onClose={onClose} title={income ? "Registrar ingreso" : "Registrar egreso"} size="sm">
<div className="space-y-3">
<div>
<label className="label">Concepto *</label>
<input className="input" value={concept} onChange={(e) => setConcept(e.target.value)} placeholder={income ? "Ej. Venta de producto" : "Ej. Compra de insumos"} />
</div>
<div>
<label className="label">Monto *</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">{sym}</span>
<input type="number" min={0} className="input pl-7" value={amount} onChange={(e) => setAmount(e.target.value)} />
</div>
</div>
<div><label className="label">Método</label>
<select className="input" value={method} onChange={(e) => setMethod(e.target.value)}>
<option value="cash">Efectivo</option><option value="card">Tarjeta</option><option value="transfer">Transferencia</option>
</select>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button onClick={onClose} className="btn btn-ghost">Cancelar</button>
<button
onClick={() => concept && amount && onSave({ type: kind, amount: Number(amount), concept, payment_method: method }).then(onClose)}
className="btn btn-primary"
disabled={!concept || !amount}
>
Guardar
</button>
</div>
</Modal>
);
}
+243
View File
@@ -0,0 +1,243 @@
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
Mail,
Phone,
DollarSign,
Calendar,
Star,
Clock,
Save,
Pencil,
} from "lucide-react";
import { api } from "../lib/api";
import { PageHeader, Avatar, Spinner, EmptyState, Badge } from "../components/ui";
import { Modal } from "../components/Modal";
import {
formatCurrency,
formatDate,
formatTime,
statusLabel,
statusColor,
} from "../lib/format";
export function ClientDetailPage() {
const { id } = useParams();
const clientId = Number(id);
const [editing, setEditing] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["clients", clientId],
queryFn: () => api.clients.list().then((r) => ({ ...r, target: r.clients.find((c) => c.id === clientId) })),
});
const client = data?.target;
const { data: appts } = useQuery({
queryKey: ["clients", clientId, "appts"],
queryFn: () => api.appointments.list({ client_id: clientId, limit: 50 }),
});
if (isLoading) {
return (
<div className="flex h-full items-center justify-center"><Spinner className="h-6 w-6 text-brand-500" /></div>
);
}
if (!client) {
return (
<div className="flex h-full items-center justify-center">
<EmptyState title="Cliente no encontrado" action={<Link to="/clients" className="btn btn-secondary">Volver</Link>} />
</div>
);
}
const stats = client.stats!;
return (
<div className="flex h-full flex-col">
<PageHeader
title={client.name}
subtitle="Perfil del cliente"
actions={
<>
<Link to="/clients" className="btn btn-ghost">
<ArrowLeft className="h-4 w-4" /> Volver
</Link>
<button onClick={() => setEditing(true)} className="btn btn-secondary">
<Pencil className="h-4 w-4" /> Editar
</button>
</>
}
/>
<div className="grid gap-5 px-5 py-5 sm:px-7 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-1">
<div className="card p-5">
<div className="flex items-center gap-3">
<Avatar name={client.name} color="#3b66ff" size={56} />
<div className="min-w-0">
<h3 className="truncate text-lg font-extrabold text-slate-900">{client.name}</h3>
{client.tags?.split(",").map((t) => (
<span key={t} className="chip mr-1 bg-brand-50 text-brand-600">{t.trim()}</span>
))}
</div>
</div>
<div className="mt-4 space-y-2 text-sm">
{client.phone && (
<a href={`tel:${client.phone}`} className="flex items-center gap-2 text-slate-600 hover:text-brand-600">
<Phone className="h-4 w-4 text-slate-400" /> {client.phone}
</a>
)}
{client.email && (
<a href={`mailto:${client.email}`} className="flex items-center gap-2 truncate text-slate-600 hover:text-brand-600">
<Mail className="h-4 w-4 text-slate-400 shrink-0" /> <span className="truncate">{client.email}</span>
</a>
)}
<div className="flex items-center gap-2 text-slate-500">
<Calendar className="h-4 w-4 text-slate-400" /> Desde {formatDate(client.created_at)}
</div>
</div>
{client.notes && (
<div className="mt-3 rounded-xl bg-slate-50 p-3 text-xs text-slate-600">
<span className="font-bold">Notas:</span> {client.notes}
</div>
)}
</div>
<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">
<StatBox icon={DollarSign} color="#10b981" label="Gasto total" value={formatCurrency(stats.total_spent)} />
<StatBox icon={Calendar} color="#3b66ff" label="Visitas" value={String(stats.visits)} />
<StatBox icon={Clock} color="#a855f7" label="Ticket prom." value={formatCurrency(stats.avg_ticket)} />
<StatBox icon={Star} color="#f59e0b" label="No shows" value={String(stats.no_show_count)} />
</div>
</div>
</div>
<div className="card overflow-hidden lg:col-span-2">
<div className="border-b border-slate-100 px-5 py-4">
<h4 className="text-sm font-bold text-slate-900">Historial de citas</h4>
<p className="text-xs text-slate-500">Últimas {appts?.appointments.length ?? 0} citas</p>
</div>
{!appts?.appointments.length ? (
<EmptyState title="Sin historial" description="Este cliente aún no tiene citas." />
) : (
<div className="divide-y divide-slate-50">
{appts.appointments
.slice()
.sort((a, b) => b.start_at.localeCompare(a.start_at))
.map((a) => {
const c = statusColor(a.status);
return (
<div key={a.id} className="flex items-center 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: a.service?.color ?? "#3b66ff" }}
>
<Calendar className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-bold text-slate-800">{a.service?.name}</span>
<Badge bg={c.bg} fg={c.fg} dot={c.dot}>
{statusLabel(a.status)}
</Badge>
</div>
<div className="text-xs text-slate-500">
{formatDate(a.start_at)} · {formatTime(a.start_at)} · con {a.employee?.name}
</div>
</div>
<div className="text-right">
<div className="text-sm font-bold text-slate-900">{formatCurrency(a.price)}</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
<ClientEditModal open={editing} client={client} onClose={() => setEditing(false)} />
</div>
);
}
function StatBox({
icon: Icon,
color,
label,
value,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
color: string;
label: string;
value: string;
}) {
return (
<div className="rounded-xl bg-slate-50 p-3">
<Icon className="h-4 w-4" style={{ color }} />
<div className="mt-1 text-base font-extrabold text-slate-900">{value}</div>
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">{label}</div>
</div>
);
}
function ClientEditModal({ open, client, onClose }: { open: boolean; client: any; onClose: () => void }) {
const qc = useQueryClient();
const [name, setName] = useState(client.name);
const [phone, setPhone] = useState(client.phone ?? "");
const [email, setEmail] = useState(client.email ?? "");
const [notes, setNotes] = useState(client.notes ?? "");
const [tags, setTags] = useState(client.tags ?? "");
const mut = useMutation({
mutationFn: () => api.clients.update(client.id, { name, phone, email, notes, tags }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["clients"] });
onClose();
},
});
return (
<Modal
open={open}
onClose={onClose}
title="Editar cliente"
footer={
<div className="flex items-center justify-end gap-2">
<button onClick={onClose} className="btn btn-ghost">Cancelar</button>
<button onClick={() => mut.mutate()} className="btn btn-primary" disabled={!name.trim() || mut.isPending}>
{mut.isPending ? <Spinner /> : <Save className="h-4 w-4" />} Guardar
</button>
</div>
}
>
<div className="space-y-3">
<div>
<label className="label">Nombre *</label>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label">Teléfono</label>
<input className="input" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div>
<label className="label">Correo</label>
<input className="input" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
</div>
<div>
<label className="label">Etiquetas (separadas por coma)</label>
<input className="input" value={tags} onChange={(e) => setTags(e.target.value)} placeholder="VIP, Frecuente" />
</div>
<div>
<label className="label">Notas</label>
<textarea className="textarea" rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
</div>
</Modal>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { Search, Users, Mail, Phone, DollarSign, Calendar, ChevronRight, Sparkles } from "lucide-react";
import { api } from "../lib/api";
import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui";
import { formatCurrency, formatDate, formatRelative } from "../lib/format";
export function ClientsPage() {
const navigate = useNavigate();
const [q, setQ] = useState("");
const [debounced, setDebounced] = useState("");
const [timer, setTimer] = useState<any>(null);
const onChange = (v: string) => {
setQ(v);
if (timer) clearTimeout(timer);
const t = setTimeout(() => setDebounced(v), 250);
setTimer(t);
};
const { data, isFetching } = useQuery({
queryKey: ["clients", debounced],
queryFn: () => api.clients.list(debounced),
});
return (
<div className="flex h-full flex-col">
<PageHeader
title="Clientes"
subtitle="Busca, edita y revisa el historial de tus clientes."
/>
<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" />
<input
className="input pl-9"
placeholder="Buscar por nombre, teléfono o correo…"
value={q}
onChange={(e) => onChange(e.target.value)}
/>
{isFetching && <Spinner className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" />}
</div>
</div>
<div className="flex-1 px-5 py-5 sm:px-7">
{isFetching && !data ? (
<div className="flex h-40 items-center justify-center"><Spinner /></div>
) : !data?.clients.length ? (
<EmptyState icon={Users} title="Sin clientes" description="Los clientes aparecerán aquí cuando agendes su primera cita." />
) : (
<div className="card overflow-hidden">
{/* Mobile card list */}
<div className="divide-y divide-slate-50 sm:hidden">
{data.clients.map((c) => (
<button
key={c.id}
onClick={() => navigate(`/clients/${c.id}`)}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-slate-50/60"
>
<Avatar name={c.name} color="#94a3b8" size={38} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate font-bold text-slate-900">{c.name}</span>
{c.tags?.split(",")[0] && (
<span className="chip bg-brand-50 text-brand-600">{c.tags.split(",")[0].trim()}</span>
)}
</div>
<div className="truncate text-[11px] text-slate-500">
{c.phone ?? c.email ?? "Sin contacto"}
</div>
<div className="mt-0.5 flex items-center gap-3 text-[11px]">
<span className="font-semibold text-slate-600">{c.stats?.visits ?? 0} visitas</span>
<span className="font-bold text-emerald-600">{formatCurrency(c.stats?.total_spent ?? 0)}</span>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-slate-300" />
</button>
))}
</div>
{/* Desktop table */}
<div className="hidden overflow-x-auto sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 text-left text-[11px] font-bold uppercase tracking-wider text-slate-400">
<th className="px-4 py-3">Cliente</th>
<th className="px-4 py-3">Contacto</th>
<th className="px-4 py-3 text-right">Visitas</th>
<th className="px-4 py-3 text-right">Gasto total</th>
<th className="px-4 py-3">Última visita</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{data.clients.map((c) => (
<tr
key={c.id}
onClick={() => navigate(`/clients/${c.id}`)}
className="group cursor-pointer border-b border-slate-50 transition-colors hover:bg-slate-50/60"
>
<td className="px-4 py-3">
<div className="flex items-center gap-2.5">
<Avatar name={c.name} color="#94a3b8" size={34} />
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-bold text-slate-900">{c.name}</span>
{c.tags?.split(",").map((t) => (
<span key={t} className="chip bg-brand-50 text-brand-600">{t.trim()}</span>
))}
</div>
<span className="text-[11px] text-slate-400">Desde {formatDate(c.created_at)}</span>
</div>
</div>
</td>
<td className="px-4 py-3 text-slate-600">
<div className="flex flex-col gap-0.5 text-xs">
{c.phone && <span className="inline-flex items-center gap-1"><Phone className="h-3 w-3" /> {c.phone}</span>}
{c.email && <span className="inline-flex items-center gap-1"><Mail className="h-3 w-3" /> {c.email}</span>}
</div>
</td>
<td className="px-4 py-3 text-right">
<span className="inline-flex items-center gap-1 font-bold text-slate-800">
<Calendar className="h-3.5 w-3.5 text-slate-400" />
{c.stats?.visits ?? 0}
</span>
</td>
<td className="px-4 py-3 text-right">
<span className="inline-flex items-center gap-1 font-bold text-emerald-600">
<DollarSign className="h-3.5 w-3.5" />
{formatCurrency(c.stats?.total_spent ?? 0)}
</span>
</td>
<td className="px-4 py-3 text-xs text-slate-500">{formatRelative(c.stats?.last_visit ?? null)}</td>
<td className="px-4 py-3 text-right">
<ChevronRight className="h-4 w-4 text-slate-300 transition-transform group-hover:translate-x-0.5 group-hover:text-brand-500" />
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-center gap-2 border-t border-slate-100 bg-slate-50/60 px-4 py-2.5 text-xs text-slate-500">
<Sparkles className="h-3.5 w-3.5 text-brand-400" />
Mostrando {data.clients.length} cliente{data.clients.length !== 1 ? "s" : ""}
</div>
</div>
)}
</div>
</div>
);
}
+576
View File
@@ -0,0 +1,576 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
CartesianGrid,
} from "recharts";
import {
DollarSign,
CalendarCheck,
TrendingUp,
TrendingDown,
Crown,
Star,
Scissors,
Ticket as TicketIcon,
Flame,
Trophy,
Coins,
} from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "../lib/auth";
import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui";
import {
formatCurrency,
formatNumber,
formatDate,
paymentLabel,
cn,
} from "../lib/format";
const RANGES = [
{ value: "7", label: "7 días" },
{ value: "30", label: "30 días" },
{ value: "60", label: "60 días" },
];
const PIE_COLORS = ["#3b66ff", "#f17616", "#10b981", "#a855f7", "#ec4899", "#14b8a6", "#f59e0b", "#6366f1"];
export function DashboardPage() {
const { business } = useAuth();
const [range, setRange] = useState("30");
const symbol = business?.currency_symbol ?? "$";
const { data: overview, isLoading: ovLoading } = useQuery({
queryKey: ["dashboard", "overview"],
queryFn: () => api.dashboard.overview(),
});
const { data: emp } = useQuery({
queryKey: ["dashboard", "best-employees", range],
queryFn: () => api.dashboard.bestEmployees(range),
});
const { data: svc } = useQuery({
queryKey: ["dashboard", "best-services", range],
queryFn: () => api.dashboard.bestServices(range),
});
const { data: tickets } = useQuery({
queryKey: ["dashboard", "top-tickets", range],
queryFn: () => api.dashboard.topTickets(range, 6),
});
const { data: topClients } = useQuery({
queryKey: ["dashboard", "top-clients", range],
queryFn: () => api.dashboard.topClients(range, 6),
});
const { data: freqClients } = useQuery({
queryKey: ["dashboard", "frequent-clients", range],
queryFn: () => api.dashboard.frequentClients(range, 6),
});
const { data: trend } = useQuery({
queryKey: ["dashboard", "trend", range],
queryFn: () => api.dashboard.revenueTrend(Number(range)),
});
const { data: cats } = useQuery({
queryKey: ["dashboard", "categories", range],
queryFn: () => api.dashboard.revenueByCategory(range),
});
const { data: commissions } = useQuery({
queryKey: ["dashboard", "commissions", range],
queryFn: () => api.dashboard.commissions(range),
});
const trendData = (trend?.points ?? []).map((p) => ({
...p,
label: new Date(p.date).toLocaleDateString("es-MX", { day: "numeric", month: range === "7" ? "short" : undefined }),
}));
const totalCat = (cats?.slices ?? []).reduce((a, s) => a + s.revenue, 0) || 1;
return (
<div className="flex h-full flex-col">
<PageHeader
title="Tablero"
subtitle={`Rendimiento de ${business?.name ?? "tu negocio"}`}
actions={
<div className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white p-1">
{RANGES.map((r) => (
<button
key={r.value}
onClick={() => setRange(r.value)}
className={cn(
"rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors",
range === r.value ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
)}
>
{r.label}
</button>
))}
</div>
}
/>
<div className="flex-1 space-y-5 px-5 py-5 sm:px-7">
{/* KPIs */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<KpiCard
title="Ingresos del período"
value={ovLoading ? "…" : formatCurrency(overview?.revenue_month ?? 0, symbol)}
sub="últimos 30 días"
delta={overview?.revenue_change_pct}
deltaSuffix="% vs mes anterior"
icon={DollarSign}
color="#3b66ff"
/>
<KpiCard
title="Citas programadas"
value={ovLoading ? "…" : formatNumber(overview?.appts_upcoming ?? 0)}
sub="próximas"
icon={CalendarCheck}
color="#10b981"
/>
<KpiCard
title="Ticket promedio"
value={ovLoading ? "…" : formatCurrency(overview?.avg_ticket ?? 0, symbol)}
sub="por venta"
icon={TrendingUp}
color="#a855f7"
/>
<KpiCard
title="Ocupación"
value={ovLoading ? "…" : `${overview?.occupancy_pct ?? 0}%`}
sub={`${overview?.cancel_rate ?? 0}% cancelación`}
icon={Flame}
color="#f17616"
/>
</div>
{/* Revenue chart + Category pie */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
<div className="card p-5 lg:col-span-2">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-sm font-bold text-slate-900">Ingresos del período</h3>
<p className="text-xs text-slate-500">Tendencia diaria</p>
</div>
<div className="flex items-center gap-1.5 text-xs font-bold text-brand-600">
<div className="h-2 w-2 rounded-full bg-brand-500" />
Ingresos
</div>
</div>
{!trend ? (
<div className="flex h-64 items-center justify-center"><Spinner /></div>
) : (
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={trendData} margin={{ left: -10, right: 8, top: 4 }}>
<defs>
<linearGradient id="rev" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b66ff" stopOpacity={0.35} />
<stop offset="100%" stopColor="#3b66ff" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="#eef0f4" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<YAxis
tick={{ fontSize: 11, fill: "#94a3b8" }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${symbol}${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`}
/>
<Tooltip
contentStyle={{
borderRadius: 12,
border: "1px solid #eef0f4",
boxShadow: "0 8px 24px -8px rgba(0,0,0,0.15)",
fontSize: 12,
}}
formatter={(v: any) => [formatCurrency(Number(v), symbol), "Ingresos"]}
labelStyle={{ fontWeight: 700, color: "#0f172a" }}
/>
<Area type="monotone" dataKey="revenue" stroke="#3b66ff" strokeWidth={2.5} fill="url(#rev)" />
</AreaChart>
</ResponsiveContainer>
)}
</div>
<div className="card p-5">
<div className="mb-4">
<h3 className="text-sm font-bold text-slate-900">Por categoría</h3>
<p className="text-xs text-slate-500">Distribución de ingresos</p>
</div>
{!cats ? (
<div className="flex h-64 items-center justify-center"><Spinner /></div>
) : cats.slices.length === 0 ? (
<EmptyState title="Sin datos" />
) : (
<div className="flex flex-col items-center">
<ResponsiveContainer width="100%" height={180}>
<PieChart>
<Pie
data={cats.slices}
dataKey="revenue"
nameKey="category"
innerRadius={48}
outerRadius={72}
paddingAngle={2}
>
{cats.slices.map((_, i) => (
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{ borderRadius: 12, border: "1px solid #eef0f4", fontSize: 12 }}
formatter={(v: any, _n, p: any) => [
`${formatCurrency(Number(v), symbol)} (${Math.round((Number(v) / totalCat) * 100)}%)`,
p.payload.category,
]}
/>
</PieChart>
</ResponsiveContainer>
<div className="mt-2 w-full space-y-1.5">
{cats.slices.slice(0, 5).map((s, i) => (
<div key={s.category} className="flex items-center gap-2 text-xs">
<span className="h-2.5 w-2.5 rounded-sm" style={{ background: PIE_COLORS[i % PIE_COLORS.length] }} />
<span className="flex-1 font-medium text-slate-600">{s.category}</span>
<span className="font-bold text-slate-800">{formatCurrency(s.revenue, symbol)}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Best employees + best services */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<RankCard
title="Mejores empleados"
subtitle="Por ingresos generados"
icon={Crown}
iconColor="#f59e0b"
items={(emp?.employees ?? []).slice(0, 5).map((r, i) => ({
id: r.employee.id,
rank: i + 1,
title: r.employee.name,
subtitle: r.employee.role,
value: formatCurrency(r.revenue, symbol),
extra: `${r.appointments} citas · ⭐ ${r.avg_rating}`,
color: r.employee.color,
}))}
/>
<RankCard
title="Servicios más rentables"
subtitle="Por ingresos generados"
icon={Scissors}
iconColor="#3b66ff"
barMode
items={(svc?.services ?? []).slice(0, 5).map((r, i) => ({
id: r.service.id,
rank: i + 1,
title: r.service.name,
subtitle: r.service.category,
value: formatCurrency(r.revenue, symbol),
extra: `${r.count} ventas`,
color: r.service.color,
}))}
/>
</div>
{/* Top tickets */}
<div className="card p-5">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<TicketIcon className="h-4 w-4 text-accent-600" />
<h3 className="text-sm font-bold text-slate-900">Tickets más altos</h3>
</div>
<span className="text-xs text-slate-500">últimos {range} días</span>
</div>
{!tickets ? (
<div className="flex h-24 items-center justify-center"><Spinner /></div>
) : tickets.tickets.length === 0 ? (
<EmptyState title="Sin tickets" />
) : (
<>
{/* Mobile cards */}
<div className="divide-y divide-slate-50 sm:hidden">
{tickets.tickets.map(({ ticket }) => (
<div key={ticket.id} className="flex items-center gap-2 py-2">
<Avatar name={ticket.client?.name ?? "?"} size={30} color="#94a3b8" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-slate-800">{ticket.client?.name}</div>
<div className="flex items-center gap-1.5">
<span className="chip" style={{ background: `${ticket.service?.color}1a`, color: ticket.service?.color }}>
{ticket.service?.name}
</span>
</div>
<div className="text-[11px] text-slate-400">{ticket.employee?.name} · {paymentLabel(ticket.payment_method)}</div>
</div>
<div className="shrink-0 text-right font-bold text-slate-900">
{formatCurrency(ticket.amount + ticket.tip, symbol)}
</div>
</div>
))}
</div>
{/* Desktop table */}
<div className="hidden overflow-x-auto sm:block">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[11px] font-bold uppercase tracking-wider text-slate-400">
<th className="px-2 py-1.5">Cliente</th>
<th className="px-2 py-1.5">Servicio</th>
<th className="px-2 py-1.5">Especialista</th>
<th className="px-2 py-1.5">Pago</th>
<th className="px-2 py-1.5 text-right">Total</th>
</tr>
</thead>
<tbody>
{tickets.tickets.map(({ ticket }) => (
<tr key={ticket.id} className="border-t border-slate-50">
<td className="px-2 py-2">
<div className="flex items-center gap-2">
<Avatar name={ticket.client?.name ?? "?"} size={26} color="#94a3b8" />
<span className="font-semibold text-slate-800">{ticket.client?.name}</span>
</div>
</td>
<td className="px-2 py-2">
<span className="chip" style={{ background: `${ticket.service?.color}1a`, color: ticket.service?.color }}>
{ticket.service?.name}
</span>
</td>
<td className="px-2 py-2 text-slate-600">{ticket.employee?.name}</td>
<td className="px-2 py-2 text-xs text-slate-500">{paymentLabel(ticket.payment_method)}</td>
<td className="px-2 py-2 text-right font-bold text-slate-900">
{formatCurrency(ticket.amount + ticket.tip, symbol)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
{/* Top + frequent clients */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<RankCard
title="Top clientes"
subtitle="Por gasto total"
icon={Trophy}
iconColor="#a855f7"
items={(topClients?.clients ?? []).slice(0, 6).map((r, i) => ({
id: r.client.id,
rank: i + 1,
title: r.client.name,
subtitle: r.last_visit ? `Última visita ${formatDate(r.last_visit)}` : "—",
value: formatCurrency(r.total_spent, symbol),
extra: `${r.visits} visitas`,
tags: r.client.tags,
}))}
/>
<RankCard
title="Clientes frecuentes"
subtitle="Por número de visitas"
icon={Flame}
iconColor="#f17616"
items={(freqClients?.clients ?? []).slice(0, 6).map((r, i) => ({
id: r.client.id,
rank: i + 1,
title: r.client.name,
subtitle: r.last_visit ? `Última visita ${formatDate(r.last_visit)}` : "—",
value: `${r.visits} visitas`,
extra: formatCurrency(r.total_spent, symbol),
tags: r.client.tags,
}))}
/>
</div>
{/* Commissions */}
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
<div className="flex items-center gap-2">
<Coins className="h-4 w-4 text-emerald-600" />
<h3 className="text-sm font-bold text-slate-900">Comisiones del equipo</h3>
</div>
<span className="text-xs text-slate-500">últimos {range} días</span>
</div>
{!commissions ? (
<div className="flex h-24 items-center justify-center"><Spinner /></div>
) : (
<div className="grid grid-cols-1 gap-2 p-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="rounded-xl bg-emerald-50 p-3">
<div className="text-[10px] font-semibold uppercase tracking-wide text-emerald-700">Total comisiones</div>
<div className="text-xl font-extrabold text-emerald-700">{formatCurrency(commissions.total, symbol)}</div>
</div>
{(commissions.rows ?? []).slice(0, 6).map((r: any) => (
<div key={r.employee.id} className="flex items-center gap-2 rounded-xl bg-slate-50/70 px-3 py-2">
<Avatar name={r.employee.name} color={r.employee.color} size={32} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-slate-800">{r.employee.name}</div>
<div className="text-[11px] text-slate-500">{r.sales} ventas · {formatCurrency(r.revenue, symbol)}</div>
</div>
<div className="text-right">
<div className="text-sm font-extrabold text-emerald-600">{formatCurrency(r.commission, symbol)}</div>
<div className="text-[10px] text-slate-400">comisión</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
function KpiCard({
title,
value,
sub,
delta,
deltaSuffix,
icon: Icon,
color,
}: {
title: string;
value: string;
sub?: string;
delta?: number;
deltaSuffix?: string;
icon: React.ComponentType<{ className?: string }>;
color: string;
}) {
const positive = (delta ?? 0) >= 0;
return (
<div className="card relative overflow-hidden p-5">
<div className="absolute -right-6 -top-6 h-24 w-24 rounded-full opacity-10" style={{ background: color }} />
<div className="flex items-start justify-between">
<div className="min-w-0">
<div className="text-xs font-semibold uppercase tracking-wider text-slate-500">{title}</div>
<div className="mt-1 text-2xl font-extrabold tracking-tight text-slate-900">{value}</div>
{sub && <div className="mt-0.5 text-xs text-slate-500">{sub}</div>}
</div>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-white shadow-soft"
style={{ background: color }}
>
<Icon className="h-5 w-5" />
</div>
</div>
{delta !== undefined && (
<div
className={cn(
"mt-3 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-bold",
positive ? "bg-emerald-50 text-emerald-700" : "bg-rose-50 text-rose-700"
)}
>
{positive ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{positive ? "+" : ""}{delta}%
<span className="font-medium opacity-70">{deltaSuffix}</span>
</div>
)}
</div>
);
}
interface RankItem {
id: number;
rank: number;
title: string;
subtitle?: string;
value: string;
extra?: string;
color?: string;
tags?: string | null;
}
function RankCard({
title,
subtitle,
icon: Icon,
iconColor,
items,
barMode,
}: {
title: string;
subtitle?: string;
icon: React.ComponentType<{ className?: string }>;
iconColor: string;
items: RankItem[];
barMode?: boolean;
}) {
const maxVal = Math.max(...items.map((i) => parseFloat(i.value.replace(/[^0-9.]/g, "")) || 0), 1);
return (
<div className="card p-5">
<div className="mb-4 flex items-center gap-2">
<div
className="flex h-8 w-8 items-center justify-center rounded-lg text-white"
style={{ background: iconColor }}
>
<Icon className="h-4 w-4" />
</div>
<div>
<h3 className="text-sm font-bold text-slate-900">{title}</h3>
{subtitle && <p className="text-xs text-slate-500">{subtitle}</p>}
</div>
</div>
{items.length === 0 ? (
<EmptyState title="Sin datos" />
) : (
<div className="space-y-2.5">
{items.map((it) => (
<div key={it.id} className="flex items-center gap-3">
<div
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-xs font-bold",
it.rank === 1
? "bg-amber-100 text-amber-700"
: it.rank === 2
? "bg-slate-200 text-slate-600"
: it.rank === 3
? "bg-orange-100 text-orange-700"
: "bg-slate-100 text-slate-500"
)}
>
{it.rank === 1 ? <Star className="h-3.5 w-3.5 fill-current" /> : it.rank}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
{it.color && <span className="h-2 w-2 shrink-0 rounded-full" style={{ background: it.color }} />}
<span className="truncate text-sm font-bold text-slate-800">{it.title}</span>
{it.tags?.split(",")[0] && (
<span className="chip bg-slate-100 text-slate-500">{it.tags.split(",")[0]}</span>
)}
</div>
<span className="shrink-0 text-sm font-bold text-slate-900">{it.value}</span>
</div>
<div className="mt-0.5 flex items-center justify-between gap-2">
{it.subtitle && <span className="truncate text-[11px] text-slate-500">{it.subtitle}</span>}
{it.extra && <span className="shrink-0 text-[11px] font-medium text-slate-400">{it.extra}</span>}
</div>
{barMode && (
<div className="mt-1 h-1 overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full"
style={{
width: `${(parseFloat(it.value.replace(/[^0-9.]/g, "")) / maxVal) * 100}%`,
background: it.color ?? iconColor,
}}
/>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
+313
View File
@@ -0,0 +1,313 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Plus,
Star,
DollarSign,
CalendarCheck,
Activity,
Mail,
Phone,
Pencil,
Trash2,
} from "lucide-react";
import { api } from "../lib/api";
import type { Employee } from "../../shared/types";
import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui";
import { Modal } from "../components/Modal";
import { formatCurrency, cn } from "../lib/format";
const COLORS = ["#3b66ff", "#f17616", "#10b981", "#a855f7", "#ec4899", "#14b8a6", "#f59e0b", "#6366f1"];
export function EmployeesPage() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["employees"], queryFn: () => api.employees.list() });
const { data: servicesData } = useQuery({ queryKey: ["services"], queryFn: () => api.services.list() });
const [editing, setEditing] = useState<Employee | null>(null);
const [creating, setCreating] = useState(false);
const removeMut = useMutation({
mutationFn: (id: number) => api.employees.remove(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["employees"] }),
});
return (
<div className="flex h-full flex-col">
<PageHeader
title="Empleados"
subtitle="Gestiona a tu equipo, sus servicios y mide su rendimiento."
actions={
<button
onClick={() => {
setEditing(null);
setCreating(true);
}}
className="btn btn-primary"
>
<Plus className="h-4 w-4" /> Nuevo empleado
</button>
}
/>
<div className="flex-1 px-5 py-5 sm:px-7">
{isLoading ? (
<div className="flex h-40 items-center justify-center"><Spinner /></div>
) : !data?.employees.length ? (
<EmptyState title="Sin empleados" description="Agrega a tu primer especialista." />
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{data.employees.map((e) => (
<div key={e.id} className="card group p-5">
<div className="flex items-start gap-3">
<Avatar name={e.name} color={e.color} size={48} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-bold text-slate-900">{e.name}</h3>
{!e.active && (
<span className="chip bg-slate-100 text-slate-500">Inactivo</span>
)}
</div>
<p className="text-xs text-slate-500">{e.role}</p>
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-slate-500">
{e.email && (
<span className="inline-flex items-center gap-1">
<Mail className="h-3 w-3" /> {e.email}
</span>
)}
{e.phone && (
<span className="inline-flex items-center gap-1">
<Phone className="h-3 w-3" /> {e.phone}
</span>
)}
</div>
</div>
<div className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={() => {
setEditing(e);
setCreating(false);
}}
className="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-brand-600"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => {
if (confirm(`¿Eliminar a ${e.name}?`)) removeMut.mutate(e.id);
}}
className="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-2 text-center">
<Stat icon={DollarSign} color="#10b981" label="Ingresos" value={formatCurrency(e.stats?.revenue_total ?? 0)} />
<Stat icon={CalendarCheck} color="#3b66ff" label="Citas" value={String(e.stats?.appointments_completed ?? 0)} />
<Stat icon={Star} color="#f59e0b" label="Rating" value={`${e.stats?.avg_rating ?? e.rating}`} />
</div>
{e.service_ids && e.service_ids.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{(servicesData?.services ?? [])
.filter((s) => e.service_ids!.includes(s.id))
.slice(0, 4)
.map((s) => (
<span
key={s.id}
className="chip"
style={{ background: `${s.color}1a`, color: s.color }}
>
{s.name}
</span>
))}
{e.service_ids.length > 4 && (
<span className="chip bg-slate-100 text-slate-500">+{e.service_ids.length - 4}</span>
)}
</div>
)}
<div className="mt-3 flex items-center gap-2 text-[11px] text-slate-400">
<Activity className="h-3 w-3" />
Utilización {e.stats?.utilization_pct ?? 0}%
<div className="ml-1 h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
<div className="h-full rounded-full bg-brand-500" style={{ width: `${e.stats?.utilization_pct ?? 0}%` }} />
</div>
</div>
</div>
))}
</div>
)}
</div>
<EmployeeModal
open={creating || !!editing}
employee={editing}
services={servicesData?.services ?? []}
onClose={() => {
setEditing(null);
setCreating(false);
}}
/>
</div>
);
}
function Stat({
icon: Icon,
color,
label,
value,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
color: string;
label: string;
value: string;
}) {
return (
<div className="rounded-xl bg-slate-50 px-2 py-2">
<Icon className="mx-auto h-3.5 w-3.5" style={{ color }} />
<div className="mt-1 truncate text-sm font-bold text-slate-800">{value}</div>
<div className="text-[10px] font-medium uppercase tracking-wide text-slate-400">{label}</div>
</div>
);
}
function EmployeeModal({
open,
employee,
services,
onClose,
}: {
open: boolean;
employee: Employee | null;
services: { id: number; name: string; color: string; category: string }[];
onClose: () => void;
}) {
const qc = useQueryClient();
const isEdit = !!employee;
const [name, setName] = useState("");
const [role, setRole] = useState("Especialista");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [color, setColor] = useState(COLORS[0]);
const [active, setActive] = useState(true);
const [svcIds, setSvcIds] = useState<number[]>([]);
useEffect(() => {
if (!open) return;
if (employee) {
setName(employee.name);
setRole(employee.role);
setEmail(employee.email ?? "");
setPhone(employee.phone ?? "");
setColor(employee.color);
setActive(!!employee.active);
setSvcIds(employee.service_ids ?? []);
} else {
setName("");
setRole("Especialista");
setEmail("");
setPhone("");
setColor(COLORS[Math.floor(Math.random() * COLORS.length)]);
setActive(true);
setSvcIds([]);
}
}, [open, employee]);
const mut = useMutation({
mutationFn: async () => {
const payload = { name, role, email, phone, color, active, service_ids: svcIds };
if (employee) return api.employees.update(employee.id, payload);
return api.employees.create(payload);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["employees"] });
qc.invalidateQueries({ queryKey: ["services"] });
onClose();
},
});
return (
<Modal
open={open}
onClose={onClose}
title={isEdit ? "Editar empleado" : "Nuevo empleado"}
footer={
<div className="flex items-center justify-end gap-2">
<button onClick={onClose} className="btn btn-ghost">Cancelar</button>
<button onClick={() => mut.mutate()} className="btn btn-primary" disabled={!name.trim() || mut.isPending}>
{mut.isPending ? <Spinner /> : isEdit ? "Guardar" : "Crear"}
</button>
</div>
}
>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Avatar name={name || "?"} color={color} size={56} />
<div className="flex-1">
<label className="label">Color</label>
<div className="flex flex-wrap gap-1.5">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={cn(
"h-7 w-7 rounded-full transition-transform",
color === c && "ring-2 ring-offset-2 ring-slate-400 scale-110"
)}
style={{ background: c }}
/>
))}
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label">Nombre *</label>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label className="label">Puesto</label>
<input className="input" value={role} onChange={(e) => setRole(e.target.value)} />
</div>
<div>
<label className="label">Correo</label>
<input className="input" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div>
<label className="label">Teléfono</label>
<input className="input" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
</div>
<div>
<label className="label">Servicios que ofrece</label>
<div className="flex max-h-44 flex-wrap gap-1.5 overflow-y-auto rounded-xl border border-slate-100 p-2">
{services.map((s) => {
const on = svcIds.includes(s.id);
return (
<button
key={s.id}
type="button"
onClick={() => setSvcIds((arr) => (on ? arr.filter((x) => x !== s.id) : [...arr, s.id]))}
className={cn(
"chip transition-colors",
on ? "text-white" : "bg-slate-100 text-slate-600 hover:bg-slate-200"
)}
style={on ? { background: s.color } : undefined}
>
{s.name}
</button>
);
})}
</div>
</div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
Activo (puede recibir citas)
</label>
</div>
</Modal>
);
}
+191
View File
@@ -0,0 +1,191 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Sparkles, ArrowRight, Mail, Lock, AlertCircle, Wand2 } from "lucide-react";
import { useAuth } from "../lib/auth";
import { api } from "../lib/api";
import { Spinner } from "../components/ui";
export function LoginPage() {
const { login, user } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("[email protected]");
const [password, setPassword] = useState("demo1234");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [demoUsers, setDemoUsers] = useState<{ email: string; name: string; role: string; avatar_color: string }[]>([]);
useEffect(() => {
if (user) navigate("/", { replace: true });
}, [user, navigate]);
useEffect(() => {
api.auth.demoUsers().then((r) => setDemoUsers(r.users)).catch(() => {});
}, []);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await login(email, password);
navigate("/", { replace: true });
} catch (err: any) {
setError(err.message || "No pudimos iniciar sesión");
} finally {
setLoading(false);
}
};
const quickLogin = async (mail: string) => {
setEmail(mail);
setPassword("demo1234");
setError(null);
setLoading(true);
try {
await login(mail, "demo1234");
navigate("/", { replace: true });
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="grid min-h-screen lg:grid-cols-2">
{/* Left: brand panel */}
<div className="relative hidden flex-col justify-between overflow-hidden bg-gradient-to-br from-brand-700 via-brand-600 to-brand-800 p-10 text-white lg:flex">
<div className="absolute -right-24 -top-24 h-96 w-96 rounded-full bg-white/10 blur-3xl" />
<div className="absolute -bottom-32 -left-20 h-96 w-96 rounded-full bg-accent-500/30 blur-3xl" />
<div className="relative z-10 flex items-center gap-2.5">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/15 backdrop-blur">
<Sparkles className="h-5 w-5" />
</div>
<span className="text-lg font-extrabold tracking-tight">AgendaPro</span>
</div>
<div className="relative z-10 max-w-md">
<h1 className="text-4xl font-extrabold leading-tight tracking-tight">
La gestión visual de tu negocio, en un solo lugar.
</h1>
<p className="mt-4 text-brand-100">
Calendario de citas con arrastrar y soltar, control de empleados, tickets e ingresos en tiempo real.
Diseñado para dueños y equipos.
</p>
<ul className="mt-8 space-y-3 text-sm">
{[
"Agenda citas en segundos y arrástralas cuando quieras",
"Tablero con tus ingresos, mejores empleados y clientes top",
"Gestiona servicios, empleados y clientes desde un panel",
].map((t) => (
<li key={t} className="flex items-start gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-accent-300" />
<span className="text-brand-50">{t}</span>
</li>
))}
</ul>
</div>
<div className="relative z-10 text-xs text-brand-200">
© {new Date().getFullYear()} AgendaPro · Demo
</div>
</div>
{/* Right: form */}
<div className="flex items-center justify-center bg-[#f6f7fb] px-5 py-10">
<div className="w-full max-w-sm">
<div className="mb-7 text-center lg:hidden">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500 to-brand-700 text-white">
<Sparkles className="h-6 w-6" />
</div>
<h1 className="text-xl font-extrabold">AgendaPro</h1>
</div>
<div className="card p-6 shadow-card">
<h2 className="text-lg font-extrabold text-slate-900">Bienvenido de nuevo</h2>
<p className="mt-1 text-sm text-slate-500">Inicia sesión para acceder a tu panel.</p>
<form onSubmit={submit} className="mt-5 space-y-4">
<div>
<label className="label">Correo</label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="email"
className="input pl-9"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
autoComplete="email"
required
/>
</div>
</div>
<div>
<label className="label">Contraseña</label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="password"
className="input pl-9"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
required
/>
</div>
</div>
{error && (
<div className="flex items-center gap-2 rounded-lg bg-rose-50 px-3 py-2 text-sm font-medium text-rose-700">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<button type="submit" className="btn btn-primary w-full" disabled={loading}>
{loading ? <Spinner /> : <>Entrar <ArrowRight className="h-4 w-4" /></>}
</button>
</form>
<div className="mt-5 flex items-center gap-2 text-xs font-medium text-slate-400">
<span className="h-px flex-1 bg-slate-200" />
cuentas demo
<span className="h-px flex-1 bg-slate-200" />
</div>
<div className="mt-3 space-y-1.5">
{demoUsers.slice(0, 4).map((u) => (
<button
key={u.email}
onClick={() => quickLogin(u.email)}
disabled={loading}
className="group flex w-full items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2 text-left transition-colors hover:border-brand-300 hover:bg-brand-50"
>
<div
className="flex h-8 w-8 items-center justify-center rounded-full text-[11px] font-bold text-white"
style={{ background: u.avatar_color }}
>
{u.name.split(" ").map((p) => p[0]).slice(0, 2).join("")}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-slate-800">{u.name}</div>
<div className="truncate text-[11px] text-slate-500">
{u.role === "owner" ? "Dueño" : "Empleado"} · {u.email}
</div>
</div>
<Wand2 className="h-4 w-4 text-slate-300 transition-colors group-hover:text-brand-500" />
</button>
))}
</div>
</div>
<p className="mt-4 text-center text-xs text-slate-400">
Demo · cualquier cuenta usa la contraseña <code className="font-mono font-semibold text-slate-600">demo1234</code>
</p>
</div>
</div>
</div>
);
}
+144
View File
@@ -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>
);
}
+298
View File
@@ -0,0 +1,298 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Clock, DollarSign, Pencil, Trash2, Scissors } from "lucide-react";
import { api } from "../lib/api";
import type { Service } from "../../shared/types";
import { PageHeader, Spinner, EmptyState } from "../components/ui";
import { Modal } from "../components/Modal";
import { formatCurrency, cn } from "../lib/format";
const COLORS = ["#3b66ff", "#f17616", "#10b981", "#a855f7", "#ec4899", "#14b8a6", "#f59e0b", "#6366f1"];
export function ServicesPage() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["services"], queryFn: () => api.services.list() });
const { data: empData } = useQuery({ queryKey: ["employees"], queryFn: () => api.employees.list() });
const [editing, setEditing] = useState<Service | null>(null);
const [creating, setCreating] = useState(false);
const removeMut = useMutation({
mutationFn: (id: number) => api.services.remove(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["services"] }),
});
const grouped: Record<string, Service[]> = {};
for (const s of data?.services ?? []) {
(grouped[s.category] ||= []).push(s);
}
const categories = Object.keys(grouped).sort();
return (
<div className="flex h-full flex-col">
<PageHeader
title="Servicios"
subtitle="El menú de servicios que tu negocio ofrece a los clientes."
actions={
<button
onClick={() => {
setEditing(null);
setCreating(true);
}}
className="btn btn-primary"
>
<Plus className="h-4 w-4" /> Nuevo servicio
</button>
}
/>
<div className="flex-1 space-y-6 px-5 py-5 sm:px-7">
{isLoading ? (
<div className="flex h-40 items-center justify-center"><Spinner /></div>
) : !data?.services.length ? (
<EmptyState
icon={Scissors}
title="Sin servicios"
description="Crea tu primer servicio del catálogo."
/>
) : (
categories.map((cat) => (
<div key={cat}>
<h3 className="mb-2 text-xs font-bold uppercase tracking-wider text-slate-500">{cat}</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{grouped[cat].map((s) => (
<div key={s.id} className="card group flex items-center gap-3 p-4">
<div
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-white shadow-soft"
style={{ background: s.color }}
>
<Scissors className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="truncate font-bold text-slate-900">{s.name}</h4>
{!s.active && <span className="chip bg-slate-100 text-slate-500">Inactivo</span>}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-3 text-xs text-slate-500">
<span className="inline-flex items-center gap-1">
<Clock className="h-3 w-3" /> {s.duration_min} min
</span>
<span className="inline-flex items-center gap-1 font-bold text-slate-700">
<DollarSign className="h-3 w-3" /> {formatCurrency(s.price)}
</span>
</div>
{(s.employee_ids?.length ?? 0) > 0 && (
<div className="mt-1.5 text-[11px] text-slate-400">
{s.employee_ids!.length} especialista{s.employee_ids!.length > 1 ? "s" : ""}
</div>
)}
</div>
<div className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={() => {
setEditing(s);
setCreating(false);
}}
className="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-brand-600"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => {
if (confirm(`¿Eliminar "${s.name}"?`)) removeMut.mutate(s.id);
}}
className="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
</div>
))
)}
</div>
<ServiceModal
open={creating || !!editing}
service={editing}
employees={empData?.employees ?? []}
onClose={() => {
setEditing(null);
setCreating(false);
}}
/>
</div>
);
}
function ServiceModal({
open,
service,
employees,
onClose,
}: {
open: boolean;
service: Service | null;
employees: { id: number; name: string; color: string }[];
onClose: () => void;
}) {
const qc = useQueryClient();
const isEdit = !!service;
const [name, setName] = useState("");
const [category, setCategory] = useState("");
const [description, setDescription] = useState("");
const [duration, setDuration] = useState(60);
const [price, setPrice] = useState(0);
const [color, setColor] = useState(COLORS[0]);
const [active, setActive] = useState(true);
const [empIds, setEmpIds] = useState<number[]>([]);
useEffect(() => {
if (!open) return;
if (service) {
setName(service.name);
setCategory(service.category);
setDescription(service.description ?? "");
setDuration(service.duration_min);
setPrice(service.price);
setColor(service.color);
setActive(!!service.active);
setEmpIds(service.employee_ids ?? []);
} else {
setName("");
setCategory("General");
setDescription("");
setDuration(60);
setPrice(0);
setColor(COLORS[Math.floor(Math.random() * COLORS.length)]);
setActive(true);
setEmpIds([]);
}
}, [open, service]);
const mut = useMutation({
mutationFn: async () => {
const payload = {
name,
category,
description,
duration_min: duration,
price,
color,
active,
employee_ids: empIds,
};
if (service) return api.services.update(service.id, payload);
return api.services.create(payload);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["services"] });
qc.invalidateQueries({ queryKey: ["employees"] });
onClose();
},
});
return (
<Modal
open={open}
onClose={onClose}
title={isEdit ? "Editar servicio" : "Nuevo servicio"}
footer={
<div className="flex items-center justify-end gap-2">
<button onClick={onClose} className="btn btn-ghost">Cancelar</button>
<button onClick={() => mut.mutate()} className="btn btn-primary" disabled={!name.trim() || mut.isPending}>
{mut.isPending ? <Spinner /> : isEdit ? "Guardar" : "Crear"}
</button>
</div>
}
>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label">Nombre *</label>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ej. Corte de cabello" />
</div>
<div>
<label className="label">Categoría</label>
<input className="input" value={category} onChange={(e) => setCategory(e.target.value)} list="svc-categories" />
<datalist id="svc-categories">
<option value="Cabello" />
<option value="Barbería" />
<option value="Facial" />
<option value="Spa" />
<option value="Uñas" />
<option value="General" />
</datalist>
</div>
</div>
<div>
<label className="label">Descripción</label>
<textarea className="textarea" rows={2} value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label">Duración (minutos)</label>
<div className="flex flex-wrap gap-1.5">
{[15, 30, 45, 60, 75, 90, 120, 150, 180].map((d) => (
<button
key={d}
type="button"
onClick={() => setDuration(d)}
className={cn(
"rounded-lg border px-2.5 py-1 text-xs font-semibold transition-colors",
duration === d ? "border-brand-500 bg-brand-50 text-brand-700" : "border-slate-200 text-slate-600 hover:bg-slate-50"
)}
>
{d}
</button>
))}
</div>
</div>
<div>
<label className="label">Precio</label>
<input type="number" min={0} step={10} className="input" value={price} onChange={(e) => setPrice(Number(e.target.value))} />
</div>
</div>
<div>
<label className="label">Color</label>
<div className="flex flex-wrap gap-1.5">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={cn("h-7 w-7 rounded-full transition-transform", color === c && "ring-2 ring-offset-2 ring-slate-400 scale-110")}
style={{ background: c }}
/>
))}
</div>
</div>
<div>
<label className="label">Especialistas que lo ofrecen</label>
<div className="flex max-h-44 flex-wrap gap-1.5 overflow-y-auto rounded-xl border border-slate-100 p-2">
{employees.length === 0 && <span className="px-2 py-1 text-xs text-slate-400">Sin empleados</span>}
{employees.map((e) => {
const on = empIds.includes(e.id);
return (
<button
key={e.id}
type="button"
onClick={() => setEmpIds((arr) => (on ? arr.filter((x) => x !== e.id) : [...arr, e.id]))}
className={cn("chip transition-colors", on ? "text-white" : "bg-slate-100 text-slate-600 hover:bg-slate-200")}
style={on ? { background: e.color } : undefined}
>
{e.name}
</button>
);
})}
</div>
</div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
Servicio activo (disponible para agendar)
</label>
</div>
</Modal>
);
}
+177
View File
@@ -0,0 +1,177 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Settings as SettingsIcon,
Save,
ShieldAlert,
CalendarClock,
Globe,
DollarSign,
Copy,
Check,
ExternalLink,
} from "lucide-react";
import { api } from "../lib/api";
import { PageHeader, Spinner } from "../components/ui";
export function SettingsPage() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["settings"], queryFn: () => api.settings.get() });
const [f, setF] = useState<any>(null);
const [copied, setCopied] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
if (data?.settings) setF(data.settings);
}, [data]);
const mut = useMutation({
mutationFn: () =>
api.settings.update({
name: f.name,
industry: f.industry,
phone: f.phone,
address: f.address,
slug: f.slug,
booking_enabled: f.booking_enabled ? 1 : 0,
cancel_window_hours: Number(f.cancel_window_hours),
cancel_penalty_pct: Number(f.cancel_penalty_pct),
require_deposit: f.require_deposit ? 1 : 0,
deposit_pct: Number(f.deposit_pct),
timezone: f.timezone,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["settings"] });
qc.invalidateQueries({ queryKey: ["business"] });
setSaved(true);
setTimeout(() => setSaved(false), 2500);
},
});
if (isLoading || !f) {
return (
<div className="flex h-full items-center justify-center"><Spinner className="h-6 w-6 text-brand-500" /></div>
);
}
const set = (k: string, v: any) => setF((s: any) => ({ ...s, [k]: v }));
const bookingUrl = `${window.location.origin}/b/${f.slug}`;
return (
<div className="flex h-full flex-col">
<PageHeader title="Configuración" subtitle="Políticas del negocio, reservas online y cobranza." />
<div className="flex-1 space-y-5 px-5 py-5 sm:px-7">
{/* Reservas online */}
<div className="card overflow-hidden">
<SectionTitle icon={Globe} color="#3b66ff" title="Reservas online" subtitle="Tu página pública para que los clientes agenden solos 24/7." />
<div className="space-y-4 p-5 pt-0">
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-3">
<input type="checkbox" className="mt-0.5 h-4 w-4 accent-brand-500" checked={!!f.booking_enabled} onChange={(e) => set("booking_enabled", e.target.checked)} />
<div>
<div className="text-sm font-bold text-slate-800">Página de reservas activa</div>
<p className="text-xs text-slate-500">Cuando está activa, cualquier cliente puede agendar desde tu link público.</p>
</div>
</label>
<div>
<label className="label">URL pública de reservas</label>
<div className="flex items-center gap-2">
<div className="flex flex-1 items-center rounded-xl border border-slate-200 bg-slate-50 px-3 py-2">
<span className="truncate font-mono text-xs text-slate-600">{bookingUrl}</span>
</div>
<button
onClick={() => {
navigator.clipboard?.writeText(bookingUrl);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
className="btn btn-secondary"
title="Copiar"
>
{copied ? <Check className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4" />}
</button>
<a href={bookingUrl} target="_blank" rel="noreferrer" className="btn btn-secondary" title="Abrir">
<ExternalLink className="h-4 w-4" />
</a>
</div>
<div className="mt-2 flex items-center gap-2">
<label className="label mb-0 shrink-0">/b/</label>
<input className="input" value={f.slug} onChange={(e) => set("slug", e.target.value)} placeholder="mi-negocio" />
</div>
</div>
</div>
</div>
{/* Política de cancelación */}
<div className="card overflow-hidden">
<SectionTitle icon={ShieldAlert} color="#f17616" title="Política de cancelación" subtitle="Reduce inasistencias: define ventanas y penalizaciones." />
<div className="space-y-4 p-5 pt-0">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="label flex items-center gap-1.5"><CalendarClock className="h-3.5 w-3.5 text-slate-400" /> Ventana gratuita (horas)</label>
<input type="number" min={0} step={1} className="input" value={f.cancel_window_hours} onChange={(e) => set("cancel_window_hours", e.target.value)} />
<p className="mt-1 text-[11px] text-slate-500">Cancelación sin costo si se hace con más de estas horas de anticipación.</p>
</div>
<div>
<label className="label flex items-center gap-1.5"><DollarSign className="h-3.5 w-3.5 text-slate-400" /> Penalización (% del servicio)</label>
<input type="number" min={0} max={100} step={5} className="input" value={f.cancel_penalty_pct} onChange={(e) => set("cancel_penalty_pct", e.target.value)} />
<p className="mt-1 text-[11px] text-slate-500">% del precio que se cobra al cancelar dentro de la ventana. 0 = sin penalización.</p>
</div>
</div>
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-3">
<input type="checkbox" className="mt-0.5 h-4 w-4 accent-brand-500" checked={!!f.require_deposit} onChange={(e) => set("require_deposit", e.target.checked)} />
<div className="flex-1">
<div className="text-sm font-bold text-slate-800">Solicitar anticipo al reservar</div>
<p className="text-xs text-slate-500">Compromete al cliente y reduce no-shows.</p>
</div>
</label>
{f.require_deposit && (
<div>
<label className="label">% de anticipo</label>
<input type="number" min={0} max={100} step={5} className="input sm:max-w-xs" value={f.deposit_pct} onChange={(e) => set("deposit_pct", e.target.value)} />
</div>
)}
<div className="rounded-xl bg-amber-50 px-3 py-2 text-xs text-amber-700">
<strong>Ejemplo:</strong> con ventana 24h y 50% de penalización, una cita cancelada con menos de 24h genera un cargo del 50% que verás al cancelarla desde el calendario.
</div>
</div>
</div>
{/* Datos del negocio */}
<div className="card overflow-hidden">
<SectionTitle icon={SettingsIcon} color="#10b981" title="Datos del negocio" />
<div className="grid gap-4 p-5 pt-0 sm:grid-cols-2">
<div><label className="label">Nombre</label><input className="input" value={f.name ?? ""} onChange={(e) => set("name", e.target.value)} /></div>
<div><label className="label">Industria</label><input className="input" value={f.industry ?? ""} onChange={(e) => set("industry", e.target.value)} /></div>
<div><label className="label">Teléfono</label><input className="input" value={f.phone ?? ""} onChange={(e) => set("phone", e.target.value)} /></div>
<div><label className="label">Zona horaria</label>
<select className="input" value={f.timezone ?? "America/Mexico_City"} onChange={(e) => set("timezone", e.target.value)}>
{["America/Mexico_City","America/Monterrey","America/Mazatlan","America/Bogota","America/Lima","America/Santiago","America/Argentina/Buenos_Aires","Europe/Madrid"].map((tz)=><option key={tz} value={tz}>{tz}</option>)}
</select>
</div>
<div className="sm:col-span-2"><label className="label">Dirección</label><input className="input" value={f.address ?? ""} onChange={(e) => set("address", e.target.value)} /></div>
</div>
</div>
<div className="sticky bottom-4 flex justify-end">
<button onClick={() => mut.mutate()} className="btn btn-primary shadow-card" disabled={mut.isPending}>
{mut.isPending ? <Spinner /> : <Save className="h-4 w-4" />} Guardar cambios
</button>
{saved && <span className="ml-3 inline-flex items-center gap-1 self-center text-sm font-semibold text-emerald-600"><Check className="h-4 w-4" /> Guardado</span>}
</div>
</div>
</div>
);
}
function SectionTitle({ icon: Icon, color, title, subtitle }: { icon: any; color: string; title: string; subtitle?: string }) {
return (
<div className="flex items-center gap-3 border-b border-slate-100 px-5 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl text-white" style={{ background: color }}>
<Icon className="h-4 w-4" />
</div>
<div>
<h3 className="text-sm font-bold text-slate-900">{title}</h3>
{subtitle && <p className="text-xs text-slate-500">{subtitle}</p>}
</div>
</div>
);
}
+184
View File
@@ -0,0 +1,184 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Ticket as TicketIcon, Search, CreditCard, Banknote, Smartphone } from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "../lib/auth";
import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui";
import { formatCurrency, formatDate, formatTime, paymentLabel } from "../lib/format";
export function TicketsPage() {
const { business } = useAuth();
const symbol = business?.currency_symbol ?? "$";
const [search, setSearch] = useState("");
const [method, setMethod] = useState<string>("all");
const { data, isLoading } = useQuery({
queryKey: ["tickets"],
queryFn: () => api.dashboard.tickets(100),
});
const filtered = useMemo(() => {
let rows = data?.tickets ?? [];
if (method !== "all") rows = rows.filter((t) => t.payment_method === method);
if (search.trim()) {
const q = search.toLowerCase();
rows = rows.filter(
(t) =>
t.client?.name?.toLowerCase().includes(q) ||
t.service?.name?.toLowerCase().includes(q) ||
t.employee?.name?.toLowerCase().includes(q)
);
}
return rows;
}, [data, search, method]);
const total = filtered.reduce((a, t) => a + t.amount + t.tip, 0);
const avg = filtered.length ? total / filtered.length : 0;
const METHODS = [
{ value: "all", label: "Todos", icon: TicketIcon },
{ value: "card", label: "Tarjeta", icon: CreditCard },
{ value: "cash", label: "Efectivo", icon: Banknote },
{ value: "transfer", label: "Transferencia", icon: Smartphone },
];
return (
<div className="flex h-full flex-col">
<PageHeader
title="Tickets"
subtitle="Historial de ventas y transacciones."
actions={
<div className="flex flex-wrap items-center gap-3">
<div className="rounded-xl bg-white px-4 py-2 text-right shadow-soft">
<div className="text-[10px] font-bold uppercase tracking-wide text-slate-400">Total mostrado</div>
<div className="text-base font-extrabold text-slate-900">{formatCurrency(total, symbol)}</div>
</div>
<div className="rounded-xl bg-white px-4 py-2 text-right shadow-soft">
<div className="text-[10px] font-bold uppercase tracking-wide text-slate-400">Promedio</div>
<div className="text-base font-extrabold text-slate-900">{formatCurrency(avg, symbol)}</div>
</div>
</div>
}
/>
<div className="flex flex-wrap items-center gap-3 px-5 pt-4 sm:px-7">
<div className="relative min-w-[220px] flex-1 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" />
<input
className="input pl-9"
placeholder="Buscar por cliente, servicio o empleado…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white p-1">
{METHODS.map((m) => (
<button
key={m.value}
onClick={() => setMethod(m.value)}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-semibold transition-colors ${
method === m.value ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
}`}
>
<m.icon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{m.label}</span>
</button>
))}
</div>
</div>
<div className="flex-1 px-5 py-5 sm:px-7">
{isLoading ? (
<div className="flex h-40 items-center justify-center"><Spinner /></div>
) : !filtered.length ? (
<EmptyState icon={TicketIcon} title="Sin tickets" description="No hay ventas que coincidan con el filtro." />
) : (
<div className="card overflow-hidden">
{/* Mobile card list */}
<div className="divide-y divide-slate-50 sm:hidden">
{filtered.map((t) => (
<div key={t.id} className="px-4 py-3">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Avatar name={t.client?.name ?? "?"} size={32} color="#94a3b8" />
<div className="min-w-0">
<div className="truncate font-bold text-slate-900">{t.client?.name}</div>
<div className="font-mono text-[11px] text-slate-400">#{t.id.toString().padStart(5, "0")} · {formatDate(t.created_at)}</div>
</div>
</div>
<div className="text-right">
<div className="font-extrabold text-emerald-600">{formatCurrency(t.amount + t.tip, symbol)}</div>
{t.tip > 0 && <div className="text-[10px] text-slate-400">+{formatCurrency(t.tip, symbol)} propina</div>}
</div>
</div>
<div className="mt-2 flex items-center gap-2 text-[11px]">
<span className="chip" style={{ background: `${t.service?.color}1a`, color: t.service?.color }}>
{t.service?.name}
</span>
<span className="inline-flex items-center gap-1 text-slate-500">
<span className="h-2 w-2 rounded-full" style={{ background: t.employee?.color }} />
{t.employee?.name}
</span>
<span className="ml-auto text-slate-400">{paymentLabel(t.payment_method)}</span>
</div>
</div>
))}
</div>
{/* Desktop table */}
<div className="hidden overflow-x-auto sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 text-left text-[11px] font-bold uppercase tracking-wider text-slate-400">
<th className="px-4 py-3">Ticket</th>
<th className="px-4 py-3">Cliente</th>
<th className="px-4 py-3">Servicio</th>
<th className="px-4 py-3">Especialista</th>
<th className="px-4 py-3">Pago</th>
<th className="px-4 py-3 text-right">Monto</th>
<th className="px-4 py-3 text-right">Propina</th>
<th className="px-4 py-3 text-right">Total</th>
</tr>
</thead>
<tbody>
{filtered.map((t) => (
<tr key={t.id} className="border-b border-slate-50 transition-colors hover:bg-slate-50/60">
<td className="px-4 py-3">
<div className="font-mono text-xs font-bold text-slate-700">#{t.id.toString().padStart(5, "0")}</div>
<div className="text-[11px] text-slate-400">{formatDate(t.created_at)} · {formatTime(t.created_at)}</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Avatar name={t.client?.name ?? "?"} size={28} color="#94a3b8" />
<span className="font-semibold text-slate-800">{t.client?.name}</span>
</div>
</td>
<td className="px-4 py-3">
<span className="chip" style={{ background: `${t.service?.color}1a`, color: t.service?.color }}>
{t.service?.name}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full" style={{ background: t.employee?.color }} />
<span className="text-slate-700">{t.employee?.name}</span>
</div>
</td>
<td className="px-4 py-3 text-xs text-slate-500">{paymentLabel(t.payment_method)}</td>
<td className="px-4 py-3 text-right font-semibold text-slate-700">{formatCurrency(t.amount, symbol)}</td>
<td className="px-4 py-3 text-right text-slate-500">
{t.tip > 0 ? formatCurrency(t.tip, symbol) : "—"}
</td>
<td className="px-4 py-3 text-right font-extrabold text-emerald-600">
{formatCurrency(t.amount + t.tip, symbol)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
}
+272
View File
@@ -0,0 +1,272 @@
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
Building2,
Users,
CalendarCheck,
DollarSign,
Scissors,
Ticket as TicketIcon,
RotateCcw,
Trash2,
Save,
AlertTriangle,
Sparkles,
} from "lucide-react";
import { api } from "../../lib/api";
import { PageHeader, Spinner, EmptyState } from "../../components/ui";
import { Modal } from "../../components/Modal";
import { formatCurrency, formatNumber, formatDate, cn } from "../../lib/format";
export function AdminBusinessDetailPage() {
const { id } = useParams();
const qc = useQueryClient();
const businessId = Number(id);
const [resetOpen, setResetOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["admin", "business", businessId],
queryFn: () => api.admin.business(businessId),
});
const { data: tplData } = useQuery({ queryKey: ["admin", "templates"], queryFn: () => api.admin.templates() });
const b = data?.business;
const stats = data?.stats;
const [name, setName] = useState("");
const [industry, setIndustry] = useState("");
const [plan, setPlan] = useState("trial");
const [status, setStatus] = useState("active");
const [hydrated, setHydrated] = useState(false);
if (b && !hydrated) {
setName(b.name);
setIndustry(b.industry);
setPlan(b.plan ?? "trial");
setStatus(b.status ?? "active");
setHydrated(true);
}
const updateMut = useMutation({
mutationFn: () => api.admin.updateBusiness(businessId, { name, industry, plan, status }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin"] }),
});
const resetMut = useMutation({
mutationFn: (tplKey: string) => api.admin.resetDemo(businessId, { template: tplKey }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin"] });
setResetOpen(false);
},
});
const deleteMut = useMutation({
mutationFn: () => api.admin.deleteBusiness(businessId),
onSuccess: () => (window.location.href = "/admin/businesses"),
});
if (isLoading) {
return <div className="flex h-full items-center justify-center"><Spinner className="h-6 w-6 text-brand-500" /></div>;
}
if (!b) {
return (
<div className="flex h-full items-center justify-center">
<EmptyState title="Negocio no encontrado" action={<Link to="/admin/businesses" className="btn btn-secondary">Volver</Link>} />
</div>
);
}
return (
<div className="flex h-full flex-col">
<PageHeader
title={b.name}
subtitle={`${b.industry} · ${b.template ?? "personalizado"}`}
actions={
<Link to="/admin/businesses" className="btn btn-ghost">
<ArrowLeft className="h-4 w-4" /> Volver
</Link>
}
/>
<div className="grid gap-5 px-5 py-5 sm:px-7 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-2">
<div className="card p-5">
<h3 className="mb-3 text-sm font-bold text-slate-900">Estadísticas</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<Stat icon={Users} color="#a855f7" label="Usuarios" value={String(stats?.users ?? 0)} />
<Stat icon={Scissors} color="#3b66ff" label="Servicios" value={String(stats?.services ?? 0)} />
<Stat icon={Users} color="#10b981" label="Clientes" value={String(stats?.clients ?? 0)} />
<Stat icon={CalendarCheck} color="#3b66ff" label="Citas totales" value={formatNumber(stats?.appointments ?? 0)} />
<Stat icon={TicketIcon} color="#f17616" label="Tickets" value={String(stats?.tickets ?? 0)} />
<Stat icon={DollarSign} color="#10b981" label="Ingresos 30d" value={formatCurrency(stats?.revenue_30d ?? 0)} />
</div>
</div>
<div className="card p-5">
<h3 className="mb-3 text-sm font-bold text-slate-900">Datos del negocio</h3>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label">Nombre</label>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label className="label">Industria</label>
<input className="input" value={industry} onChange={(e) => setIndustry(e.target.value)} />
</div>
<div>
<label className="label">Plan</label>
<div className="flex gap-1.5">
{["trial", "pro", "free"].map((p) => (
<button
key={p}
type="button"
onClick={() => setPlan(p)}
className={cn(
"flex-1 rounded-lg border px-2 py-1.5 text-xs font-semibold capitalize transition-colors",
plan === p ? "border-brand-500 bg-brand-50 text-brand-700" : "border-slate-200 text-slate-600 hover:bg-slate-50"
)}
>
{p === "trial" ? "Prueba" : p === "pro" ? "Pro" : "Free"}
</button>
))}
</div>
</div>
<div>
<label className="label">Estado</label>
<div className="flex gap-1.5">
{["active", "suspended"].map((s) => (
<button
key={s}
type="button"
onClick={() => setStatus(s)}
className={cn(
"flex-1 rounded-lg border px-2 py-1.5 text-xs font-semibold transition-colors",
status === s ? "border-brand-500 bg-brand-50 text-brand-700" : "border-slate-200 text-slate-600 hover:bg-slate-50"
)}
>
{s === "active" ? "Activo" : "Suspendido"}
</button>
))}
</div>
</div>
</div>
<div className="mt-3 flex justify-end">
<button onClick={() => updateMut.mutate()} className="btn btn-primary" disabled={updateMut.isPending || !name.trim()}>
{updateMut.isPending ? <Spinner /> : <Save className="h-4 w-4" />} Guardar cambios
</button>
</div>
{updateMut.isSuccess && <div className="mt-2 text-right text-xs font-semibold text-emerald-600">Guardado </div>}
</div>
</div>
<div className="space-y-4">
<div className="card p-5">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-brand-500" />
<h3 className="text-sm font-bold text-slate-900">Plantilla demo</h3>
</div>
<p className="mt-1 text-xs text-slate-500">
Recarga este negocio con datos de demostración. Esto <strong>borra</strong> todos sus empleados, servicios, clientes y citas actuales.
</p>
<button onClick={() => setResetOpen(true)} className="btn btn-secondary mt-3 w-full">
<RotateCcw className="h-4 w-4" /> Recargar datos demo
</button>
</div>
<div className="card border-rose-100 p-5">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-rose-500" />
<h3 className="text-sm font-bold text-slate-900">Zona de peligro</h3>
</div>
<p className="mt-1 text-xs text-slate-500">
Elimina permanentemente este negocio y <strong>todos sus datos</strong>, incluidas las cuentas de sus usuarios.
</p>
<button onClick={() => setDeleteOpen(true)} className="btn btn-danger mt-3 w-full">
<Trash2 className="h-4 w-4" /> Eliminar negocio
</button>
</div>
<div className="card p-4 text-[11px] text-slate-400">
<div>ID: <span className="font-mono">#{b.id}</span></div>
<div>Creado: {formatDate(b.created_at)}</div>
<div>Plantilla actual: {b.template ?? "—"}</div>
</div>
</div>
</div>
{/* Reset demo modal */}
<Modal
open={resetOpen}
onClose={() => setResetOpen(false)}
title="Recargar datos de demostración"
subtitle="Elige qué plantilla cargar. Se reemplazarán todos los datos actuales del negocio."
>
<div className="space-y-2">
{(tplData?.templates ?? []).map((t) => (
<button
key={t.key}
onClick={() => resetMut.mutate(t.key)}
disabled={resetMut.isPending}
className="flex w-full items-center gap-3 rounded-xl border border-slate-200 p-3 text-left transition-colors hover:border-brand-300 hover:bg-brand-50 disabled:opacity-50"
>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-white text-brand-600 shadow-soft">
<Building2 className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="font-bold text-slate-900">{t.name}</div>
<div className="text-[11px] text-slate-500">{t.description}</div>
<div className="text-[10px] text-slate-400">{t.employees} esp. · {t.services} serv. · {t.clients} clientes</div>
</div>
<RotateCcw className="h-4 w-4 text-slate-400" />
</button>
))}
{resetMut.isPending && <div className="flex items-center justify-center gap-2 py-3 text-sm text-slate-500"><Spinner /> Recargando</div>}
</div>
</Modal>
{/* Delete confirm modal */}
<Modal
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
title="Eliminar negocio"
size="sm"
footer={
<div className="flex items-center justify-end gap-2">
<button onClick={() => setDeleteOpen(false)} className="btn btn-ghost">Cancelar</button>
<button onClick={() => deleteMut.mutate()} className="btn btn-danger" disabled={deleteMut.isPending}>
{deleteMut.isPending ? <Spinner /> : <Trash2 className="h-4 w-4" />} Eliminar definitivamente
</button>
</div>
}
>
<div className="flex items-start gap-3 rounded-xl bg-rose-50 p-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-rose-500" />
<div className="text-sm text-rose-900">
¿Seguro que deseas eliminar <strong>{b.name}</strong>? Se borrarán para siempre todos sus empleados, servicios, clientes, citas, tickets y cuentas de usuario. Esta acción no se puede deshacer.
</div>
</div>
</Modal>
</div>
);
}
function Stat({
icon: Icon,
color,
label,
value,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
color: string;
label: string;
value: string;
}) {
return (
<div className="rounded-xl bg-slate-50 p-3">
<Icon className="h-4 w-4" style={{ color }} />
<div className="mt-1 text-base font-extrabold text-slate-900">{value}</div>
<div className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">{label}</div>
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Plus,
Building2,
Search,
Users,
CalendarCheck,
DollarSign,
ArrowUpRight,
} from "lucide-react";
import { api } from "../../lib/api";
import { PageHeader, Spinner, EmptyState } from "../../components/ui";
import { CreateBusinessModal } from "../../components/admin/CreateBusinessModal";
import { formatCurrency, formatNumber, formatDate } from "../../lib/format";
const PLAN_STYLE: Record<string, { bg: string; fg: string; label: string }> = {
trial: { bg: "#fff7ed", fg: "#b9440b", label: "Prueba" },
pro: { bg: "#eef4ff", fg: "#1c36dc", label: "Pro" },
free: { bg: "#f3f4f6", fg: "#374151", label: "Free" },
};
const STATUS_STYLE: Record<string, { bg: string; fg: string }> = {
active: { bg: "#ecfdf5", fg: "#047857" },
suspended: { bg: "#fef2f2", fg: "#b91c1c" },
};
export function AdminBusinessesPage() {
const qc = useQueryClient();
const [params, setParams] = useSearchParams();
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const { data, isLoading } = useQuery({ queryKey: ["admin", "businesses"], queryFn: () => api.admin.businesses() });
// open modal when ?new=1
useEffect(() => {
if (params.get("new") === "1") {
setCreateOpen(true);
params.delete("new");
setParams(params, { replace: true });
}
}, [params, setParams]);
const filtered = (data?.businesses ?? []).filter(({ business: b }) =>
!search || b.name.toLowerCase().includes(search.toLowerCase()) || b.industry.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="flex h-full flex-col">
<PageHeader
title="Negocios"
subtitle={`${data?.businesses.length ?? 0} negocios en la plataforma`}
actions={
<button onClick={() => setCreateOpen(true)} className="btn btn-primary">
<Plus className="h-4 w-4" /> Nuevo negocio
</button>
}
/>
<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" />
<input
className="input pl-9"
placeholder="Buscar por nombre o industria…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="flex-1 px-5 py-5 sm:px-7">
{isLoading ? (
<div className="flex h-40 items-center justify-center"><Spinner /></div>
) : !filtered.length ? (
<EmptyState
icon={Building2}
title="Sin negocios"
description="Crea el primer negocio de la plataforma."
action={<button onClick={() => setCreateOpen(true)} className="btn btn-primary"><Plus className="h-4 w-4" /> Crear negocio</button>}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{filtered.map(({ business: b, stats }) => {
const plan = PLAN_STYLE[b.plan ?? "trial"] ?? PLAN_STYLE.trial;
const status = STATUS_STYLE[b.status ?? "active"] ?? STATUS_STYLE.active;
return (
<Link
key={b.id}
to={`/admin/businesses/${b.id}`}
className="card group p-5 transition-shadow hover:shadow-card"
>
<div className="flex items-start gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-sm font-bold text-white">
{(b.name ?? "?").slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<h3 className="truncate font-bold text-slate-900">{b.name}</h3>
<span className="chip" style={{ background: plan.bg, color: plan.fg }}>{plan.label}</span>
<span className="chip" style={{ background: status.bg, color: status.fg }}>
{b.status === "active" ? "Activo" : "Suspendido"}
</span>
</div>
<p className="text-xs text-slate-500">{b.industry}</p>
</div>
<ArrowUpRight className="h-4 w-4 text-slate-300 transition-transform group-hover:translate-x-0.5 group-hover:text-brand-500" />
</div>
<div className="mt-4 grid grid-cols-3 gap-2 text-center">
<Mini icon={Users} color="#a855f7" label="Usuarios" value={String(stats.users)} />
<Mini icon={CalendarCheck} color="#3b66ff" label="Citas" value={formatNumber(stats.appointments)} />
<Mini icon={DollarSign} color="#10b981" label="Ingresos 30d" value={formatCurrency(stats.revenue_30d)} />
</div>
<div className="mt-3 text-[11px] text-slate-400">
Creado {formatDate(b.created_at)} · {b.template ?? "personalizado"}
</div>
</Link>
);
})}
</div>
)}
</div>
<CreateBusinessModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={() => qc.invalidateQueries({ queryKey: ["admin"] })}
/>
</div>
);
}
function Mini({
icon: Icon,
color,
label,
value,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
color: string;
label: string;
value: string;
}) {
return (
<div className="rounded-xl bg-slate-50 px-2 py-2">
<Icon className="mx-auto h-3.5 w-3.5" style={{ color }} />
<div className="mt-1 truncate text-sm font-bold text-slate-800">{value}</div>
<div className="text-[10px] font-medium uppercase tracking-wide text-slate-400">{label}</div>
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import {
Building2,
Users,
CalendarCheck,
DollarSign,
ArrowUpRight,
TrendingUp,
Sparkles,
Plus,
} from "lucide-react";
import { api } from "../../lib/api";
import { PageHeader, Spinner } from "../../components/ui";
import { formatCurrency, formatNumber, formatDate, cn } from "../../lib/format";
const PLAN_STYLE: Record<string, { bg: string; fg: string; label: string }> = {
trial: { bg: "#fff7ed", fg: "#b9440b", label: "Prueba" },
pro: { bg: "#eef4ff", fg: "#1c36dc", label: "Pro" },
free: { bg: "#f3f4f6", fg: "#374151", label: "Free" },
};
const STATUS_STYLE: Record<string, { bg: string; fg: string }> = {
active: { bg: "#ecfdf5", fg: "#047857" },
suspended: { bg: "#fef2f2", fg: "#b91c1c" },
};
export function AdminOverviewPage() {
const { data: ov, isLoading } = useQuery({ queryKey: ["admin", "overview"], queryFn: () => api.admin.overview() });
const { data: biz } = useQuery({ queryKey: ["admin", "businesses"], queryFn: () => api.admin.businesses() });
return (
<div className="flex h-full flex-col">
<PageHeader
title="Resumen de la plataforma"
subtitle="Visión general de todos los negocios en AgendaPro"
actions={
<Link to="/admin/businesses?new=1" className="btn btn-primary">
<Plus className="h-4 w-4" /> Nuevo negocio
</Link>
}
/>
<div className="flex-1 space-y-5 px-5 py-5 sm:px-7">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Kpi icon={Building2} color="#3b66ff" label="Negocios activos" value={isLoading ? "…" : String(ov?.active_businesses ?? 0)} sub={`${ov?.businesses ?? 0} en total`} />
<Kpi icon={Users} color="#a855f7" label="Usuarios" value={isLoading ? "…" : formatNumber(ov?.total_users ?? 0)} sub="owners + empleados" />
<Kpi icon={DollarSign} color="#10b981" label="Ingresos (30d)" value={isLoading ? "…" : formatCurrency(ov?.revenue_30d ?? 0)} sub="suma de todos los negocios" />
<Kpi icon={CalendarCheck} color="#f17616" label="Citas próximas" value={isLoading ? "…" : formatNumber(ov?.upcoming_appointments ?? 0)} sub="programadas" />
</div>
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-slate-400" />
<h3 className="text-sm font-bold text-slate-900">Negocios recientes</h3>
</div>
<Link to="/admin/businesses" className="text-xs font-semibold text-brand-600 hover:underline">
Ver todos
</Link>
</div>
{!biz ? (
<div className="flex h-32 items-center justify-center"><Spinner /></div>
) : (
<div className="divide-y divide-slate-50">
{biz.businesses.slice(0, 6).map(({ business: b, stats }) => {
const plan = PLAN_STYLE[b.plan ?? "trial"] ?? PLAN_STYLE.trial;
const status = STATUS_STYLE[b.status ?? "active"] ?? STATUS_STYLE.active;
return (
<Link
key={b.id}
to={`/admin/businesses/${b.id}`}
className="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-slate-50/60"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-xs font-bold text-white">
{(b.name ?? "?").slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-bold text-slate-900">{b.name}</span>
<span className="chip" style={{ background: plan.bg, color: plan.fg }}>{plan.label}</span>
<span className="chip" style={{ background: status.bg, color: status.fg }}>{b.status === "active" ? "Activo" : "Suspendido"}</span>
</div>
<div className="text-[11px] text-slate-500">
{b.industry} · {formatDate(b.created_at)} · {stats.employees} empleados · {stats.services} servicios
</div>
</div>
<div className="hidden text-right sm:block">
<div className="text-sm font-bold text-slate-900">{formatCurrency(stats.revenue_30d)}</div>
<div className="text-[11px] text-slate-500">{stats.appointments} citas</div>
</div>
<ArrowUpRight className="h-4 w-4 text-slate-300 transition-transform group-hover:translate-x-0.5 group-hover:text-brand-500" />
</Link>
);
})}
{biz.businesses.length === 0 && (
<div className="flex flex-col items-center gap-2 px-5 py-10 text-center">
<Sparkles className="h-6 w-6 text-brand-400" />
<p className="text-sm font-semibold text-slate-700">Aún no hay negocios</p>
<Link to="/admin/businesses?new=1" className="btn btn-primary mt-1">
<Plus className="h-4 w-4" /> Crear el primer negocio
</Link>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
function Kpi({
icon: Icon,
color,
label,
value,
sub,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
color: string;
label: string;
value: string;
sub?: string;
}) {
return (
<div className="card relative overflow-hidden p-5">
<div className="absolute -right-6 -top-6 h-24 w-24 rounded-full opacity-10" style={{ background: color }} />
<div className="flex items-start justify-between">
<div className="min-w-0">
<div className="text-xs font-semibold uppercase tracking-wider text-slate-500">{label}</div>
<div className="mt-1 text-2xl font-extrabold tracking-tight text-slate-900">{value}</div>
{sub && <div className="mt-0.5 text-xs text-slate-500">{sub}</div>}
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-white shadow-soft" style={{ background: color }}>
<Icon className="h-5 w-5" />
</div>
</div>
<div className={cn("mt-3 inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-bold text-emerald-700")}>
<TrendingUp className="h-3 w-3" /> multi-tenant
</div>
</div>
);
}
+862
View File
@@ -0,0 +1,862 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Sparkles,
Clock,
CalendarDays,
User,
Check,
ChevronRight,
ChevronLeft,
Phone,
MapPin,
CalendarCheck,
PartyPopper,
AlertCircle,
Mail,
} from "lucide-react";
import {
getBusiness,
getSlots,
book,
type PublicService,
type PublicSlot,
type BookResponse,
} from "../../lib/publicApi";
import { Spinner } from "../../components/ui";
import { cn, formatCurrency, formatDate, formatTime } from "../../lib/format";
function todayStr() {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
const STEPS = [
{ n: 1, label: "Servicio" },
{ n: 2, label: "Especialista" },
{ n: 3, label: "Horario" },
{ n: 4, label: "Datos" },
];
export default function BookingPage() {
const { slug } = useParams<{ slug: string }>();
const [step, setStep] = useState(1);
const [serviceId, setServiceId] = useState<number | null>(null);
const [employeeId, setEmployeeId] = useState<number | null>(null);
const [selectedDate, setSelectedDate] = useState<string>(todayStr());
const [selectedSlot, setSelectedSlot] = useState<PublicSlot | null>(null);
const [client, setClient] = useState({ name: "", phone: "", email: "", notes: "" });
const [result, setResult] = useState<BookResponse | null>(null);
const businessQuery = useQuery({
queryKey: ["public", "business", slug],
queryFn: () => getBusiness(slug!),
enabled: !!slug,
retry: 1,
});
const data = businessQuery.data;
const business = data?.business;
const services = data?.services ?? [];
const employees = data?.employees ?? [];
const selectedService = useMemo<PublicService | undefined>(
() => services.find((s) => s.id === serviceId),
[services, serviceId]
);
const slotsQuery = useQuery({
queryKey: ["public", "slots", slug, serviceId, selectedDate, employeeId],
queryFn: () => getSlots(slug!, serviceId!, selectedDate, employeeId ?? undefined),
enabled: step >= 3 && !!serviceId && !!selectedDate && !result,
});
const slots = slotsQuery.data?.slots ?? [];
const bookMut = useMutation({
mutationFn: (payload: Parameters<typeof book>[1]) => book(slug!, payload),
onSuccess: (res) => {
setResult(res);
window.scrollTo({ top: 0, behavior: "smooth" });
},
});
useEffect(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, [step]);
const reset = () => {
setStep(1);
setServiceId(null);
setEmployeeId(null);
setSelectedDate(todayStr());
setSelectedSlot(null);
setClient({ name: "", phone: "", email: "", notes: "" });
setResult(null);
bookMut.reset();
};
if (!slug) {
return <CenteredMessage title="Enlace inválido" description="Falta el identificador del negocio." />;
}
if (businessQuery.isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f6f7fb]">
<div className="flex flex-col items-center gap-3 text-slate-500">
<Spinner className="h-6 w-6 text-brand-500" />
<span className="text-sm font-medium">Cargando reservas</span>
</div>
</div>
);
}
if (businessQuery.isError || !business || !data) {
return (
<CenteredMessage
title="No encontramos este negocio"
description={businessQuery.error instanceof Error ? businessQuery.error.message : "El enlace puede ser incorrecto o el negocio no está disponible."}
/>
);
}
if (result) {
return <Confirmation result={result} businessName={business.name} onReset={reset} />;
}
if (!business.booking_enabled) {
return (
<div className="flex min-h-screen flex-col bg-[#f6f7fb]">
<TopBar name={business.name} industry={business.industry} step={0} />
<main className="flex flex-1 items-center justify-center px-5 py-10">
<div className="card w-full max-w-md p-8 text-center shadow-card animate-slide-up">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-amber-100 text-amber-600">
<AlertCircle className="h-7 w-7" />
</div>
<h1 className="mt-4 text-xl font-extrabold text-slate-900">Reservas desactivadas</h1>
<p className="mt-2 text-sm text-slate-500">
{business.name} no está recibiendo reservas en línea en este momento.
</p>
{business.phone && (
<a href={`tel:${business.phone}`} className="btn btn-primary mx-auto mt-5">
<Phone className="h-4 w-4" /> Llamar al {business.phone}
</a>
)}
</div>
</main>
</div>
);
}
const goNext = () => setStep((s) => Math.min(4, s + 1));
const goBack = () => setStep((s) => Math.max(1, s - 1));
const pickService = (id: number) => {
if (serviceId !== id) {
setServiceId(id);
setSelectedSlot(null);
}
};
const pickEmployee = (id: number | null) => {
setEmployeeId(id);
setSelectedSlot(null);
};
const pickSlot = (slot: PublicSlot) => {
setSelectedSlot(slot);
setStep(4);
};
const handleBook = () => {
if (!selectedService || !selectedSlot) return;
if (!client.name.trim()) return;
bookMut.mutate({
service_id: selectedService.id,
employee_id: selectedSlot.employee_id,
start_at: selectedSlot.iso,
client: {
name: client.name.trim(),
email: client.email.trim() || undefined,
phone: client.phone.trim() || undefined,
notes: client.notes.trim() || undefined,
},
});
};
const canContinue =
(step === 1 && !!serviceId) ||
step === 2 ||
(step === 3 && !!selectedSlot) ||
(step === 4 && client.name.trim().length > 0);
const currency = business.currency_symbol || "$";
const primaryLabel = step === 4 ? "Confirmar reserva" : "Continuar";
const onPrimary = step === 4 ? handleBook : goNext;
return (
<div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/50 via-[#f6f7fb] to-[#f6f7fb]">
<TopBar name={business.name} industry={business.industry} step={step} />
<main className="mx-auto w-full max-w-2xl flex-1 px-4 pb-32 pt-5 sm:px-6 md:pb-28">
{step === 1 && (
<Hero business={business} />
)}
<div className="mt-4">
{step === 1 && (
<StepShell
icon={<Sparkles className="h-4 w-4" />}
title="Elige un servicio"
subtitle="Selecciona el servicio que deseas reservar."
>
<ServiceGrid
services={services}
currency={currency}
selectedId={serviceId}
onPick={pickService}
/>
</StepShell>
)}
{step === 2 && (
<StepShell
icon={<User className="h-4 w-4" />}
title="Elige un especialista"
subtitle="¿Tienes una preferencia? O déjalo en cualquiera."
>
<EmployeeList
employees={employees}
selectedId={employeeId}
onPick={pickEmployee}
/>
</StepShell>
)}
{step === 3 && selectedService && (
<StepShell
icon={<CalendarDays className="h-4 w-4" />}
title="Elige fecha y hora"
subtitle={`${selectedService.name} · ${selectedService.duration_min} min · ${formatCurrency(selectedService.price, currency)}`}
>
<DateTimePicker
date={selectedDate}
onDate={(d) => {
setSelectedDate(d);
setSelectedSlot(null);
}}
min={todayStr()}
slots={slots}
loading={slotsQuery.isLoading}
isError={slotsQuery.isError}
selectedSlot={selectedSlot}
onPick={pickSlot}
/>
</StepShell>
)}
{step === 4 && selectedService && selectedSlot && (
<StepShell
icon={<CalendarCheck className="h-4 w-4" />}
title="Tus datos"
subtitle="Necesitamos algunos datos para confirmar tu cita."
>
<DetailsForm
client={client}
setClient={setClient}
summary={
<>
<SummaryRow label="Servicio" value={selectedService.name} />
<SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long" })} />
<SummaryRow label="Hora" value={formatTime(selectedSlot.iso)} />
<SummaryRow label="Especialista" value={selectedSlot.employee_name} />
</>
}
/>
</StepShell>
)}
</div>
</main>
<ActionBar
hidden={!!result}
step={step}
goBack={goBack}
onPrimary={onPrimary}
primaryLabel={primaryLabel}
canContinue={canContinue}
busy={bookMut.isPending}
priceLabel={
selectedService ? formatCurrency(selectedService.price, currency) : undefined
}
serviceName={selectedService?.name}
slotLabel={selectedSlot ? `${formatDate(selectedSlot.iso, { weekday: "short", day: "numeric", month: "short" })} · ${selectedSlot.time}` : undefined}
/>
<FooterNote name={business.name} />
</div>
);
}
function TopBar({ name, industry, step }: { name: string; industry: string; step: number }) {
const current = STEPS.find((s) => s.n === step);
return (
<header className="sticky top-0 z-30 border-b border-slate-200/70 bg-white/85 backdrop-blur-md">
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-soft">
<Sparkles className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-extrabold text-slate-900">{name}</div>
<div className="truncate text-[11px] text-slate-500">{industry}</div>
</div>
</div>
{step > 0 && (
<div className="flex items-center gap-1.5">
<span className="hidden text-[11px] font-semibold uppercase tracking-wide text-slate-400 sm:inline">
Paso {step} de 4
</span>
<div className="hidden items-center gap-1 md:flex">
{STEPS.map((s) => (
<div
key={s.n}
className={cn(
"flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold transition-colors",
s.n === step
? "bg-brand-500 text-white"
: s.n < step
? "bg-brand-50 text-brand-700"
: "bg-slate-100 text-slate-400"
)}
>
{s.n < step ? <Check className="h-3 w-3" /> : <span>{s.n}</span>}
<span>{s.label}</span>
</div>
))}
</div>
<div className="flex items-center gap-1.5 rounded-full bg-brand-50 px-3 py-1 text-xs font-bold text-brand-700 md:hidden">
<span>{step}</span>
<span className="font-semibold text-brand-400">/</span>
<span className="text-brand-400">4</span>
<span className="text-brand-300">·</span>
<span>{current?.label}</span>
</div>
</div>
)}
</div>
</header>
);
}
function Hero({ business }: { business: { name: string; industry: string; phone: string | null; address: string | null } }) {
return (
<section className="overflow-hidden rounded-2xl bg-gradient-to-br from-brand-600 via-brand-600 to-brand-800 p-6 text-white shadow-card animate-fade-in sm:p-8">
<div className="relative z-10">
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1 text-xs font-semibold backdrop-blur">
<Sparkles className="h-3 w-3" /> Reservas en línea
</span>
<h1 className="mt-3 text-2xl font-extrabold leading-tight tracking-tight sm:text-3xl">
{business.name}
</h1>
<p className="mt-1.5 max-w-md text-sm text-brand-100">
Reserva tu cita en {business.industry.toLowerCase()} en pocos segundos. Sin llamadas, sin esperas.
</p>
{(business.address || business.phone) && (
<div className="mt-4 flex flex-wrap gap-2 text-xs">
{business.address && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-white/10 px-2.5 py-1.5 backdrop-blur">
<MapPin className="h-3.5 w-3.5" /> {business.address}
</span>
)}
{business.phone && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-white/10 px-2.5 py-1.5 backdrop-blur">
<Phone className="h-3.5 w-3.5" /> {business.phone}
</span>
)}
</div>
)}
</div>
</section>
);
}
function StepShell({
icon,
title,
subtitle,
children,
}: {
icon: React.ReactNode;
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<div className="animate-slide-up">
<div className="mb-4 flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-50 text-brand-600">
{icon}
</div>
<div>
<h2 className="text-base font-extrabold text-slate-900 sm:text-lg">{title}</h2>
{subtitle && <p className="text-xs text-slate-500 sm:text-sm">{subtitle}</p>}
</div>
</div>
{children}
</div>
);
}
function ServiceGrid({
services,
currency,
selectedId,
onPick,
}: {
services: PublicService[];
currency: string;
selectedId: number | null;
onPick: (id: number) => void;
}) {
if (services.length === 0) {
return (
<div className="card p-8 text-center text-sm text-slate-500">
Este negocio aún no tiene servicios disponibles para reservar.
</div>
);
}
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{services.map((s) => {
const active = s.id === selectedId;
return (
<button
key={s.id}
type="button"
onClick={() => onPick(s.id)}
className={cn(
"group relative flex flex-col gap-2 rounded-2xl border p-4 text-left transition-all",
active
? "border-brand-500 bg-brand-50/60 shadow-soft ring-2 ring-brand-500/20"
: "border-slate-200 bg-white hover:border-brand-300 hover:shadow-soft"
)}
>
<div className="flex items-start justify-between gap-2">
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-bold text-white"
style={{ background: s.color || "#3b66ff" }}
>
{s.category}
</span>
{active && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 text-white">
<Check className="h-3 w-3" />
</span>
)}
</div>
<div>
<div className="font-bold text-slate-900">{s.name}</div>
{s.description && (
<p className="mt-0.5 line-clamp-2 text-xs text-slate-500">{s.description}</p>
)}
</div>
<div className="mt-1 flex items-center justify-between">
<span className="inline-flex items-center gap-1 text-xs font-medium text-slate-500">
<Clock className="h-3.5 w-3.5" /> {s.duration_min} min
</span>
<span className="text-base font-extrabold text-brand-700">
{formatCurrency(s.price, currency)}
</span>
</div>
</button>
);
})}
</div>
);
}
function EmployeeList({
employees,
selectedId,
onPick,
}: {
employees: { id: number; name: string; role: string; color: string }[];
selectedId: number | null;
onPick: (id: number | null) => void;
}) {
const anyActive = selectedId === null;
return (
<div className="space-y-2.5">
<button
type="button"
onClick={() => onPick(null)}
className={cn(
"flex w-full items-center gap-3 rounded-2xl border p-3.5 text-left transition-all",
anyActive
? "border-brand-500 bg-brand-50/60 shadow-soft ring-2 ring-brand-500/20"
: "border-slate-200 bg-white hover:border-brand-300"
)}
>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-brand-500 to-brand-700 text-white">
<Sparkles className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="font-bold text-slate-900">Cualquiera</div>
<div className="text-xs text-slate-500">Asigna al especialista disponible</div>
</div>
{anyActive && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 text-white">
<Check className="h-3 w-3" />
</span>
)}
</button>
{employees.map((e) => {
const active = e.id === selectedId;
return (
<button
key={e.id}
type="button"
onClick={() => onPick(e.id)}
className={cn(
"flex w-full items-center gap-3 rounded-2xl border p-3.5 text-left transition-all",
active
? "border-brand-500 bg-brand-50/60 shadow-soft ring-2 ring-brand-500/20"
: "border-slate-200 bg-white hover:border-brand-300"
)}
>
<div
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
style={{ background: e.color || "#3b66ff" }}
>
{e.name.split(" ").filter(Boolean).slice(0, 2).map((p) => p[0]?.toUpperCase()).join("")}
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-bold text-slate-900">{e.name}</div>
<div className="truncate text-xs text-slate-500">{e.role}</div>
</div>
{active && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-brand-500 text-white">
<Check className="h-3 w-3" />
</span>
)}
</button>
);
})}
</div>
);
}
function DateTimePicker({
date,
onDate,
min,
slots,
loading,
isError,
selectedSlot,
onPick,
}: {
date: string;
onDate: (d: string) => void;
min: string;
slots: PublicSlot[];
loading: boolean;
isError: boolean;
selectedSlot: PublicSlot | null;
onPick: (s: PublicSlot) => void;
}) {
return (
<div className="space-y-4">
<div>
<label className="label flex items-center gap-1.5">
<CalendarDays className="h-3.5 w-3.5 text-slate-400" /> Fecha
</label>
<input
type="date"
className="input"
value={date}
min={min}
onChange={(e) => e.target.value && onDate(e.target.value)}
/>
</div>
<div>
<label className="label flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-slate-400" /> Horario disponible
</label>
<div className="card p-4">
{loading && (
<div className="flex items-center justify-center gap-2 py-8 text-slate-400">
<Spinner className="text-brand-500" />
<span className="text-sm font-medium">Buscando horarios</span>
</div>
)}
{!loading && isError && (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<AlertCircle className="h-6 w-6 text-rose-400" />
<p className="text-sm text-slate-500">No pudimos cargar los horarios. Intenta otra fecha.</p>
</div>
)}
{!loading && !isError && slots.length === 0 && (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<CalendarDays className="h-6 w-6 text-slate-300" />
<p className="text-sm font-medium text-slate-600">Sin horarios disponibles</p>
<p className="text-xs text-slate-400">Prueba con otra fecha o especialista.</p>
</div>
)}
{!loading && !isError && slots.length > 0 && (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{slots.map((slot) => {
const active = selectedSlot?.iso === slot.iso && selectedSlot?.employee_id === slot.employee_id;
return (
<button
key={`${slot.iso}-${slot.employee_id}`}
type="button"
onClick={() => onPick(slot)}
className={cn(
"rounded-xl border px-2 py-2.5 text-sm font-bold transition-all",
active
? "border-brand-500 bg-brand-500 text-white shadow-soft"
: "border-slate-200 bg-white text-slate-700 hover:border-brand-400 hover:bg-brand-50"
)}
>
{slot.time}
</button>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
function DetailsForm({
client,
setClient,
summary,
}: {
client: { name: string; phone: string; email: string; notes: string };
setClient: (c: { name: string; phone: string; email: string; notes: string }) => void;
summary: React.ReactNode;
}) {
return (
<div className="space-y-4">
<div className="card divide-y divide-slate-100 p-2">{summary}</div>
<div className="card p-4">
<div className="grid gap-3">
<div>
<label className="label">Nombre completo *</label>
<div className="relative">
<User className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
className="input pl-9"
value={client.name}
onChange={(e) => setClient({ ...client, name: e.target.value })}
placeholder="Tu nombre"
autoFocus
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label">Teléfono</label>
<div className="relative">
<Phone className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
className="input pl-9"
inputMode="tel"
value={client.phone}
onChange={(e) => setClient({ ...client, phone: e.target.value })}
placeholder="+52 …"
/>
</div>
</div>
<div>
<label className="label">Correo</label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="email"
className="input pl-9"
value={client.email}
onChange={(e) => setClient({ ...client, email: e.target.value })}
placeholder="[email protected]"
/>
</div>
</div>
</div>
<div>
<label className="label">Notas (opcional)</label>
<textarea
className="textarea"
rows={2}
value={client.notes}
onChange={(e) => setClient({ ...client, notes: e.target.value })}
placeholder="Preferencias, alergias, indicaciones…"
/>
</div>
</div>
</div>
</div>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-3 px-3 py-2.5">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">{label}</span>
<span className="text-right text-sm font-bold text-slate-800">{value}</span>
</div>
);
}
function ActionBar({
hidden,
step,
goBack,
onPrimary,
primaryLabel,
canContinue,
busy,
priceLabel,
serviceName,
slotLabel,
}: {
hidden: boolean;
step: number;
goBack: () => void;
onPrimary: () => void;
primaryLabel: string;
canContinue: boolean;
busy: boolean;
priceLabel?: string;
serviceName?: string;
slotLabel?: string;
}) {
if (hidden) return null;
return (
<div className="sticky bottom-0 z-30 border-t border-slate-200/70 bg-white/90 backdrop-blur-md">
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-4 py-3 sm:px-6">
{step > 1 && (
<button onClick={goBack} className="btn btn-ghost shrink-0" type="button">
<ChevronLeft className="h-4 w-4" /> <span className="hidden sm:inline">Atrás</span>
</button>
)}
<div className="min-w-0 flex-1">
{serviceName ? (
<>
<div className="truncate text-sm font-bold text-slate-900">{serviceName}</div>
<div className="truncate text-xs text-slate-500">
{slotLabel ? slotLabel : priceLabel}
</div>
</>
) : (
<div className="text-sm font-medium text-slate-400">
{step === 1 ? "Selecciona un servicio" : "Continúa para reservar"}
</div>
)}
</div>
<button
onClick={onPrimary}
disabled={!canContinue || busy}
className="btn btn-primary shrink-0"
type="button"
>
{busy ? <Spinner /> : <>{primaryLabel} <ChevronRight className="h-4 w-4" /></>}
</button>
</div>
</div>
);
}
function FooterNote({ name }: { name: string }) {
return (
<div className="border-t border-slate-200/60 bg-white/50 px-4 py-3 text-center text-[11px] text-slate-400">
Reserva en línea · {name} · powered by AgendaPro
</div>
);
}
function Confirmation({
result,
businessName,
onReset,
}: {
result: BookResponse;
businessName: string;
onReset: () => void;
}) {
const { appointment } = result;
const currency = result.business.currency_symbol || "$";
return (
<div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/60 to-[#f6f7fb]">
<TopBar name={businessName} industry="Reserva confirmada" step={0} />
<main className="flex flex-1 items-center justify-center px-5 py-10">
<div className="w-full max-w-md animate-slide-up">
<div className="card overflow-hidden p-0 text-center shadow-card">
<div className="relative overflow-hidden bg-gradient-to-br from-emerald-500 to-emerald-600 px-6 py-8 text-white">
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full bg-white/15 blur-2xl" />
<div className="relative z-10 mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-white/20 backdrop-blur">
<PartyPopper className="h-8 w-8" />
</div>
<h1 className="relative z-10 mt-3 text-xl font-extrabold">¡Reserva confirmada!</h1>
<p className="relative z-10 mt-1 text-sm text-emerald-50">
Te esperamos en {businessName}.
</p>
</div>
<div className="space-y-1 p-5 text-left">
<SummaryRow label="Servicio" value={appointment.service_name} />
<div className="h-px bg-slate-100" />
<SummaryRow label="Fecha" value={formatDate(appointment.start_at, { weekday: "long" })} />
<div className="h-px bg-slate-100" />
<SummaryRow label="Hora" value={formatTime(appointment.start_at)} />
<div className="h-px bg-slate-100" />
<SummaryRow label="Especialista" value={appointment.employee_name} />
<div className="h-px bg-slate-100" />
<div className="flex items-center justify-between gap-3 px-3 py-3">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">Total</span>
<span className="text-lg font-extrabold text-brand-700">
{formatCurrency(appointment.price, currency)}
</span>
</div>
</div>
</div>
<button onClick={onReset} className="btn btn-secondary mt-4 w-full" type="button">
<ChevronLeft className="h-4 w-4" /> Reservar otra cita
</button>
<p className="mt-3 text-center text-xs text-slate-400">
Guarda esta página o toma una captura con los datos de tu cita.
</p>
</div>
</main>
</div>
);
}
function CenteredMessage({ title, description }: { title: string; description: string }) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f6f7fb] px-5">
<div className="card w-full max-w-md p-8 text-center shadow-card">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-rose-100 text-rose-500">
<AlertCircle className="h-6 w-6" />
</div>
<h1 className="mt-3 text-lg font-extrabold text-slate-900">{title}</h1>
<p className="mt-1.5 text-sm text-slate-500">{description}</p>
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
},
colors: {
brand: {
50: "#eef4ff",
100: "#dbe6ff",
200: "#bfd2ff",
300: "#93b4ff",
400: "#608bff",
500: "#3b66ff",
600: "#2447f5",
700: "#1c36dc",
800: "#1d31b1",
900: "#1e2f8c",
950: "#161e54",
},
accent: {
50: "#fef7ee",
100: "#fdedd6",
200: "#fbd7ac",
300: "#f8ba77",
400: "#f49342",
500: "#f17616",
600: "#df5d0b",
700: "#b9440b",
800: "#943612",
900: "#7a2f12",
},
},
boxShadow: {
soft: "0 2px 12px -2px rgba(15, 23, 42, 0.08)",
card: "0 6px 24px -6px rgba(15, 23, 42, 0.12)",
},
borderRadius: {
xl: "0.9rem",
"2xl": "1.25rem",
},
animation: {
"fade-in": "fadeIn 0.25s ease-out",
"slide-up": "slideUp 0.3s ease-out",
"scale-in": "scaleIn 0.2s ease-out",
},
keyframes: {
fadeIn: {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
slideUp: {
"0%": { opacity: "0", transform: "translateY(8px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
scaleIn: {
"0%": { opacity: "0", transform: "scale(0.96)" },
"100%": { opacity: "1", transform: "scale(1)" },
},
},
},
},
plugins: [],
};
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@server/*": ["server/*"]
}
},
"include": ["src", "server", "shared", "vite.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"target": "ES2022",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
+196
View File
@@ -0,0 +1,196 @@
import { chromium } from "playwright";
import fs from "node:fs";
const BASE = "http://localhost:5173";
const OUT = "screenshots";
fs.mkdirSync(OUT, { recursive: true });
const VIEWPORTS = [
{ name: "mobile", width: 375, height: 720 },
{ name: "tablet", width: 768, height: 1024 },
{ name: "desktop", width: 1280, height: 850 },
{ name: "wide", width: 1536, height: 900 },
];
const PAGES = [
{ name: "login", path: "/", login: false },
{ name: "dashboard", path: "/dashboard", login: true, role: "owner" },
{ name: "calendar-week", path: "/calendar", login: true, role: "owner", waitFor: ".fc" },
{ name: "employees", path: "/employees", login: true, role: "owner" },
{ name: "services", path: "/services", login: true, role: "owner" },
{ name: "clients", path: "/clients", login: true, role: "owner" },
{ name: "tickets", path: "/tickets", login: true, role: "owner" },
{ name: "cash", path: "/cash", login: true, role: "owner" },
{ name: "notifications", path: "/notifications", login: true, role: "owner" },
{ name: "settings", path: "/settings", login: true, role: "owner" },
{ name: "emp-calendar", path: "/calendar", login: true, role: "employee", waitFor: ".fc" },
{ name: "admin-overview", path: "/admin", login: true, role: "admin" },
{ name: "admin-businesses", path: "/admin/businesses", login: true, role: "admin" },
{ name: "public-booking", path: "/b/lumiere-estetica-spa", login: false },
];
const results = [];
const consoleErrors = [];
const pageErrors = [];
async function login(browser, viewport, role) {
const page = await browser.newPage({ viewport });
page.on("console", (m) => {
if (m.type() === "error") consoleErrors.push({ where: "login", text: m.text() });
});
page.on("pageerror", (e) => pageErrors.push({ where: "login", text: e.message }));
await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
await page.waitForTimeout(400);
const email =
role === "admin"
? "[email protected]"
: role === "owner"
? "[email protected]"
: "[email protected]";
// Fill the login form
await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', "demo1234");
await page.click('button[type="submit"]');
await page.waitForURL(/\/(dashboard|calendar|admin)/, { timeout: 8000 });
await page.waitForTimeout(8000); // let queries resolve
// Save storage state
const state = await page.context().storageState();
await page.close();
return state;
}
async function measureOverflow(page) {
return page.evaluate(() => {
const docWidth = document.documentElement.clientWidth;
const bodyWidth = document.body.scrollWidth;
const htmlWidth = document.documentElement.scrollWidth;
const overflow = Math.max(bodyWidth, htmlWidth) - docWidth;
// find offending elements
const offenders = [];
document.querySelectorAll("*").forEach((el) => {
const r = el.getBoundingClientRect();
if (r.right > docWidth + 2 && r.width > 50) {
offenders.push({
tag: el.tagName.toLowerCase(),
cls: (el.className || "").toString().slice(0, 60),
right: Math.round(r.right),
width: Math.round(r.width),
});
}
});
return { docWidth, scrollWidth: Math.max(bodyWidth, htmlWidth), overflow, offenders: offenders.slice(0, 5) };
});
}
async function run() {
const browser = await chromium.launch();
// Pre-login for owner, employee and admin to grab storage state
const ownerState = await login(browser, VIEWPORTS[2], "owner");
const empState = await login(browser, VIEWPORTS[2], "employee");
const adminState = await login(browser, VIEWPORTS[2], "admin");
for (const vp of VIEWPORTS) {
for (const p of PAGES) {
const tag = `${vp.name}/${p.name}`;
const state = p.role === "employee" ? empState : p.role === "admin" ? adminState : ownerState;
const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, storageState: state });
const page = await context.newPage();
const localErrors = [];
page.on("console", (m) => {
if (m.type() === "error") {
localErrors.push(m.text());
consoleErrors.push({ where: tag, text: m.text() });
}
});
page.on("pageerror", (e) => {
localErrors.push("PAGEERROR: " + e.message);
pageErrors.push({ where: tag, text: e.message });
});
try {
await page.goto(`${BASE}${p.path}`, { waitUntil: "networkidle", timeout: 20000 });
if (p.waitFor) await page.waitForSelector(p.waitFor, { timeout: 15000 });
await page.waitForTimeout(2500);
const overflow = await measureOverflow(page);
const file = `${OUT}/${p.name}--${vp.width}.png`;
await page.screenshot({ path: file, fullPage: true });
// also a viewport-only shot for above-the-fold view
await page.screenshot({ path: `${OUT}/${p.name}--${vp.width}-fold.png` });
results.push({ tag, vp: vp.width, status: "ok", overflow: overflow.overflow, offenders: overflow.offenders, errors: localErrors });
console.log(`${tag} (overflow=${overflow.overflow}px${overflow.offenders.length ? ", " + overflow.offenders.length + " offenders" : ""})`);
} catch (e) {
results.push({ tag, vp: vp.width, status: "fail", error: e.message, errors: localErrors });
console.log(`${tag}${e.message.split("\n")[0]}`);
} finally {
await context.close();
}
}
}
// Interaction test: open appointment modal on mobile + desktop
for (const vp of [VIEWPORTS[0], VIEWPORTS[2]]) {
const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, storageState: ownerState });
const page = await context.newPage();
try {
await page.goto(`${BASE}/calendar`, { waitUntil: "networkidle" });
await page.waitForSelector(".fc", { timeout: 15000 });
await page.waitForTimeout(2000);
// open mobile menu on small screens (screenshot only, then reset)
if (vp.width < 1024) {
const menuBtn = await page.locator('button:has(svg.lucide-menu)').first();
if (await menuBtn.isVisible()) {
await menuBtn.click();
await page.waitForTimeout(600);
await page.screenshot({ path: `${OUT}/calendar-mobile-menu--${vp.width}.png` });
// reset state by reloading
await page.reload({ waitUntil: "networkidle" });
await page.waitForSelector(".fc", { timeout: 15000 });
await page.waitForTimeout(1500);
}
}
// Click "Nueva cita"
const newBtn = page.getByRole("button", { name: /Nueva cita/ }).first();
if (await newBtn.isVisible()) {
await newBtn.click();
await page.waitForTimeout(1500);
await page.screenshot({ path: `${OUT}/appointment-modal--${vp.width}.png` });
// check modal scroll
const modalOverflow = await page.evaluate(() => {
const modal = document.querySelector('[class*="fixed inset-0"] [class*="overflow-y-auto"]');
return modal ? { scrollH: modal.scrollHeight, clientH: modal.clientHeight } : null;
});
results.push({ tag: `interaction/modal@${vp.width}`, status: "ok", modalOverflow });
}
} catch (e) {
console.log(`✗ interaction@${vp.width}${e.message.split("\n")[0]}`);
} finally {
await context.close();
}
}
await browser.close();
fs.writeFileSync("screenshots/report.json", JSON.stringify({ results, consoleErrors, pageErrors }, null, 2));
// Summary
const failed = results.filter((r) => r.status === "fail");
const overflowIssues = results.filter((r) => r.overflow > 4);
const totalErrors = consoleErrors.length + pageErrors.length;
console.log("\n========== SUMMARY ==========");
console.log(`Screens/visits: ${results.length}`);
console.log(`Failed loads: ${failed.length}`);
console.log(`Horizontal overflow issues (>4px): ${overflowIssues.length}`);
console.log(` ${overflowIssues.map((o) => `${o.tag}(+${o.overflow}px)`).join(", ") || " none"}`);
console.log(`Console/page errors: ${totalErrors}`);
if (totalErrors) {
const uniq = [...new Set([...consoleErrors.map((e) => e.text), ...pageErrors.map((e) => e.text)])];
uniq.slice(0, 10).forEach((e) => console.log(`${e.slice(0, 180)}`));
}
console.log(`\nReport → screenshots/report.json`);
console.log(`Screenshots → screenshots/*.png`);
}
run().catch((e) => {
console.error(e);
process.exit(1);
});
+43
View File
@@ -0,0 +1,43 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
},
},
},
build: {
outDir: "dist",
sourcemap: false,
chunkSizeWarningLimit: 1200,
rollupOptions: {
output: {
manualChunks: {
react: ["react", "react-dom", "react-router-dom"],
calendar: [
"@fullcalendar/core",
"@fullcalendar/react",
"@fullcalendar/daygrid",
"@fullcalendar/timegrid",
"@fullcalendar/interaction",
],
charts: ["recharts"],
query: ["@tanstack/react-query"],
icons: ["lucide-react"],
},
},
},
},
});