From d796538fd95ab76b20f1b88a1a939133fba72536 Mon Sep 17 00:00:00 2001 From: AgendaPro Dev Date: Mon, 27 Jul 2026 10:11:16 -0600 Subject: [PATCH] feat: rebrand AgendaPro -> AgendaMax + calendar fixes + current-week demo seed - Rebrand all user-facing text to AgendaMax - Fix sidebar scrolling: card container no longer overflows html (flex h-full min-h-0) - Fix calendar toolbar hover: active buttons keep readable contrast - Sticky calendar toolbar (month/week/day always visible on scroll) - Seed guarantees 2-4 appointments per day for current week (Mon-Sat) --- .superpowers/sdd/final-review-diff.txt | Bin 0 -> 137646 bytes .superpowers/sdd/progress.md | 45 +++ .superpowers/sdd/task-1-report.md | 50 +++ .superpowers/sdd/task-10-report.md | 54 +++ .superpowers/sdd/task-11-report.md | 40 ++ .superpowers/sdd/task-12-report.md | 63 ++++ .superpowers/sdd/task-13-report.md | 41 ++ .superpowers/sdd/task-14-report.md | 36 ++ .superpowers/sdd/task-2-report.md | 61 +++ .superpowers/sdd/task-3-report.md | 41 ++ .superpowers/sdd/task-4-report.md | 89 +++++ .superpowers/sdd/task-5-report.md | 66 ++++ .superpowers/sdd/task-6-report.md | 59 +++ .superpowers/sdd/task-7-report.md | 38 ++ .superpowers/sdd/task-8-report.md | 126 +++++++ .superpowers/sdd/task-9-report.md | 64 ++++ README.md | 4 +- ...sign-specialist-booking-redesign-design.md | 4 +- e2e-full.mjs | 2 +- e2e-test.mjs | 33 +- graphify-out/.graphify_python | 1 + graphify-out/.graphify_root | 1 + index.html | 4 +- package.json | 2 +- server/index.ts | 4 +- server/lib/metrics.test.ts | 46 +++ server/lib/metrics.ts | 20 + server/lib/scheduling.test.ts | 60 ++- server/lib/scheduling.ts | 63 +++- server/lib/time.test.ts | 81 ++++ server/lib/time.ts | 117 ++++++ server/routes/appointments.ts | 19 +- server/routes/booking.ts | 16 +- server/routes/clients.ts | 6 +- server/routes/dashboard.ts | 213 +++++++---- server/routes/employees.ts | 13 +- server/routes/me.ts | 134 +++++++ server/routes/notifications.ts | 21 +- server/scripts/booking-e2e.mjs | 3 + server/scripts/seed.ts | 23 +- shared/types.ts | 29 +- src/App.tsx | 4 +- src/components/AdminShell.tsx | 2 +- src/components/AppShell.tsx | 23 +- src/components/AppointmentModal.tsx | 352 +++++++++++++----- src/components/DemoSwitcher.tsx | 3 + src/index.css | 26 +- src/lib/api.ts | 26 +- src/lib/publicApi.ts | 17 +- src/pages/CalendarPage.tsx | 73 +++- src/pages/DashboardPage.tsx | 26 +- src/pages/EmployeesPage.tsx | 4 +- src/pages/LoginPage.tsx | 115 +++--- src/pages/MyPerformancePage.tsx | 209 +++++++++++ src/pages/admin/AdminOverviewPage.tsx | 2 +- src/pages/public/BookingPage.tsx | 124 +++++- src/vite-env.d.ts | 1 + 57 files changed, 2477 insertions(+), 322 deletions(-) create mode 100644 .superpowers/sdd/final-review-diff.txt create mode 100644 .superpowers/sdd/progress.md create mode 100644 .superpowers/sdd/task-1-report.md create mode 100644 .superpowers/sdd/task-10-report.md create mode 100644 .superpowers/sdd/task-11-report.md create mode 100644 .superpowers/sdd/task-12-report.md create mode 100644 .superpowers/sdd/task-13-report.md create mode 100644 .superpowers/sdd/task-14-report.md create mode 100644 .superpowers/sdd/task-2-report.md create mode 100644 .superpowers/sdd/task-3-report.md create mode 100644 .superpowers/sdd/task-4-report.md create mode 100644 .superpowers/sdd/task-5-report.md create mode 100644 .superpowers/sdd/task-6-report.md create mode 100644 .superpowers/sdd/task-7-report.md create mode 100644 .superpowers/sdd/task-8-report.md create mode 100644 .superpowers/sdd/task-9-report.md create mode 100644 graphify-out/.graphify_python create mode 100644 graphify-out/.graphify_root create mode 100644 server/lib/metrics.test.ts create mode 100644 server/lib/metrics.ts create mode 100644 server/lib/time.test.ts create mode 100644 server/lib/time.ts create mode 100644 server/routes/me.ts create mode 100644 src/pages/MyPerformancePage.tsx create mode 100644 src/vite-env.d.ts diff --git a/.superpowers/sdd/final-review-diff.txt b/.superpowers/sdd/final-review-diff.txt new file mode 100644 index 0000000000000000000000000000000000000000..87ca7f7c7b5bc4575e09ccef5dc7300ad641c927 GIT binary patch literal 137646 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 d6Q$F6rw6af&3e*nJAPH(sX-0T-A-iU{|6& 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 @@ +///