AgendaPro v1.0 - multi-tenant SaaS, mobile calendar, public booking
Multi-tenant scheduling SaaS (AgendaPro-equivalent): - Backend: Node + Express + node:sqlite, multi-tenant (admin/owner/employee), versioned migrations, 4 templates (estetica-spa, barberia, clinica, blank). - Frontend: React 18 + TS + Vite + Tailwind, FullCalendar drag-and-drop, Recharts dashboard, mobile-first (day/list views on mobile). - Features: calendar+services+employees+clients+tickets, dashboard with best employee/service, top tickets, top/frequent clients, commissions, cancellation policy + no-show tracking, public online booking (/b/:slug), cash register (cierre de caja), reminders/notifications center. - Verification: 65/65 e2e API, 58/58 visual (0 overflow, 0 console errors), typecheck clean, vite build OK.
This commit is contained in:
@@ -0,0 +1,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 });
|
||||
}
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user