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:
+159
@@ -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" }),
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user