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]>
443 lines
17 KiB
TypeScript
443 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|