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:
+320
@@ -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();
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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:00–20: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,
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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) });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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')`
|
||||
),
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user