AgendaPro v1.0 - multi-tenant SaaS, mobile calendar, public booking

Multi-tenant scheduling SaaS (AgendaPro-equivalent):
- Backend: Node + Express + node:sqlite, multi-tenant (admin/owner/employee),
  versioned migrations, 4 templates (estetica-spa, barberia, clinica, blank).
- Frontend: React 18 + TS + Vite + Tailwind, FullCalendar drag-and-drop,
  Recharts dashboard, mobile-first (day/list views on mobile).
- Features: calendar+services+employees+clients+tickets, dashboard with
  best employee/service, top tickets, top/frequent clients, commissions,
  cancellation policy + no-show tracking, public online booking (/b/:slug),
  cash register (cierre de caja), reminders/notifications center.
- Verification: 65/65 e2e API, 58/58 visual (0 overflow, 0 console errors),
  typecheck clean, vite build OK.
This commit is contained in:
AgendaPro Dev
2026-07-25 13:45:53 -06:00
commit e8d2435cc2
63 changed files with 16539 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
import type {
Appointment,
Business,
Client,
DashboardOverview,
Employee,
RankedClient,
RankedEmployee,
RankedService,
RevenuePoint,
Service,
Ticket,
User,
CategorySlice,
} from "../../shared/types";
const BASE = "/api";
let token: string | null = localStorage.getItem("ap_token");
export function setToken(t: string | null) {
token = t;
if (t) localStorage.setItem("ap_token", t);
else localStorage.removeItem("ap_token");
}
export function getToken() {
return token;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(init?.headers as Record<string, string>),
};
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetch(`${BASE}${path}`, { ...init, headers });
if (!res.ok) {
let message = `Error ${res.status}`;
try {
const body = await res.json();
message = body.error || message;
} catch {
/* ignore */
}
const e = new Error(message) as Error & { status?: number };
e.status = res.status;
throw e;
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const api = {
auth: {
login: (email: string, password: string) =>
request<{ token: string; user: User }>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
}),
demoUsers: () => request<{ users: any[] }>("/auth/demo-users"),
},
admin: {
overview: () => request<any>("/admin/overview"),
templates: () => request<{ templates: any[] }>("/admin/templates"),
businesses: () => request<{ businesses: any[] }>("/admin/businesses"),
business: (id: number) => request<{ business: Business; stats: any }>(`/admin/businesses/${id}`),
createBusiness: (data: any) =>
request<any>("/admin/businesses", { method: "POST", body: JSON.stringify(data) }),
updateBusiness: (id: number, data: any) =>
request<any>(`/admin/businesses/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteBusiness: (id: number) => request<{ ok: boolean }>(`/admin/businesses/${id}`, { method: "DELETE" }),
seedTemplate: (id: number, data: any) =>
request<any>(`/admin/businesses/${id}/seed-template`, { method: "POST", body: JSON.stringify(data) }),
resetDemo: (id: number, data: any) =>
request<any>(`/admin/businesses/${id}/reset-demo`, { method: "POST", body: JSON.stringify(data) }),
},
business: {
get: () => request<{ business: Business }>("/business"),
},
employees: {
list: () => request<{ employees: Employee[] }>("/employees"),
create: (data: any) => request<{ employee: Employee }>("/employees", { method: "POST", body: JSON.stringify(data) }),
update: (id: number, data: any) =>
request<{ employee: Employee }>(`/employees/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
remove: (id: number) => request<{ ok: boolean }>(`/employees/${id}`, { method: "DELETE" }),
},
services: {
list: (params?: { employee_id?: number; active?: boolean }) => {
const q = new URLSearchParams();
if (params?.employee_id) q.set("employee_id", String(params.employee_id));
if (params?.active) q.set("active", "true");
return request<{ services: Service[] }>(`/services${q.size ? `?${q}` : ""}`);
},
create: (data: any) => request<{ service: Service }>("/services", { method: "POST", body: JSON.stringify(data) }),
update: (id: number, data: any) =>
request<{ service: Service }>(`/services/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
remove: (id: number) => request<{ ok: boolean }>(`/services/${id}`, { method: "DELETE" }),
},
clients: {
list: (q?: string) => request<{ clients: Client[] }>(`/clients${q ? `?q=${encodeURIComponent(q)}` : ""}`),
create: (data: any) => request<{ client: Client }>("/clients", { method: "POST", body: JSON.stringify(data) }),
update: (id: number, data: any) =>
request<{ client: Client }>(`/clients/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
remove: (id: number) => request<{ ok: boolean }>(`/clients/${id}`, { method: "DELETE" }),
},
appointments: {
list: (params: { from?: string; to?: string; employee_id?: number; client_id?: number; status?: string; limit?: number }) => {
const q = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => v !== undefined && q.set(k, String(v)));
return request<{ appointments: Appointment[] }>(`/appointments?${q}`);
},
create: (data: any) =>
request<{ appointment: Appointment }>("/appointments", { method: "POST", body: JSON.stringify(data) }),
update: (id: number, data: any) =>
request<{ appointment: Appointment }>(`/appointments/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
complete: (id: number, data: { tip?: number; payment_method?: string }) =>
request<{ appointment: Appointment }>(`/appointments/${id}/complete`, {
method: "POST",
body: JSON.stringify(data),
}),
remove: (id: number) => request<{ ok: boolean }>(`/appointments/${id}`, { method: "DELETE" }),
},
dashboard: {
overview: () => request<DashboardOverview>("/dashboard/overview"),
bestEmployees: (range = "30") => request<{ employees: RankedEmployee[] }>(`/dashboard/best-employees?range=${range}`),
bestServices: (range = "30") => request<{ services: RankedService[] }>(`/dashboard/best-services?range=${range}`),
topTickets: (range = "30", limit = 8) =>
request<{ tickets: { ticket: Ticket }[] }>(`/dashboard/top-tickets?range=${range}&limit=${limit}`),
topClients: (range = "30", limit = 6) =>
request<{ clients: RankedClient[] }>(`/dashboard/top-clients?range=${range}&limit=${limit}`),
frequentClients: (range = "30", limit = 6) =>
request<{ clients: RankedClient[] }>(`/dashboard/frequent-clients?range=${range}&limit=${limit}`),
revenueTrend: (days = 30) => request<{ points: RevenuePoint[] }>(`/dashboard/revenue-trend?days=${days}`),
revenueByCategory: (range = "30") =>
request<{ slices: CategorySlice[] }>(`/dashboard/revenue-by-category?range=${range}`),
tickets: (limit = 50) => request<{ tickets: Ticket[] }>(`/dashboard/tickets?limit=${limit}`),
commissions: (range = "30") => request<{ rows: any[]; total: number }>(`/dashboard/commissions?range=${range}`),
},
settings: {
get: () => request<{ settings: any }>("/settings"),
update: (data: any) => request<{ settings: any }>("/settings", { method: "PATCH", body: JSON.stringify(data) }),
},
cash: {
session: () => request<{ session: any; stats: any }>("/cash/session"),
sessions: () => request<{ sessions: any[] }>("/cash/sessions"),
open: (data: any) => request<{ session: any; stats: any }>("/cash/open", { method: "POST", body: JSON.stringify(data) }),
close: (data: any) => request<{ session: any; stats: any; expected: number }>("/cash/close", { method: "POST", body: JSON.stringify(data) }),
entries: (sessionId?: number) =>
request<{ entries: any[] }>(`/cash/entries${sessionId ? `?session_id=${sessionId}` : ""}`),
addEntry: (data: any) => request<{ entry: any; stats: any }>("/cash/entries", { method: "POST", body: JSON.stringify(data) }),
removeEntry: (id: number) => request<{ ok: boolean; stats: any }>(`/cash/entries/${id}`, { method: "DELETE" }),
},
notifications: {
list: (status?: string) => request<{ notifications: any[] }>(`/notifications${status ? `?status=${status}` : ""}`),
stats: () => request<any>("/notifications/stats"),
regenerate: () => request<{ ok: boolean }>("/notifications/regenerate", { method: "POST" }),
send: (id: number) => request<{ ok: boolean }>(`/notifications/${id}/send`, { method: "POST" }),
cancel: (id: number) => request<{ ok: boolean }>(`/notifications/${id}/cancel`, { method: "POST" }),
},
};
+100
View File
@@ -0,0 +1,100 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { api, setToken, getToken } from "./api";
import type { Business, User } from "../../shared/types";
interface AuthState {
user: User | null;
business: Business | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
switchUser: (userId: number, email: string) => Promise<void>;
refreshBusiness: () => Promise<void>;
}
const Ctx = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [business, setBusiness] = useState<Business | null>(null);
const [loading, setLoading] = useState(true);
const loadBusiness = useCallback(async (role?: string) => {
if (role === "admin") return; // admins have no business
try {
const { business } = await api.business.get();
setBusiness(business);
} catch {
/* ignore */
}
}, []);
useEffect(() => {
const t = getToken();
if (!t) {
setLoading(false);
return;
}
// Token is the user id; we need the user object. Quick login isn't re-called,
// so we hydrate by calling /me. Server stores users in db; fetch demo list and match.
(async () => {
try {
const res = await fetch("/api/auth/me", { headers: { authorization: `Bearer ${t}` } });
if (!res.ok) throw new Error();
const { user } = (await res.json()) as { user: User };
setUser(user);
await loadBusiness(user.role);
} catch {
setToken(null);
} finally {
setLoading(false);
}
})();
}, [loadBusiness]);
const login = useCallback(
async (email: string, password: string) => {
const { token, user } = await api.auth.login(email, password);
setToken(token);
setUser(user);
await loadBusiness(user.role);
},
[loadBusiness]
);
const switchUser = useCallback(
async (userId: number, email: string) => {
// Quick demo: try logging in with known demo password
try {
const { token, user } = await api.auth.login(email, "demo1234");
setToken(token);
setUser(user);
await loadBusiness(user.role);
void userId;
} catch (e) {
throw e;
}
},
[loadBusiness]
);
const logout = useCallback(() => {
setToken(null);
setUser(null);
setBusiness(null);
}, []);
const value = useMemo(
() => ({ user, business, loading, login, logout, switchUser, refreshBusiness: loadBusiness }),
[user, business, loading, login, logout, switchUser, loadBusiness]
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useAuth() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
+98
View File
@@ -0,0 +1,98 @@
import { clsx, type ClassValue } from "clsx";
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
export function formatCurrency(amount: number, symbol = "$") {
const n = Number(amount) || 0;
const formatted = n.toLocaleString("es-MX", {
minimumFractionDigits: n % 1 === 0 ? 0 : 2,
maximumFractionDigits: 2,
});
return `${symbol}${formatted}`;
}
export function formatNumber(n: number) {
return (Number(n) || 0).toLocaleString("es-MX");
}
export function formatDate(iso: string | null | undefined, opts: Intl.DateTimeFormatOptions = {}) {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
return d.toLocaleDateString("es-MX", { day: "numeric", month: "short", year: "numeric", ...opts });
}
export function formatTime(iso: string | null) {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
return d.toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit" });
}
export function formatRelative(iso: string | null) {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
const diff = d.getTime() - Date.now();
const absMin = Math.abs(Math.round(diff / 60000));
const rtf = new Intl.RelativeTimeFormat("es-MX", { numeric: "auto" });
if (absMin < 60) return rtf.format(Math.round(diff / 60000), "minute");
if (absMin < 60 * 24) return rtf.format(Math.round(diff / 3600000), "hour");
if (absMin < 60 * 24 * 30) return rtf.format(Math.round(diff / 86400000), "day");
if (absMin < 60 * 24 * 365) return rtf.format(Math.round(diff / (86400000 * 30)), "month");
return rtf.format(Math.round(diff / (86400000 * 365)), "year");
}
export function initials(name: string) {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase())
.join("");
}
export function statusLabel(status: string) {
switch (status) {
case "scheduled":
return "Programada";
case "completed":
return "Completada";
case "cancelled":
return "Cancelada";
case "no_show":
return "No asistió";
default:
return status;
}
}
export function statusColor(status: string) {
switch (status) {
case "scheduled":
return { bg: "#eef4ff", fg: "#1c36dc", dot: "#3b66ff" };
case "completed":
return { bg: "#ecfdf5", fg: "#047857", dot: "#10b981" };
case "cancelled":
return { bg: "#fef2f2", fg: "#b91c1c", dot: "#ef4444" };
case "no_show":
return { bg: "#fff7ed", fg: "#b9440b", dot: "#f97316" };
default:
return { bg: "#f3f4f6", fg: "#374151", dot: "#6b7280" };
}
}
export function paymentLabel(method: string) {
switch (method) {
case "card":
return "Tarjeta";
case "cash":
return "Efectivo";
case "transfer":
return "Transferencia";
default:
return method;
}
}
+119
View File
@@ -0,0 +1,119 @@
const BASE = "/api/public";
export interface PublicBusiness {
id: number;
name: string;
industry: string;
currency_symbol: string;
phone: string | null;
address: string | null;
booking_enabled: boolean;
cancel_window_hours: number;
cancel_penalty_pct: number;
require_deposit: boolean;
deposit_pct: number;
}
export interface PublicService {
id: number;
name: string;
description: string | null;
category: string;
duration_min: number;
price: number;
color: string;
}
export interface PublicEmployee {
id: number;
name: string;
role: string;
color: string;
}
export interface PublicBusinessResponse {
business: PublicBusiness;
services: PublicService[];
employees: PublicEmployee[];
}
export interface PublicSlot {
time: string;
iso: string;
employee_id: number;
employee_name: string;
}
export interface PublicSlotsResponse {
slots: PublicSlot[];
service: Pick<PublicService, "id" | "name" | "price" | "duration_min">;
}
export interface BookClient {
name: string;
email?: string;
phone?: string;
notes?: string;
}
export interface BookPayload {
service_id: number;
employee_id: number;
start_at: string;
client: BookClient;
}
export interface BookResponse {
appointment: {
id: number;
start_at: string;
price: number;
service_name: string;
employee_name: string;
};
business: { name: string; currency_symbol: string };
client_created: boolean;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers as Record<string, string> | undefined),
},
});
if (!res.ok) {
let message = `Error ${res.status}`;
try {
const body = await res.json();
if (body?.error) message = body.error;
} catch {
/* ignore */
}
throw new Error(message);
}
return (await res.json()) as T;
}
export function getBusiness(slug: string): Promise<PublicBusinessResponse> {
return request<PublicBusinessResponse>(`/${slug}`);
}
export function getSlots(
slug: string,
serviceId: number,
date: string,
employeeId?: number
): Promise<PublicSlotsResponse> {
const q = new URLSearchParams({ service_id: String(serviceId), date });
if (employeeId) q.set("employee_id", String(employeeId));
return request<PublicSlotsResponse>(`/${slug}/slots?${q.toString()}`);
}
export function book(slug: string, payload: BookPayload): Promise<BookResponse> {
return request<BookResponse>(`/${slug}/book`, {
method: "POST",
body: JSON.stringify(payload),
});
}