feat(ui): booking redesign + auto-assign settings + specialist fields
Dynamic 3/4-step wizard (omits specialist step when auto-assign on), always-visible MonthCalendar, 2-column desktop layout with morning/afternoon slot grouping, confirmation reasons badges. Settings: auto-assign checkbox + business working-hours editor. Employee modal: specialties, efficiency, per-employee working hours. Fix icon/placeholder overlap app-wide via @layer components + pl-10.
This commit is contained in:
+130
-1
@@ -16,6 +16,7 @@ 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"];
|
||||
|
||||
@@ -193,6 +194,11 @@ function EmployeeModal({
|
||||
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;
|
||||
@@ -204,6 +210,12 @@ function EmployeeModal({
|
||||
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");
|
||||
@@ -212,12 +224,28 @@ function EmployeeModal({
|
||||
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 };
|
||||
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);
|
||||
},
|
||||
@@ -303,6 +331,107 @@ function EmployeeModal({
|
||||
})}
|
||||
</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)
|
||||
|
||||
Reference in New Issue
Block a user