diff --git a/.superpowers/sdd/final-review-diff.txt b/.superpowers/sdd/final-review-diff.txt new file mode 100644 index 0000000..87ca7f7 Binary files /dev/null and b/.superpowers/sdd/final-review-diff.txt differ diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md new file mode 100644 index 0000000..c5483a9 --- /dev/null +++ b/.superpowers/sdd/progress.md @@ -0,0 +1,45 @@ +# SDD Progress Ledger — Auto-assign specialist + booking redesign + +- **Plan:** `docs/superpowers/plans/2026-07-26-auto-assign-specialist-booking-redesign.md` +- **Spec:** `docs/superpowers/specs/2026-07-26-auto-assign-specialist-booking-redesign-design.md` +- **Branch:** `feat/auto-assign-specialist` +- **Base commit:** `e8d2435` +- **Commit policy:** NO commits during execution (repo policy: only on explicit request). Verify per task via typecheck + tests. Reviewer reads changed files + plan task. +- **Pre-existing WIP (do NOT clobber):** `src/components/AppointmentModal.tsx`, `src/pages/CalendarPage.tsx`, FullCalendar section of `src/index.css` (~lines 136-160). Use targeted `edit` only. + +## Tasks +- Task 1: complete — db.ts (migrateV3ToV4 + runMigrations call), shared/types.ts (Business/Employee/WorkingHoursMap). Review: APPROVED, no findings. typecheck 0 errors, schema_version=4, backfill verified. +- Task 2: complete — server/lib/scheduling.ts (pure helpers, no imports/DB), server/lib/scheduling.test.ts (15 tests), package.json test:unit. Review: APPROVED. test:unit 15/15. Minor (non-blocking): add direct test for hasConflict positive case, clamp01, parseWorkingHours malformed-shape, specialtyMatch substring branch — defer to final review. +- Task 3: complete — appended DB fns to scheduling.ts (getCandidates, getExistingBusy, isAvailable, rankOne, pickBestSlotEmployee, autoAssign, runInTransaction). Plan typo `c.info.id`→`c.id` fixed & verified. Review: APPROVED. typecheck 0, test:unit 15/15, tx smoke ok. Low-note: getExistingBusy scopes by start_at within day (cross-midnight appts not caught — neutralized by working-hours guard; revisit if 24h scheduling added). +- Task 4: complete — booking.ts publicBusiness (+auto_assign_specialist,working_hours) + slots endpoint uses real working_hours + pickBestSlotEmployee. POST /book untouched. Review: APPROVED. typecheck 0, slots smoke ok. Info-notes: local overlapsRange dup of overlaps (plan-permitted); per-slot SELECT name could be hoisted (pre-existing). +- Task 5: complete — booking.ts POST /book rewritten: runInTransaction + autoAssign (when emp omitted or auto_assign_specialist=1) + isAvailable guard → 409 on conflict; 201 includes employee_id+reasons. Review: APPROVED. typecheck 0, test:unit 15/15, integration a)201 b)409 c)201-survivor d)409-autoAssign-null all pass. Sound tx guard. +- Task 6: complete — appointments.ts POST / wrapped in runInTransaction, autoAssign fallback (replaced LIMIT 1), isAvailable guard → 409. Two plan-text deviations (move emp resolution inside tx; Number(business_id)) verified correct. Review: APPROVED. typecheck 0, test:unit 15/15, conflict smoke 409/201 pass. FOLLOW-UP: e2e-test.mjs fixtures (2026-09-15T11:00Z=05:00 local, 2026-09-20 Sun) now fail under working-hours enforcement — fixture-side, will fix in Task 7. +- Task 7: complete — settings.ts (+auto_assign_specialist,working_hours, JSON/0-1 coercion), employees.ts (+specialties/working_hours/efficiency_score), booking-e2e.mjs (new), e2e-test.mjs slot-aware fixture fix (no guards weakened). Review: APPROVED. typecheck 0, test:unit 15/15, test:e2e 25/25, test:booking 9/9. + +== BACKEND COMPLETE (Tasks 1-7). Algorithm + guards + APIs all verified green. == +- Task 8: complete — index.css wrapped .input/.select/.textarea in @layer components; BookingPage DetailsForm 3× pl-9→pl-10. Verified directly by controller (build green, @layer confirmed at index.css:272, WIP untouched). Icon/placeholder overlap fixed app-wide. +- Task 9: complete — publicApi.ts types (PublicBusiness.auto_assign_specialist; BookPayload.employee_id optional; BookResponse +employee_id/reasons). Verified directly (typecheck 0, no consumer breakage). +- Task 10: complete — src/components/MonthCalendar.tsx (42-cell month grid, nav bounded by min/max, brand selected, accent avail dot, brand today dot). Verified directly (typecheck 0, build green). Will be integration-tested in Task 11. +- Task 11: complete — BookingPage DateTimePicker rewritten: md+ 2-col (MonthCalendar left + grouped slots right), SlotGroup Mañana/Tarde, mobile stacked, slot button styles unchanged, max optional. Review: APPROVED. typecheck 0, build ok. AM/PM noon boundary verified. +- Task 12: complete — BookingPage wizard rewired to dynamic 3/4 steps via stepsFor(autoAssign)+currentOriginal; main max-w-5xl only on Horario; handleBook sends employee_id undefined when autoAssign; Confirmation shows reasons badges. Review: APPROVED. typecheck 0, build ok, HTTP smoke both flows + 409. NOTE: `npm run lint` can't run repo-wide (pre-existing ESLint9 flat-config gap, no eslint.config.js) — pre-existing, will report at Task 15. +- Task 13: complete — src/lib/workingHours.ts (DEFAULT_WH/DAYS/parseWh) + SettingsPage auto-assign checkbox + 7-day working-hours editor. Review: APPROVED. typecheck 0, build ok, runtime e2e (PATCH→GET persist, Sat 10-14 slots appear, toggle 3/4 steps). +- Task 14: complete — EmployeesPage EmployeeModal +specialties chips +efficiency slider +working-hours editor (inherit toggle). Payload specialties[]/efficiency Number/working_hours null|map. Review: APPROVED. typecheck 0, build ok. Low-notes: all-closed employee flips back to inherit (benign); case-sensitive dedup (acceptable). Runtime persistence smoke deferred to Task 15. + +== ALL 14 IMPLEMENTATION TASKS COMPLETE + REVIEWED. == + +== TASK 15: FINAL VERIFICATION == +- typecheck: 0 errors +- test:unit: 15/15 +- build: OK (2462 modules) +- test:e2e: 25/25 +- test:booking: 9/9 (auto-assign 201+reasons, double-book 409, auto_assign_specialist exposed) +- employee persistence runtime check (Task 14 deferred): specialties/efficiency=87/working_hours custom→null(inherit) all persist ✓ +- lint: PRE-EXISTING repo-wide failure (no eslint.config.js, ESLint 9.39 requires flat config) — NOT introduced by this feature. +- FINAL WHOLE-BRANCH REVIEW: READY TO MERGE. Core guarantee (no double-booking from public flow) verified end-to-end (sync handler + BEGIN IMMEDIATE + hasConflict + same-tx INSERT). No cross-task integration issues. + +== POST-MERGE FOLLOW-UPS (non-blocking, deferred) == +1. getExistingBusy: switch WHERE to overlap-based (end_at > dayStart AND start_at < dayEnd) — closes cross-midnight gap if 24h scheduling added. +2. Add 4 unit-test cases (hasConflict positive, clamp01, parseWh malformed, specialtyMatch substring). +3. Replace local overlapsRange with imported overlaps. +4. Hoist per-slot employee-name SELECT out of slots loop. +5. MonthCalendar nav buttons → h-11 w-11 (44px touch target). diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md new file mode 100644 index 0000000..4e71677 --- /dev/null +++ b/.superpowers/sdd/task-1-report.md @@ -0,0 +1,50 @@ +# Task 1 Report — Migración V3→V4 + tipos compartidos + +## Files changed +- `H:\MegaSync\Proyectos\AgendaPro\server\db.ts` + - Added `migrateV3ToV4()` immediately before `export function runMigrations()`. + - Registered `migrateV3ToV4();` as the last call inside `runMigrations()` (after `migrateV2ToV3();`). +- `H:\MegaSync\Proyectos\AgendaPro\shared\types.ts` + - Replaced `Business` interface to add booking fields (`booking_enabled`, `cancel_window_hours`, `cancel_penalty_pct`, `require_deposit`, `deposit_pct`, `timezone`) and v4 fields (`auto_assign_specialist`, `working_hours`). + - Added `WorkingDay` and `WorkingHoursMap` types after the `Business` interface. + - Added `specialties`, `working_hours`, `efficiency_score` to the `Employee` interface (after `service_ids?`). + +## Verification + +### `npm run typecheck` output (last line) +``` +> agenda-pro@1.0.0 typecheck +> tsc -b --noEmit +``` +Exit code 0 — **0 errors**. (Output was just the script banner + the tsc command echo; tsc prints nothing on success.) + +### tsx column check output (verbatim) +``` +businesses has auto_assign_specialist: true +businesses has working_hours: true +employees has specialties: true +employees has working_hours: true +employees has efficiency_score: true +schema_version: [Object: null prototype] { value: '4' } +business working_hours backfilled: true +``` +The check was run twice (second run for idempotency): identical output both times, no errors, schema_version stable at `4`. + +## Deviations from the plan +- None in the actual code edits. The `migrateV3ToV4` function, the `runMigrations` body, and all type additions match the plan verbatim. +- Verification detail: the plan's one-liner `npx tsx -e "..."` command failed under Windows PowerShell 5.1 because PowerShell parsed the embedded double-quoted SQL string (`"SELECT value FROM meta WHERE key='schema_version'"`) as terminating the outer `-e` argument, producing an "Unterminated string literal" esbuild error. To get a clean result without altering any project code, the check was executed via a throwaway `.mjs` file placed in the pre-approved temp dir (`C:\Users\URIELJ~1\AppData\Local\Temp\opencode`) that imports `server/db.ts` via an absolute `file:///H:/MegaSync/Proyectos/AgendaPro/server/db.ts` URL. The file was deleted after running. No project files were created or modified beyond `server/db.ts` and `shared/types.ts`. + +## Self-review + +- **All `Business` fields from the plan added?** Yes — interface body matches the plan exactly, including `booking_enabled`, `cancel_window_hours`, `cancel_penalty_pct`, `require_deposit`, `deposit_pct`, `timezone`, `auto_assign_specialist`, `working_hours`. The DB already has the v3 booking columns from `migrateV2ToV3`, so the type is now honest vs. the DB. +- **Does `migrateV3ToV4` guard with `schema_version >= "4"`?** Yes — first line of the function is `if (getMeta("schema_version") >= "4") return;`, mirroring the v2/v3 pattern. String comparison is safe here because the values are single-digit ASCII numerics ("3" < "4"). +- **Is the backfill idempotent?** Yes, in two layers: + 1. The schema-version guard short-circuits the entire function on subsequent runs. + 2. Even if that guard were bypassed, the backfill UPDATE is gated by `WHERE working_hours IS NULL`, so businesses whose `working_hours` was already filled (e.g. by the user via PATCH `/settings`) are never overwritten. Confirmed by the second run producing identical output. +- **Column-add idempotency:** Each ALTER is guarded by `columnExists(...)` — re-running against a DB that already has the column is a no-op. +- **Backfill content matches the plan's default** (`1..5 = 09:00-20:00`, `6,7 = null`), which replicates the previously hard-coded hours used by `booking.ts` pre-redesign. +- **Pre-existing WIP untouched:** `src/components/AppointmentModal.tsx`, `src/pages/CalendarPage.tsx`, and the FullCalendar block of `src/index.css` were not modified. Only `server/db.ts` and `shared/types.ts` were edited. +- **No commit was made** (per repo policy). + +## Concerns +None. Typecheck is clean and the migration ran end-to-end against the real `data/agendapro.db`, producing exactly the expected column set and backfilled data. diff --git a/.superpowers/sdd/task-10-report.md b/.superpowers/sdd/task-10-report.md new file mode 100644 index 0000000..b4ffaac --- /dev/null +++ b/.superpowers/sdd/task-10-report.md @@ -0,0 +1,54 @@ +# Task 10 Report — Componente `MonthCalendar` + +## Status +✅ DONE + +## Files changed +- **Created:** `src/components/MonthCalendar.tsx` (verbatim from plan, Task 10 Step 1). + +No other files modified. + +## Verification + +| Check | Command | Result | +|---|---|---| +| Typecheck | `npm run typecheck` | ✅ 0 errors | +| Build | `npm run build` | ✅ Success (`vite v5.4.21`, `built in 5.40s`) | + +## Self-review + +**Props signature:** `MonthCalendar({ value, min, max, availableDates, onSelect })` — matches spec exactly. +- `value: string` (ISO YYYY-MM-DD) +- `min?: string`, `max?: string` (ISO bounds, optional) +- `availableDates?: Set` (dates with open slots) +- `onSelect: (iso: string) => void` + +**6-row / 42-cell grid:** ✅ Yes. `cells` is computed via `useMemo` with a fixed `for (let i = 0; i < 42; i++)` loop, starting from the Monday on/before the 1st of the cursor month (`lead = (first.getDay() + 6) % 7`). Always renders 42 buttons in a `grid-cols-7` → 6 rows × 7 cols. + +**Past days disabled relative to `min`:** ✅ Yes. `selectable(d)` compares `atMidnight(d)` against `minDate` (also normalized to midnight) — strictly less → `false` → button `disabled`. Out-of-month past cells inherit the disabled state too. + +**Selecting a day calls `onSelect` with ISO YYYY-MM-DD:** ✅ Yes. `onClick={() => onSelect(dIso)}` where `dIso = toIso(d)` returns `${yyyy}-${mm}-${dd}` zero-padded. + +**Accent dot shows for `availableDates`:** ✅ Yes. When `availableDates?.has(dIso)` and the day isn't selected, an absolutely-positioned `bg-accent-500` 1.5×1.5 dot is rendered at `bottom-1`. + +**Today dot (`brand-400`) under control:** ✅ Bonus check — when `dIso === todayIso` and not selected, a `bg-brand-400` dot is rendered at `top-1` (distinct position from the accent dot to avoid overlap). + +**Selected-day highlight (`brand-500`):** ✅ Yes. When `dIso === value`, classes include `bg-brand-500 text-white shadow-soft ring-2 ring-brand-500/30`. + +**Navigation bounded by min/max:** ✅ Yes. +- `canPrev = !minDate || >= firstOfMonth` — disables ‹ when the entire previous month is before `min`. +- `canNext = !maxDate || <= ` — disables › when the entire next month is beyond `max`. +- The disabled attribute and `disabled:opacity-40` style visually communicate the bound. + +**Weekend tint:** ✅ Yes. Sáb/Dom (`getDay() === 0 || 6`) in-month, non-selected, selectable cells get `text-slate-500` (vs `text-slate-700` for weekdays). + +**Touch targets ≥44px:** ✅ Plausible. Each cell uses `aspect-square` inside a 7-col grid with `gap-1`; on the intended two-column desktop layout (parent column ~`minmax(0,1fr)` of `max-w-5xl`) cells exceed 44px. On narrow mobile (full width) they're well above 44px. The ‹ › nav buttons are `h-9 w-9` (36px) — slightly under 44px, but they're secondary controls; main interaction is the date grid. Flagging as a minor concern. + +**Client-only / SSR-safe:** ✅ Yes. Uses `new Date()` at render and inside `useState`/`useMemo` initializers — fine for a Vite SPA (no SSR). + +**Named export:** ✅ Yes. `export function MonthCalendar(...)` — a named export (the task brief says "default-style named export", which is what we have; consumer in Task 11 imports it as `import { MonthCalendar } from "../../components/MonthCalendar";`). + +## Concerns +1. **Nav buttons 36px (`h-9 w-9`):** slightly below the 44px touch-target guideline. Acceptable since they're secondary controls and the spec/plan code is verbatim; calling out for awareness only. No change made (would deviate from plan). +2. **`canNext` bound check is conservative:** it compares full-month ranges, so when `max` falls mid-month the › button stays enabled for that partial month (correct — user can still navigate to it) and only disables once the *entire* next month is past `max`. Behavior matches the plan's intent. +3. **No keyboard arrow navigation (Home/End/PgUp/PgDn/Arrows):** out of scope for the plan; the buttons are real ` + + + )} + {!isEditing && clientId && ( + )} + {isEditing && !changingClient && ( - + )} + {isEditing && changingClient && clientId && ( + + )} - {clientMode === "existing" ? ( -
-
- - { - setSearch(e.target.value); - setClientId(null); - }} - /> - {searching && } -
- {clientId && ( -
- c.id === clientId)?.name ?? "?"} size={28} /> -
-
- {searchResults?.clients.find((c) => c.id === clientId)?.name} -
+ {/* Current client card (edit mode, or after picking existing client) */} + {!changingClient && clientId && pickedClientName && ( +
+
+ +
+
+
{pickedClientName}
+
- +
+ {pickedClientPhone && ( + + + {pickedClientPhone} + + )} + {pickedClientEmail && ( + + + {pickedClientEmail} + + )} + {!pickedClientPhone && !pickedClientEmail && ( + Sin datos de contacto + )} +
+
+
+ {pickedClientTags && ( +
+ + {pickedClientTags + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + .map((t) => ( + + {t} + + ))}
)} - {!clientId && search && searchResults && ( -
- {searchResults.clients.length === 0 ? ( -
- Sin resultados.{" "} - -
- ) : ( - searchResults.clients.slice(0, 12).map((c) => ( -
- {c.tags && ( - {c.tags.split(",")[0]} - )} - - )) + ) : ( + searchResults.clients.slice(0, 12).map((c) => ( + + )) + )} +
+ )} + {!clientId && !search && ( +

Escribe para buscar un cliente existente.

)}
+ ) : ( +
+
+ + setNewClient({ ...newClient, name: e.target.value })} + placeholder="Nombre del cliente" + /> +
+
+ + setNewClient({ ...newClient, phone: e.target.value })} + placeholder="+52 …" + /> +
+
+ + setNewClient({ ...newClient, email: e.target.value })} + placeholder="cliente@email.com" + /> +
+
)} - {!clientId && !search && ( -

Escribe para buscar un cliente existente.

- )} -
- ) : ( -
-
- - setNewClient({ ...newClient, name: e.target.value })} - placeholder="Nombre del cliente" - /> -
-
- - setNewClient({ ...newClient, phone: e.target.value })} - placeholder="+52 …" - /> -
-
- - setNewClient({ ...newClient, email: e.target.value })} - placeholder="cliente@email.com" - /> -
-
+ )}
diff --git a/src/components/DemoSwitcher.tsx b/src/components/DemoSwitcher.tsx index d7e8170..246fa37 100644 --- a/src/components/DemoSwitcher.tsx +++ b/src/components/DemoSwitcher.tsx @@ -3,6 +3,8 @@ import { ChevronDown, UserCog, Check } from "lucide-react"; import { useAuth } from "../lib/auth"; import { api } from "../lib/api"; +const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1"; + export function DemoSwitcher() { const { user, switchUser } = useAuth(); const [open, setOpen] = useState(false); @@ -13,6 +15,7 @@ export function DemoSwitcher() { api.auth.demoUsers().then((r) => setUsers(r.users)).catch(() => {}); }, []); + if (!DEMO) return null; if (!user) return null; const pick = async (email: string) => { diff --git a/src/index.css b/src/index.css index 77c4f92..094a1a9 100644 --- a/src/index.css +++ b/src/index.css @@ -66,6 +66,12 @@ body { color: #0f172a; } .fc .fc-toolbar.fc-header-toolbar { + position: sticky; + top: 0; + z-index: 10; + background: #ffffff; + padding: 0.4rem 0.6rem; + margin: -0.4rem -0.6rem 0; flex-wrap: wrap; gap: 0.5rem 0.75rem; } @@ -101,11 +107,18 @@ body { text-transform: capitalize; box-shadow: none !important; } -.fc .fc-button:not(:disabled):hover { +.fc .fc-button:not(:disabled):not(.fc-button-active):hover { background: #f3f4f6; } .fc .fc-button-primary:not(:disabled).fc-button-active { color: #ffffff; + background: var(--fc-button-active-bg-color, #3b66ff); + border-color: var(--fc-button-active-border-color, #3b66ff); +} +.fc .fc-button-primary:not(:disabled).fc-button-active:hover { + background: #2447f5; + border-color: #2447f5; + color: #ffffff; } .fc .fc-button:disabled { opacity: 0.5; @@ -136,6 +149,7 @@ body { font-weight: 600 !important; cursor: pointer; transition: transform 0.12s, box-shadow 0.12s; + overflow: hidden; } .fc-event:hover { transform: translateY(-1px); @@ -149,6 +163,16 @@ body { } .fc-timegrid-event { overflow: hidden; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35); +} +.fc-timegrid-event-harness { + margin-right: 2px !important; +} +/* When slotEventOverlap is false, FullCalendar puts a small gap between + side-by-side events; tighten it a touch so each event gets more readable + width without bleeding into its neighbour. */ +.fc .fc-timegrid-col-events { + column-gap: 2px; } /* List view (great on mobile) */ diff --git a/src/lib/api.ts b/src/lib/api.ts index 97bd9b9..b4ce798 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -120,7 +120,8 @@ export const api = { remove: (id: number) => request<{ ok: boolean }>(`/appointments/${id}`, { method: "DELETE" }), }, dashboard: { - overview: () => request("/dashboard/overview"), + overview: (range?: number) => + request(`/dashboard/overview${range ? `?range=${range}` : ""}`), bestEmployees: (range = "30") => request<{ employees: RankedEmployee[] }>(`/dashboard/best-employees?range=${range}`), bestServices: (range = "30") => request<{ services: RankedService[] }>(`/dashboard/best-services?range=${range}`), topTickets: (range = "30", limit = 8) => @@ -135,6 +136,29 @@ export const api = { tickets: (limit = 50) => request<{ tickets: Ticket[] }>(`/dashboard/tickets?limit=${limit}`), commissions: (range = "30") => request<{ rows: any[]; total: number }>(`/dashboard/commissions?range=${range}`), }, + me: { + performance: () => + request<{ + appointments_today: number; + upcoming: number; + revenue_30d: number; + avg_rating: number; + share_pct: number; + next_appointment: { start_at: string; service_name: string; client_name: string } | null; + }>("/me/performance"), + commissions: () => + request<{ + rows: { + appointment_id: number | null; + service_name: string; + client_name: string; + amount: number; + commission: number; + created_at: string; + }[]; + total: number; + }>("/me/commissions"), + }, settings: { get: () => request<{ settings: any }>("/settings"), update: (data: any) => request<{ settings: any }>("/settings", { method: "PATCH", body: JSON.stringify(data) }), diff --git a/src/lib/publicApi.ts b/src/lib/publicApi.ts index 04f5503..f8e5359 100644 --- a/src/lib/publicApi.ts +++ b/src/lib/publicApi.ts @@ -1,3 +1,6 @@ +import type { BookResponse } from "../../shared/types"; +export type { BookResponse }; + const BASE = "/api/public"; export interface PublicBusiness { @@ -64,20 +67,6 @@ export interface BookPayload { client: BookClient; } -export interface BookResponse { - appointment: { - id: number; - 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; -} - async function request(path: string, init?: RequestInit): Promise { const res = await fetch(`${BASE}${path}`, { ...init, diff --git a/src/pages/CalendarPage.tsx b/src/pages/CalendarPage.tsx index 7e77cf1..d1a92e2 100644 --- a/src/pages/CalendarPage.tsx +++ b/src/pages/CalendarPage.tsx @@ -9,9 +9,10 @@ import type { EventClickArg, EventDropArg, } from "@fullcalendar/core"; +import type { EventResizeDoneArg } from "@fullcalendar/interaction"; import esLocale from "@fullcalendar/core/locales/es"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { CalendarDays, Plus, Filter, RotateCcw } from "lucide-react"; +import { CalendarDays, Plus, Filter, RotateCcw, AlertTriangle } from "lucide-react"; import { api } from "../lib/api"; import { useAuth } from "../lib/auth"; import type { Appointment } from "../../shared/types"; @@ -30,6 +31,8 @@ export function CalendarPage() { const { user } = useAuth(); const qc = useQueryClient(); const calRef = useRef(null); + const pendingRevert = useRef<(() => void) | null>(null); + const [moveError, setMoveError] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [draftSlot, setDraftSlot] = useState<{ start: string; end: string } | null>(null); @@ -100,16 +103,27 @@ export function CalendarPage() { const moveMutation = useMutation({ mutationFn: async ({ id, start, end }: { id: number; start: string; end: string }) => api.appointments.update(id, { start_at: start, end_at: end }), - onSuccess: () => qc.invalidateQueries({ queryKey: ["appointments"] }), + onSuccess: () => { + pendingRevert.current = null; + setMoveError(null); + qc.invalidateQueries({ queryKey: ["appointments"] }); + }, + onError: () => { + pendingRevert.current?.(); + pendingRevert.current = null; + setMoveError("No se pudo reagendar la cita (quizá se ocupó el horario). Se regresó a su lugar."); + }, }); const onDrop = (arg: EventDropArg) => { + pendingRevert.current = arg.revert; const id = Number(arg.event.id); const start = arg.event.start!.toISOString(); const end = (arg.event.end ?? new Date(arg.event.start!.getTime() + 60 * 60000)).toISOString(); moveMutation.mutate({ id, start, end }); }; - const onResize = (arg: any) => { + const onResize = (arg: EventResizeDoneArg) => { + pendingRevert.current = arg.revert; const id = Number(arg.event.id); moveMutation.mutate({ id, @@ -164,6 +178,21 @@ export function CalendarPage() { } /> + {moveError && ( +
+ + {moveError} + +
+ )} +
{isOwner && (
@@ -198,7 +227,7 @@ export function CalendarPage() {
-
+
{isLoading && !data && (
@@ -252,6 +281,8 @@ export function CalendarPage() { eventResizableFromStart eventDurationEditable eventStartEditable + slotEventOverlap={false} + eventMinHeight={28} dayMaxEvents={isMobile ? 2 : 3} eventTimeFormat={{ hour: "2-digit", @@ -297,17 +328,37 @@ function EventContent({ arg }: { arg: any }) { const a = arg.event.extendedProps.appointment as Appointment | undefined; if (!a) return
{arg.timeText} {arg.event.title}
; const c = statusColor(a.status); + const client = a.client as (NonNullable & { + notes?: string | null; + no_show_count?: number; + }) | undefined; + const risky = + (client?.no_show_count ?? 0) >= 2 || !!client?.notes?.startsWith("[Riesgo"); const isTime = arg.view.type !== "dayGridMonth"; + // When the event is rendered in a narrow column (because FullCalendar + // splits overlapping events side-by-side) we drop the employee line so + // client + service still fit without bleeding into adjacent slots. + const isNarrow = arg.view.type === "timeGridWeek" || arg.view.type === "timeGridDay"; + const dur = a.service?.duration_min; return ( -
-
+
+
- {arg.timeText} + {arg.timeText}
-
{a.client?.name}
-
{a.service?.name}
- {isTime && a.employee && ( -
con {a.employee.name}
+
+ {a.client?.name ?? "Cliente"} + {risky && ( + + + + )} +
+
+ {a.service?.name}{dur ? ` (${dur} min)` : ""} +
+ {isTime && a.employee && !isNarrow && ( +
con {a.employee.name}
)}
); diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index e19c34c..1842de5 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -24,6 +24,7 @@ import { Flame, Trophy, Coins, + Wallet, } from "lucide-react"; import { api } from "../lib/api"; import { useAuth } from "../lib/auth"; @@ -50,8 +51,8 @@ export function DashboardPage() { const symbol = business?.currency_symbol ?? "$"; const { data: overview, isLoading: ovLoading } = useQuery({ - queryKey: ["dashboard", "overview"], - queryFn: () => api.dashboard.overview(), + queryKey: ["dashboard", "overview", range], + queryFn: () => api.dashboard.overview(Number(range)), }); const { data: emp } = useQuery({ queryKey: ["dashboard", "best-employees", range], @@ -118,13 +119,20 @@ export function DashboardPage() {
{/* KPIs */} -
+
+ @@ -133,7 +141,7 @@ export function DashboardPage() { value={ovLoading ? "…" : formatNumber(overview?.appts_upcoming ?? 0)} sub="próximas" icon={CalendarCheck} - color="#10b981" + color="#6366f1" /> - Utilización {e.stats?.utilization_pct ?? 0}% + % de citas {e.stats?.share_pct ?? 0}%
-
+
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 677a73f..d22fe8b 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -5,11 +5,13 @@ import { useAuth } from "../lib/auth"; import { api } from "../lib/api"; import { Spinner } from "../components/ui"; +const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1"; + export function LoginPage() { const { login, user } = useAuth(); const navigate = useNavigate(); - const [email, setEmail] = useState("owner@agendapro.demo"); - const [password, setPassword] = useState("demo1234"); + const [email, setEmail] = useState(DEMO ? "owner@agendapro.demo" : ""); + const [password, setPassword] = useState(DEMO ? "demo1234" : ""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [demoUsers, setDemoUsers] = useState<{ email: string; name: string; role: string; avatar_color: string }[]>([]); @@ -19,6 +21,7 @@ export function LoginPage() { }, [user, navigate]); useEffect(() => { + if (!DEMO) return; api.auth.demoUsers().then((r) => setDemoUsers(r.users)).catch(() => {}); }, []); @@ -36,20 +39,24 @@ export function LoginPage() { } }; - const quickLogin = async (mail: string) => { - setEmail(mail); - setPassword("demo1234"); - setError(null); - setLoading(true); - try { - await login(mail, "demo1234"); - navigate("/", { replace: true }); - } catch (err: any) { - setError(err.message); - } finally { - setLoading(false); - } - }; + // Demo-only quick login. Dead-code-eliminated from the prod bundle (DEMO is a + // build-time constant), which is why the demo password lives only inside this branch. + const quickLogin = DEMO + ? async (mail: string) => { + setEmail(mail); + setPassword("demo1234"); + setError(null); + setLoading(true); + try { + await login(mail, "demo1234"); + navigate("/", { replace: true }); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + } + : undefined; return (
@@ -61,7 +68,7 @@ export function LoginPage() {
- AgendaPro + AgendaMax
@@ -87,7 +94,7 @@ export function LoginPage() {
- © {new Date().getFullYear()} AgendaPro · Demo + © {new Date().getFullYear()} AgendaMax · Demo
@@ -98,7 +105,7 @@ export function LoginPage() {
-

AgendaPro

+

AgendaMax

@@ -149,41 +156,47 @@ export function LoginPage() { -
- - cuentas demo - -
+ {DEMO && ( + <> +
+ + cuentas demo + +
-
- {demoUsers.slice(0, 4).map((u) => ( - - ))} -
+
+ {demoUsers.slice(0, 4).map((u) => ( + + ))} +
+ + )}
-

- Demo · cualquier cuenta usa la contraseña demo1234 -

+ {DEMO && ( +

+ Demo · cualquier cuenta usa la contraseña demo1234 +

+ )}
diff --git a/src/pages/MyPerformancePage.tsx b/src/pages/MyPerformancePage.tsx new file mode 100644 index 0000000..c8e9819 --- /dev/null +++ b/src/pages/MyPerformancePage.tsx @@ -0,0 +1,209 @@ +import { useQuery } from "@tanstack/react-query"; +import { + CalendarCheck, + Clock, + Wallet, + Star, + Coins, + CalendarClock, +} from "lucide-react"; +import { api } from "../lib/api"; +import { useAuth } from "../lib/auth"; +import { PageHeader, Spinner, EmptyState } from "../components/ui"; +import { formatCurrency, formatTime, formatDate } from "../lib/format"; + +export function MyPerformancePage() { + const { business, user } = useAuth(); + const symbol = business?.currency_symbol ?? "$"; + + const { data: perf, isLoading } = useQuery({ + queryKey: ["me", "performance"], + queryFn: () => api.me.performance(), + }); + const { data: commissions } = useQuery({ + queryKey: ["me", "commissions"], + queryFn: () => api.me.commissions(), + }); + + const next = perf?.next_appointment ?? null; + const commissionRows = commissions?.rows ?? []; + + return ( +
+ + +
+ {/* KPIs */} +
+ + + + +
+ + {/* Próxima cita + Comisiones total */} +
+ {/* Próxima cita */} +
+
+ +

Próxima cita

+
+ {!next ? ( + + ) : ( +
+
+ + {formatTime(next.start_at)} + + + {formatDate(next.start_at, { weekday: "long", day: "numeric", month: "short" })} + +
+
+
{next.service_name}
+
{next.client_name}
+
+
+ )} +
+ + {/* Comisiones list */} +
+
+
+ +

Mis comisiones (30 días)

+
+
+
+ Total de comisiones +
+
+ {formatCurrency(commissions?.total ?? 0, symbol)} +
+
+
+ {!commissions ? ( +
+ +
+ ) : commissionRows.length === 0 ? ( + + ) : ( + <> + {/* Mobile cards */} +
+ {commissionRows.slice(0, 20).map((r, i) => ( +
+
+
{r.service_name}
+
+ {r.client_name} · {formatDate(r.created_at)} +
+
+
+
+ {formatCurrency(r.commission, symbol)} +
+
{formatCurrency(r.amount, symbol)}
+
+
+ ))} +
+ {/* Desktop table */} +
+ + + + + + + + + + + + {commissionRows.slice(0, 50).map((r, i) => ( + + + + + + + + ))} + +
ServicioClienteFechaVentaComisión
{r.service_name}{r.client_name}{formatDate(r.created_at)}{formatCurrency(r.amount, symbol)} + {formatCurrency(r.commission, symbol)} +
+
+ + )} +
+
+
+
+ ); +} + +function StatCard({ + title, + value, + sub, + icon: Icon, + color, +}: { + title: string; + value: string; + sub?: string; + icon: React.ComponentType<{ className?: string }>; + color: string; +}) { + return ( +
+
+
+
+
{title}
+
{value}
+ {sub &&
{sub}
} +
+
+ +
+
+
+ ); +} diff --git a/src/pages/admin/AdminOverviewPage.tsx b/src/pages/admin/AdminOverviewPage.tsx index 30ced5f..601b0d8 100644 --- a/src/pages/admin/AdminOverviewPage.tsx +++ b/src/pages/admin/AdminOverviewPage.tsx @@ -32,7 +32,7 @@ export function AdminOverviewPage() {
Nuevo negocio diff --git a/src/pages/public/BookingPage.tsx b/src/pages/public/BookingPage.tsx index 1427d00..ac37588 100644 --- a/src/pages/public/BookingPage.tsx +++ b/src/pages/public/BookingPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { useParams } from "react-router-dom"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Sparkles, Clock, @@ -15,6 +15,8 @@ import { PartyPopper, AlertCircle, Mail, + RefreshCw, + Info, } from "lucide-react"; import { getBusiness, @@ -64,6 +66,9 @@ export default function BookingPage() { const [selectedSlot, setSelectedSlot] = useState(null); const [client, setClient] = useState({ name: "", phone: "", email: "", notes: "" }); const [result, setResult] = useState(null); + const [bookError, setBookError] = useState(null); + + const queryClient = useQueryClient(); const businessQuery = useQuery({ queryKey: ["public", "business", slug], @@ -98,9 +103,16 @@ export default function BookingPage() { const bookMut = useMutation({ mutationFn: (payload: Parameters[1]) => book(slug!, payload), onSuccess: (res) => { + setBookError(null); setResult(res); window.scrollTo({ top: 0, behavior: "smooth" }); }, + onError: (err: Error) => { + setBookError(err?.message || "No pudimos completar tu reserva. Inténtalo de nuevo."); + setResult(null); + queryClient.invalidateQueries({ queryKey: ["public", "slots", slug] }); + window.scrollTo({ top: 0, behavior: "smooth" }); + }, }); useEffect(() => { @@ -115,6 +127,7 @@ export default function BookingPage() { setSelectedSlot(null); setClient({ name: "", phone: "", email: "", notes: "" }); setResult(null); + setBookError(null); bookMut.reset(); }; @@ -142,8 +155,28 @@ export default function BookingPage() { ); } + const hasPolicy = + (business.cancel_penalty_pct ?? 0) > 0 || !!business.require_deposit; + const policyNote = ( + + ); + if (result) { - return ; + return ( + + ); } if (!business.booking_enabled) { @@ -177,12 +210,14 @@ export default function BookingPage() { if (serviceId !== id) { setServiceId(id); setSelectedSlot(null); + setBookError(null); } }; const pickEmployee = (id: number | null) => { setEmployeeId(id); setSelectedSlot(null); + setBookError(null); }; const pickSlot = (slot: PublicSlot) => { @@ -193,6 +228,7 @@ export default function BookingPage() { const handleBook = () => { if (!selectedService || !selectedSlot) return; if (!client.name.trim()) return; + setBookError(null); bookMut.mutate({ service_id: selectedService.id, employee_id: autoAssign ? undefined : selectedSlot.employee_id, @@ -267,6 +303,7 @@ export default function BookingPage() { onDate={(d) => { setSelectedDate(d); setSelectedSlot(null); + setBookError(null); }} min={todayStr()} max={maxBookableDate()} @@ -285,9 +322,30 @@ export default function BookingPage() { title="Tus datos" subtitle="Necesitamos algunos datos para confirmar tu cita." > + {bookError && ( +
+ +
+

{bookError}

+ +
+
+ )} @@ -705,15 +763,17 @@ function DetailsForm({ client, setClient, summary, + policy, }: { client: { name: string; phone: string; email: string; notes: string }; setClient: (c: { name: string; phone: string; email: string; notes: string }) => void; summary: React.ReactNode; + policy?: React.ReactNode; }) { return (
{summary}
- + {policy}
@@ -773,6 +833,37 @@ function DetailsForm({ ); } +function PolicyNote({ + cancelWindowHours, + cancelPenaltyPct, + requireDeposit, + depositPct, +}: { + cancelWindowHours: number; + cancelPenaltyPct: number; + requireDeposit: boolean | number; + depositPct: number; +}) { + const lines: string[] = []; + if ((cancelPenaltyPct ?? 0) > 0) { + lines.push(`Cancela con al menos ${cancelWindowHours ?? 0} h de anticipación. Si no, aplica un cargo del ${cancelPenaltyPct}%.`); + } + if (requireDeposit) { + lines.push(`Se solicita un depósito del ${depositPct ?? 0}% (te lo indicará el negocio al pagar).`); + } + if (lines.length === 0) return null; + return ( +
+ +
+ {lines.map((l) => ( +

{l}

+ ))} +
+
+ ); +} + function SummaryRow({ label, value }: { label: string; value: string }) { return (
@@ -846,7 +937,7 @@ function ActionBar({ function FooterNote({ name }: { name: string }) { return (
- Reserva en línea · {name} · powered by AgendaPro + Reserva en línea · {name} · powered by AgendaMax
); } @@ -854,14 +945,21 @@ function FooterNote({ name }: { name: string }) { function Confirmation({ result, businessName, + clientName, + hasPolicy, + policy, onReset, }: { result: BookResponse; businessName: string; + clientName: string; + hasPolicy: boolean; + policy?: React.ReactNode; onReset: () => void; }) { const { appointment } = result; const currency = result.business.currency_symbol || "$"; + const showRisk = result.risk_flag === true || (result.no_show_count ?? 0) >= 2; return (
@@ -879,6 +977,14 @@ function Confirmation({

+
+

+ {result.client_created === false + ? `¡Gracias por volver, ${clientName}! 👋` + : `¡Hola, ${clientName}! Tu cita quedó confirmada.`} +

+
+
@@ -908,6 +1014,16 @@ function Confirmation({
+ {showRisk && ( +
+ +

+ Notamos ausencias previas sin avisar. Te enviaremos un recordatorio; reserva y no asistir puede generar un cargo según la política del negocio. +

+
+ )} + {hasPolicy && policy ?
{policy}
: null} + diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +///