AgendaPro v1.0 - multi-tenant SaaS, mobile calendar, public booking
Multi-tenant scheduling SaaS (AgendaPro-equivalent): - Backend: Node + Express + node:sqlite, multi-tenant (admin/owner/employee), versioned migrations, 4 templates (estetica-spa, barberia, clinica, blank). - Frontend: React 18 + TS + Vite + Tailwind, FullCalendar drag-and-drop, Recharts dashboard, mobile-first (day/list views on mobile). - Features: calendar+services+employees+clients+tickets, dashboard with best employee/service, top tickets, top/frequent clients, commissions, cancellation policy + no-show tracking, public online booking (/b/:slug), cash register (cierre de caja), reminders/notifications center. - Verification: 65/65 e2e API, 58/58 visual (0 overflow, 0 console errors), typecheck clean, vite build OK.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function Spinner({ className = "" }: { className?: string }) {
|
||||
return <Loader2 className={`h-4 w-4 animate-spin ${className}`} />;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
{Icon && (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-bold text-slate-900">{title}</div>
|
||||
{description && <p className="mt-1 text-sm text-slate-500">{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-5 pt-5 sm:px-7 sm:pt-7 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-extrabold tracking-tight text-slate-900 sm:text-2xl">{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-sm text-slate-500">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
bg,
|
||||
fg,
|
||||
dot,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
bg?: string;
|
||||
fg?: string;
|
||||
dot?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className="chip" style={{ background: bg ?? "#f3f4f6", color: fg ?? "#374151" }}>
|
||||
{dot && <span className="h-1.5 w-1.5 rounded-full" style={{ background: dot }} />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
name,
|
||||
color,
|
||||
size = 36,
|
||||
}: {
|
||||
name: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const init = name
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase())
|
||||
.join("");
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
|
||||
style={{ background: color ?? "#3b66ff", width: size, height: size, fontSize: size * 0.35 }}
|
||||
>
|
||||
{init}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user