diff --git a/src/components/MonthCalendar.tsx b/src/components/MonthCalendar.tsx new file mode 100644 index 0000000..efa383b --- /dev/null +++ b/src/components/MonthCalendar.tsx @@ -0,0 +1,118 @@ +// src/components/MonthCalendar.tsx +import { useMemo, useState } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { cn } from "../lib/format"; + +const WD = ["Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom"]; +const MONTHS = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"]; + +function toIso(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} +function isoToDate(iso: string): Date { + const [y, m, d] = iso.split("-").map(Number); + return new Date(y, (m || 1) - 1, d || 1); +} +function atMidnight(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); +} + +export function MonthCalendar({ + value, + min, + max, + availableDates, + onSelect, +}: { + value: string; + min?: string; + max?: string; + availableDates?: Set; + onSelect: (iso: string) => void; +}) { + const initial = value ? isoToDate(value) : new Date(); + const [cursor, setCursor] = useState(new Date(initial.getFullYear(), initial.getMonth(), 1)); + const todayIso = toIso(new Date()); + const minDate = min ? atMidnight(isoToDate(min)) : null; + const maxDate = max ? atMidnight(isoToDate(max)) : null; + + const cells = useMemo(() => { + const first = new Date(cursor.getFullYear(), cursor.getMonth(), 1); + const lead = (first.getDay() + 6) % 7; // Lun primero + const start = new Date(first); + start.setDate(first.getDate() - lead); + const out: Date[] = []; + for (let i = 0; i < 42; i++) { + const d = new Date(start); + d.setDate(start.getDate() + i); + out.push(d); + } + return out; + }, [cursor]); + + const inMonth = (d: Date) => d.getMonth() === cursor.getMonth(); + const selectable = (d: Date) => { + const dm = atMidnight(d); + if (minDate && dm < minDate) return false; + if (maxDate && dm > maxDate) return false; + return true; + }; + const move = (delta: number) => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + delta, 1)); + const firstOfMonth = new Date(cursor.getFullYear(), cursor.getMonth(), 1); + const canPrev = !minDate || new Date(cursor.getFullYear(), cursor.getMonth(), 0) >= firstOfMonth; + const canNext = !maxDate || new Date(cursor.getFullYear(), cursor.getMonth() + 2, 0) <= new Date(maxDate.getFullYear(), maxDate.getMonth() + 1, 0); + + return ( +
+
+ +
{MONTHS[cursor.getMonth()]} {cursor.getFullYear()}
+ +
+
+ {WD.map((w) => ( +
{w}
+ ))} +
+
+ {cells.map((d, i) => { + const dIso = toIso(d); + const sel = dIso === value; + const on = selectable(d); + const inM = inMonth(d); + const isToday = dIso === todayIso; + const hasAvail = availableDates?.has(dIso); + const weekend = d.getDay() === 0 || d.getDay() === 6; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/index.css b/src/index.css index f562a6f..77c4f92 100644 --- a/src/index.css +++ b/src/index.css @@ -258,24 +258,26 @@ body { background: #fecaca; } -.input, -.select, -.textarea { - width: 100%; - background: #ffffff; - border: 1px solid #e5e7eb; - border-radius: 0.65rem; - padding: 0.55rem 0.75rem; - font-size: 0.875rem; - color: #0f172a; - transition: border-color 0.15s, box-shadow 0.15s; - outline: none; -} -.input:focus, -.select:focus, -.textarea:focus { - border-color: #3b66ff; - box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15); +@layer components { + .input, + .select, + .textarea { + width: 100%; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 0.65rem; + padding: 0.55rem 0.75rem; + font-size: 0.875rem; + color: #0f172a; + transition: border-color 0.15s, box-shadow 0.15s; + outline: none; + } + .input:focus, + .select:focus, + .textarea:focus { + border-color: #3b66ff; + box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15); + } } .label { display: block; diff --git a/src/lib/publicApi.ts b/src/lib/publicApi.ts index aba501c..04f5503 100644 --- a/src/lib/publicApi.ts +++ b/src/lib/publicApi.ts @@ -12,6 +12,7 @@ export interface PublicBusiness { cancel_penalty_pct: number; require_deposit: boolean; deposit_pct: number; + auto_assign_specialist: number | boolean; } export interface PublicService { @@ -58,7 +59,7 @@ export interface BookClient { export interface BookPayload { service_id: number; - employee_id: number; + employee_id?: number; start_at: string; client: BookClient; } @@ -69,7 +70,9 @@ export interface BookResponse { start_at: string; price: number; service_name: string; + employee_id: number; employee_name: string; + reasons: string[]; }; business: { name: string; currency_symbol: string }; client_created: boolean; diff --git a/src/lib/workingHours.ts b/src/lib/workingHours.ts new file mode 100644 index 0000000..ec662de --- /dev/null +++ b/src/lib/workingHours.ts @@ -0,0 +1,17 @@ +export const DEFAULT_WH: Record = { + 1: { start: "09:00", end: "20:00" }, 2: { start: "09:00", end: "20:00" }, + 3: { start: "09:00", end: "20:00" }, 4: { start: "09:00", end: "20:00" }, + 5: { start: "09:00", end: "20:00" }, 6: null, 7: null, +}; +export const DAYS = [ + { n: 1, label: "Lunes" }, { n: 2, label: "Martes" }, { n: 3, label: "Miércoles" }, + { n: 4, label: "Jueves" }, { n: 5, label: "Viernes" }, { n: 6, label: "Sábado" }, { n: 7, label: "Domingo" }, +]; +export function parseWh(v: any): Record { + let obj = v; + if (typeof v === "string") { try { obj = JSON.parse(v); } catch { obj = null; } } + if (!obj || typeof obj !== "object") return { ...DEFAULT_WH }; + const out: Record = {}; + for (let d = 1; d <= 7; d++) out[d] = obj[String(d)] ?? null; + return out; +} diff --git a/src/pages/EmployeesPage.tsx b/src/pages/EmployeesPage.tsx index 2d94462..dc41bfc 100644 --- a/src/pages/EmployeesPage.tsx +++ b/src/pages/EmployeesPage.tsx @@ -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([]); + const [specialties, setSpecialties] = useState([]); + const [specialtyInput, setSpecialtyInput] = useState(""); + const [efficiency, setEfficiency] = useState(50); + const [inheritHours, setInheritHours] = useState(true); + const [wh, setWh] = useState>({ ...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({ })} +
+ +
+ {specialties.map((sp) => ( + + {sp} + + + ))} + setSpecialtyInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && specialtyInput.trim()) { + e.preventDefault(); + setSpecialties((a) => [...new Set([...a, specialtyInput.trim()])]); + setSpecialtyInput(""); + } + }} + /> +
+

Etiquetas que el algoritmo compara con la categoría y el nombre del servicio.

+
+
+ + setEfficiency(Number(e.target.value))} + className="w-full accent-brand-500" + /> +

Qué tan rápido/hábil es. El algoritmo premia valores altos cuando hay empate por disponibilidad.

+
+
+ + {!inheritHours && ( +
+ {DAYS.map(({ n, label }) => { + const on = !!wh[n]; + return ( +
+ + {on ? ( +
+ + setWh({ ...wh, [n]: { ...(wh[n] as { start: string; end: string }), start: e.target.value } }) + } + /> + a + + setWh({ ...wh, [n]: { ...(wh[n] as { start: string; end: string }), end: e.target.value } }) + } + /> +
+ ) : ( + Cerrado + )} +
+ ); + })} +
+ )} +