Files
AgendaPro/src/pages/EmployeesPage.tsx
T
AgendaPro DevandClaude Opus 5 4c19244df9 feat: public landing page with magic login, brand palette and demo build flag
Adds a public funnel landing at `/` and moves the login to `/login`, rebuilt
around the panel's own colors instead of an invented palette.

Landing (`src/components/landing/`, one section per file, no props):
- Seven funnel sections composed by `LandingPage`. The hero's eight swatches are
  literally `PIE_COLORS` from `DashboardPage`, the same hex values the seed hands
  to avatars and services, so "your colors become your numbers" is literal.
- `BrandMark` becomes the single source for the logo, replicating
  `public/favicon.svg`. Blue is the action surface, orange only ever marks.
- `NotebookVisual` is the one deliberate exception to the palette: it is what the
  product replaces.
- Copy drops all system vocabulary; motion comes from `src/lib/motion.ts` with a
  single easing, and reduced-motion resolves `initial` to the final state so a
  never-firing `whileInView` cannot leave a section invisible forever.

Routing and bundle:
- `homePathFor` is the single definition of each role's destination.
- Landing, login, dashboard and calendar load lazily. Eager, the login dragged
  framer-motion (~40 KB gz) into every panel load and the landing downloaded
  recharts + FullCalendar (~187 KB gz) without charting anything. `clsx` is
  pinned to the `react` chunk because Rollup otherwise assigns it to `charts`,
  making the entry import 111 KB gz for a 200-byte utility.

Responsiveness (iPhone/iPad), verified with `npm run audit:responsive`:
- No touch form field below 16px, `dvh` height utilities, safe-area insets, and
  40px touch targets keyed off `pointer: coarse` rather than `sm:`.
- New `.ld-gutter`: `.safe-x` lives outside `@layer` and beats Tailwind's `px-*`,
  so it left the login's side padding at 0 on anything but an iPhone in
  landscape. No overflow check could see it — there was no overflow, just zero
  margin. The audit now guards it with a `gutter` check.
- `shell-height` no longer fires on pages that legitimately scroll; the static
  `raw-viewport-unit` scan covers those instead.

Production build:
- The Dockerfile now sets `VITE_DEMO_UI=1` as a build arg. `DEMO` is a build-time
  constant, so without it Vite eliminated the magic login and the "Ver como…"
  switcher: the deployed landing promised "no registration" and led to an empty
  form. Verified by building both ways and diffing the bundle.
- Service worker cache bumped to v2 so the orphaned pre-landing chunks get purged
  from returning visitors' caches.

Verified: typecheck clean; unit 39/39, e2e 33/33, admin 17/17, booking 12/12,
landing 13/13 (WebKit), PWA passed against the real production build.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:25:58 -06:00

443 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Plus,
Star,
DollarSign,
CalendarCheck,
Activity,
Mail,
Phone,
Pencil,
Trash2,
} from "lucide-react";
import { api } from "../lib/api";
import type { Employee } from "../../shared/types";
import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui";
import { Modal } from "../components/Modal";
import { formatCurrency, cn } from "../lib/format";
import { DEFAULT_WH, DAYS, parseWh } from "../lib/workingHours";
const COLORS = ["#3b66ff", "#f17616", "#10b981", "#a855f7", "#ec4899", "#14b8a6", "#f59e0b", "#6366f1"];
export function EmployeesPage() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["employees"], queryFn: () => api.employees.list() });
const { data: servicesData } = useQuery({ queryKey: ["services"], queryFn: () => api.services.list() });
const [editing, setEditing] = useState<Employee | null>(null);
const [creating, setCreating] = useState(false);
const removeMut = useMutation({
mutationFn: (id: number) => api.employees.remove(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["employees"] }),
});
return (
<div className="flex h-full flex-col">
<PageHeader
title="Empleados"
subtitle="Gestiona a tu equipo, sus servicios y mide su rendimiento."
actions={
<button
onClick={() => {
setEditing(null);
setCreating(true);
}}
className="btn btn-primary"
>
<Plus className="h-4 w-4" /> Nuevo empleado
</button>
}
/>
<div className="flex-1 px-5 py-5 sm:px-7">
{isLoading ? (
<div className="flex h-40 items-center justify-center"><Spinner /></div>
) : !data?.employees.length ? (
<EmptyState title="Sin empleados" description="Agrega a tu primer especialista." />
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{data.employees.map((e) => (
<div key={e.id} className="card group p-5">
<div className="flex items-start gap-3">
<Avatar name={e.name} color={e.color} size={48} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-bold text-slate-900">{e.name}</h3>
{!e.active && (
<span className="chip bg-slate-100 text-slate-500">Inactivo</span>
)}
</div>
<p className="text-xs text-slate-500">{e.role}</p>
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-slate-500">
{e.email && (
<span className="inline-flex items-center gap-1">
<Mail className="h-3 w-3" /> {e.email}
</span>
)}
{e.phone && (
<span className="inline-flex items-center gap-1">
<Phone className="h-3 w-3" /> {e.phone}
</span>
)}
</div>
</div>
<div className="flex flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={() => {
setEditing(e);
setCreating(false);
}}
className="icon-btn rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-brand-600"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => {
if (confirm(`¿Eliminar a ${e.name}?`)) removeMut.mutate(e.id);
}}
className="icon-btn rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-2 text-center">
<Stat icon={DollarSign} color="#10b981" label="Ingresos" value={formatCurrency(e.stats?.revenue_total ?? 0)} />
<Stat icon={CalendarCheck} color="#3b66ff" label="Citas" value={String(e.stats?.appointments_completed ?? 0)} />
<Stat icon={Star} color="#f59e0b" label="Rating" value={`⭐ ${e.stats?.avg_rating ?? e.rating}`} />
</div>
{e.service_ids && e.service_ids.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{(servicesData?.services ?? [])
.filter((s) => e.service_ids!.includes(s.id))
.slice(0, 4)
.map((s) => (
<span
key={s.id}
className="chip"
style={{ background: `${s.color}1a`, color: s.color }}
>
{s.name}
</span>
))}
{e.service_ids.length > 4 && (
<span className="chip bg-slate-100 text-slate-500">+{e.service_ids.length - 4}</span>
)}
</div>
)}
<div className="mt-3 flex items-center gap-2 text-[11px] text-slate-400">
<Activity className="h-3 w-3" />
% de citas {e.stats?.share_pct ?? 0}%
<div className="ml-1 h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
<div className="h-full rounded-full bg-brand-500" style={{ width: `${e.stats?.share_pct ?? 0}%` }} />
</div>
</div>
</div>
))}
</div>
)}
</div>
<EmployeeModal
open={creating || !!editing}
employee={editing}
services={servicesData?.services ?? []}
onClose={() => {
setEditing(null);
setCreating(false);
}}
/>
</div>
);
}
function Stat({
icon: Icon,
color,
label,
value,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
color: string;
label: string;
value: string;
}) {
return (
<div className="rounded-xl bg-slate-50 px-2 py-2">
<Icon className="mx-auto h-3.5 w-3.5" style={{ color }} />
<div className="mt-1 truncate text-sm font-bold text-slate-800">{value}</div>
<div className="text-[10px] font-medium uppercase tracking-wide text-slate-400">{label}</div>
</div>
);
}
function EmployeeModal({
open,
employee,
services,
onClose,
}: {
open: boolean;
employee: Employee | null;
services: { id: number; name: string; color: string; category: string }[];
onClose: () => void;
}) {
const qc = useQueryClient();
const isEdit = !!employee;
const [name, setName] = useState("");
const [role, setRole] = useState("Especialista");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [color, setColor] = useState(COLORS[0]);
const [active, setActive] = useState(true);
const [svcIds, setSvcIds] = useState<number[]>([]);
const [specialties, setSpecialties] = useState<string[]>([]);
const [specialtyInput, setSpecialtyInput] = useState("");
const [efficiency, setEfficiency] = useState(50);
const [inheritHours, setInheritHours] = useState(true);
const [wh, setWh] = useState<Record<number, { start: string; end: string } | null>>({ ...DEFAULT_WH });
useEffect(() => {
if (!open) return;
if (employee) {
setName(employee.name);
setRole(employee.role);
setEmail(employee.email ?? "");
setPhone(employee.phone ?? "");
setColor(employee.color);
setActive(!!employee.active);
setSvcIds(employee.service_ids ?? []);
setSpecialties(Array.isArray(employee.specialties) ? employee.specialties : []);
setEfficiency(typeof employee.efficiency_score === "number" ? employee.efficiency_score : 50);
const parsed = parseWh(employee.working_hours);
const hasOwn = !!employee.working_hours && Object.values(parsed).some((v) => v !== null);
setInheritHours(!hasOwn);
setWh(hasOwn ? parsed : { ...DEFAULT_WH });
} else {
setName("");
setRole("Especialista");
setEmail("");
setPhone("");
setColor(COLORS[Math.floor(Math.random() * COLORS.length)]);
setActive(true);
setSvcIds([]);
setSpecialties([]);
setSpecialtyInput("");
setEfficiency(50);
setInheritHours(true);
setWh({ ...DEFAULT_WH });
}
}, [open, employee]);
const mut = useMutation({
mutationFn: async () => {
const payload = {
name,
role,
email,
phone,
color,
active,
service_ids: svcIds,
specialties,
efficiency_score: efficiency,
working_hours: inheritHours ? null : wh,
};
if (employee) return api.employees.update(employee.id, payload);
return api.employees.create(payload);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["employees"] });
qc.invalidateQueries({ queryKey: ["services"] });
onClose();
},
});
return (
<Modal
open={open}
onClose={onClose}
title={isEdit ? "Editar empleado" : "Nuevo empleado"}
footer={
<div className="flex items-center justify-end gap-2">
<button onClick={onClose} className="btn btn-ghost">Cancelar</button>
<button onClick={() => mut.mutate()} className="btn btn-primary" disabled={!name.trim() || mut.isPending}>
{mut.isPending ? <Spinner /> : isEdit ? "Guardar" : "Crear"}
</button>
</div>
}
>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Avatar name={name || "?"} color={color} size={56} />
<div className="flex-1">
<label className="label">Color</label>
<div className="flex flex-wrap gap-1.5">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={cn(
"h-7 w-7 rounded-full transition-transform",
color === c && "ring-2 ring-offset-2 ring-slate-400 scale-110"
)}
style={{ background: c }}
/>
))}
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label">Nombre *</label>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label className="label">Puesto</label>
<input className="input" value={role} onChange={(e) => setRole(e.target.value)} />
</div>
<div>
<label className="label">Correo</label>
<input className="input" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div>
<label className="label">Teléfono</label>
<input className="input" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
</div>
<div>
<label className="label">Servicios que ofrece</label>
<div className="flex max-h-44 flex-wrap gap-1.5 overflow-y-auto rounded-xl border border-slate-100 p-2">
{services.map((s) => {
const on = svcIds.includes(s.id);
return (
<button
key={s.id}
type="button"
onClick={() => setSvcIds((arr) => (on ? arr.filter((x) => x !== s.id) : [...arr, s.id]))}
className={cn(
"chip transition-colors",
on ? "text-white" : "bg-slate-100 text-slate-600 hover:bg-slate-200"
)}
style={on ? { background: s.color } : undefined}
>
{s.name}
</button>
);
})}
</div>
</div>
<div>
<label className="label">Especialidades</label>
<div className="flex flex-wrap items-center gap-1.5 rounded-xl border border-slate-100 p-2">
{specialties.map((sp) => (
<span key={sp} className="chip bg-brand-50 text-brand-700">
{sp}
<button
type="button"
onClick={() => setSpecialties((a) => a.filter((x) => x !== sp))}
className="ml-0.5 text-brand-400 hover:text-brand-700"
>
×
</button>
</span>
))}
<input
className="min-w-[120px] flex-1 bg-transparent text-sm outline-none"
placeholder="Ej. Coloración y pulsa Enter"
value={specialtyInput}
onChange={(e) => setSpecialtyInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && specialtyInput.trim()) {
e.preventDefault();
setSpecialties((a) => [...new Set([...a, specialtyInput.trim()])]);
setSpecialtyInput("");
}
}}
/>
</div>
<p className="mt-1 text-[11px] text-slate-500">Etiquetas que el algoritmo compara con la categoría y el nombre del servicio.</p>
</div>
<div>
<label className="label">
Eficiencia <span className="ml-1 text-brand-700">{efficiency}</span>
</label>
<input
type="range"
min={0}
max={100}
value={efficiency}
onChange={(e) => setEfficiency(Number(e.target.value))}
className="w-full accent-brand-500"
/>
<p className="mt-1 text-[11px] text-slate-500">Qué tan rápido/hábil es. El algoritmo premia valores altos cuando hay empate por disponibilidad.</p>
</div>
<div>
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
className="h-4 w-4 accent-brand-500"
checked={inheritHours}
onChange={(e) => setInheritHours(e.target.checked)}
/>
<span className="text-sm font-bold text-slate-800">Heredar horario del negocio</span>
</label>
{!inheritHours && (
<div className="mt-2 space-y-1.5">
{DAYS.map(({ n, label }) => {
const on = !!wh[n];
return (
<div key={n} className="flex items-center gap-2 rounded-lg border border-slate-100 px-2 py-1.5">
<label className="flex w-24 cursor-pointer items-center gap-2 text-xs font-semibold text-slate-700">
<input
type="checkbox"
className="h-4 w-4 accent-brand-500"
checked={on}
onChange={() =>
setWh({ ...wh, [n]: wh[n] ? null : { start: "09:00", end: "18:00" } })
}
/>
{label}
</label>
{on ? (
<div className="flex items-center gap-1.5">
<input
type="time"
className="input !w-auto !py-1 text-xs"
value={wh[n]!.start}
onChange={(e) =>
setWh({ ...wh, [n]: { ...(wh[n] as { start: string; end: string }), start: e.target.value } })
}
/>
<span className="text-xs text-slate-400">a</span>
<input
type="time"
className="input !w-auto !py-1 text-xs"
value={wh[n]!.end}
onChange={(e) =>
setWh({ ...wh, [n]: { ...(wh[n] as { start: string; end: string }), end: e.target.value } })
}
/>
</div>
) : (
<span className="text-xs font-medium text-slate-400">Cerrado</span>
)}
</div>
);
})}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
Activo (puede recibir citas)
</label>
</div>
</Modal>
);
}