Compare commits
8
Commits
6b28b9f6c0
...
0e6195ce23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e6195ce23 | ||
|
|
bec3e45573 | ||
|
|
d796538fd9 | ||
|
|
4227db0c8b | ||
|
|
5410e27c89 | ||
|
|
ed608656e0 | ||
|
|
811d2a24b2 | ||
|
|
9d78663cfe |
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
data
|
||||
.git
|
||||
.dist
|
||||
*.md
|
||||
.opencode
|
||||
.superpowers
|
||||
graphify-out
|
||||
screenshots
|
||||
docs
|
||||
Binary file not shown.
@@ -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).
|
||||
@@ -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)
|
||||
```
|
||||
> [email protected] 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.
|
||||
@@ -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<string>` (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 || <last day of previous month> >= firstOfMonth` — disables ‹ when the entire previous month is before `min`.
|
||||
- `canNext = !maxDate || <last day of next+1 month> <= <last day of max month>` — 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 `<button>`s so Tab+Enter works, and disabled days are correctly removed from the tab order via the `disabled` attribute.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Task 11 — `DateTimePicker` 2-col + month calendar + Mañana/Tarde grouping
|
||||
|
||||
## File changed
|
||||
- `src/pages/public/BookingPage.tsx`
|
||||
- Added `import { MonthCalendar } from "../../components/MonthCalendar";` (line 28).
|
||||
- Rewrote `DateTimePicker` (now lines 556–639): 2-column grid on `md+`, vertical stack below `md`, new optional `max?: string` prop, slots grouped via new `SlotGroup` subcomponent.
|
||||
- Added new `SlotGroup` helper (lines 641–681).
|
||||
|
||||
## Verification results
|
||||
|
||||
### `npm run typecheck`
|
||||
```
|
||||
> tsc -b --noEmit
|
||||
```
|
||||
**0 errors.** The existing caller of `DateTimePicker` (in `BookingPage`) does not pass `max` — confirms `max` is optional and not breaking the caller.
|
||||
|
||||
### `npm run build`
|
||||
```
|
||||
✓ 2461 modules transformed.
|
||||
✓ built in 4.98s
|
||||
```
|
||||
**Succeeds.** Production bundle emitted.
|
||||
|
||||
### Visual smoke
|
||||
Not executed interactively (subagent has no browser). Validated indirectly via successful `vite build` (2461 modules transformed, including the rewritten `BookingPage` and the imported `MonthCalendar`). Task 12 is responsible for wiring `max`/`employeeId` end-to-end and for any final interactive smoke; the unit of work in this task compiles and type-checks cleanly against the rest of the project.
|
||||
|
||||
## Self-review
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| 2-col layout only on `md+` | ✅ Outer wrapper uses `md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:items-start md:gap-8`. Below `md` no grid is applied. |
|
||||
| Mobile stacked vertically | ✅ Right column wrapper has `mt-4 md:mt-0` so on mobile the calendar card is followed by the slots card with vertical spacing; the outer div is a plain block (no `flex`/`grid`) below `md`. |
|
||||
| Slot button markup unchanged | ✅ Identical classes (`grid grid-cols-3 gap-2 sm:grid-cols-4`; button `rounded-xl border px-2 py-2.5 text-sm font-bold transition-all`; active `border-brand-500 bg-brand-500 text-white shadow-soft`; inactive `border-slate-200 bg-white text-slate-700 hover:border-brand-400 hover:bg-brand-50`), same `key={`${slot.iso}-${slot.employee_id}`}`, same `onClick={() => onPick(slot)}`. Only the grouping wrapper (`SlotGroup`) is new. |
|
||||
| Morning/afternoon split correct, incl. 12 PM boundary | ✅ Algorithm: parse `(\d{1,2}):(\d{2})` → `h`; `pm = /PM/i`; `hour24 = pm && h!==12 ? h+12 : !pm && h===12 ? 0 : h`; bucket by `hour24 < 12`. Verified cases: `12:00 AM` → h=12, !pm, h===12 → 0 → **Mañana**. `11:30 AM` → 11 → **Mañana**. `12:00 PM` → h=12, pm, h===12 (no +12) → 12 → **Tarde**. `12:30 PM` → 12 → **Tarde**. `1:00 PM` → 13 → **Tarde**. `11:59 PM` → 23 → **Tarde**. Boundary at noon is correct (12:00 PM is the first Tarde slot). |
|
||||
| `max` is optional | ✅ Declared `max?: string`. Typecheck with caller that omits `max` passes. |
|
||||
| No commit performed | ✅ No `git commit` run. |
|
||||
| Scope respected (only `BookingPage.tsx`, only `DateTimePicker` + import + `SlotGroup`) | ✅ No other functions touched (`TopBar`, `ActionBar`, `Confirmation`, `EmployeeList`, `ServiceGrid`, `DetailsForm` unchanged; Task 8's `pl-10` untouched). |
|
||||
|
||||
## Concerns
|
||||
- None. Implementation matches the plan verbatim. Interactive browser smoke deferred to the orchestrator / Task 12 (which will also pass `max` and `employeeId` to the picker and exercise the full wizard).
|
||||
@@ -0,0 +1,63 @@
|
||||
# Task 12 — Wizard 3/4 pasos + Confirmation con reasons + anclar main ancho
|
||||
|
||||
## File changed
|
||||
- `src/pages/public/BookingPage.tsx` (only file touched)
|
||||
|
||||
## Steps applied (1–9)
|
||||
1. Replaced static `STEPS` with `FULL_STEPS` const + `stepsFor(autoAssign)` helper that drops the "Especialista" step when auto-assign is on (returns `{n, label, original}` items).
|
||||
2. Added `maxBookableDate()` helper next to `todayStr` (today + 3 months, ISO `YYYY-MM-DD`).
|
||||
3. In `BookingPage`, computed `autoAssign`, `steps` (useMemo on autoAssign), `stepCount`, and `currentOriginal` (find by `n === step`, read `.original`) **before** `slotsQuery` so the closure can see it.
|
||||
4. `slotsQuery.enabled` now uses `currentOriginal === 3` (was `step >= 3`).
|
||||
5. `goNext` / `goBack` / `pickSlot` use `stepCount` (no hard-coded `4`).
|
||||
6. `canContinue`, `primaryLabel`, `onPrimary` switch on `currentOriginal`. `handleBook` sends `employee_id: autoAssign ? undefined : selectedSlot.employee_id`.
|
||||
7. Rewrote the main `return (...)` to render sections by `currentOriginal`; `main` widens to `max-w-5xl` only when `currentOriginal === 3`, otherwise `max-w-2xl`; `DateTimePicker` gets `max={maxBookableDate()}`; the Especialista row in Datos is hidden when `autoAssign`.
|
||||
8. Updated `TopBar` + `ActionBar` signatures to accept `stepCount` + `steps`; both widened from `max-w-3xl`/`max-w-2xl` → `max-w-5xl`. TopBar now reads "Paso X de {stepCount}" and iterates the dynamic `steps` array (including the mobile `X / {stepCount}` chip).
|
||||
9. `Confirmation` renders `appointment.reasons` as `brand` badges (with Sparkles icon) guarded by `reasons && reasons.length > 0`, placed under the Especialista row.
|
||||
|
||||
Also updated the two secondary `TopBar` call sites (disabled-booking screen and `Confirmation`) to pass `stepCount={4} steps={[]}` — `step={0}` hides the indicator block, so an empty array is safe there.
|
||||
|
||||
## Verification
|
||||
|
||||
### typecheck
|
||||
```
|
||||
> tsc -b --noEmit
|
||||
(no output — 0 errors)
|
||||
```
|
||||
First pass surfaced one error: `ActionBar`'s `stepCount` param was unused (TS6133). Fixed by surfacing it in the empty-state label: `Paso {step} de {stepCount} · Continúa para reservar`.
|
||||
|
||||
### build
|
||||
```
|
||||
> tsc -b && vite build
|
||||
✓ 2461 modules transformed.
|
||||
✓ built in 5.16s
|
||||
```
|
||||
|
||||
### lint
|
||||
```
|
||||
> eslint . --ext ts,tsx
|
||||
ESLint couldn't find an eslint.config.(js|mjs|cjs) file.
|
||||
```
|
||||
Pre-existing env issue (ESLint 9 with no flat config in the repo). Not introduced by this task; no `react-hooks/exhaustive-deps` warnings would be reachable until the config is repaired. The `useMemo(() => stepsFor(autoAssign), [autoAssign])` dep array is correct.
|
||||
|
||||
### Runtime smoke (server already running on :5173)
|
||||
- Slug: `lumiere-estetica-spa`. Default `auto_assign_specialist=0`.
|
||||
- **OFF flow**: GET `/api/public/<slug>` → `auto_assign_specialist=0`. Slots for service 6 tomorrow returned 10 slots. POST `/book` without `employee_id` → 201, `emp_id=2 Mateo Herrera`, `reasons=["Disponible"]`, `client_created=true`. (Server still auto-ranked because the client didn't pick one — that's the unchanged 4-step path.)
|
||||
- **ON flow**: PATCH `/api/settings {auto_assign_specialist:1}`. GET public → `auto_assign_specialist=1` (correctly exposed). POST `/book` without `employee_id` at a free slot → 201, `emp_id=2 Mateo Herrera`, `reasons=["Disponible"]`.
|
||||
- Double-booking guard: re-POSTing the first booked slot → 409 `Esa hora acaba de ocuparse, elige otra.` ✓
|
||||
- Restored `auto_assign_specialist=0` afterwards.
|
||||
|
||||
Visual confirmation of step count / layout width was done by code inspection (no headless browser available in this subagent): `stepsFor(false)` yields 4 items; `stepsFor(true)` yields 3 items (Especialista filtered). The TopBar "Paso X de {stepCount}" and the mobile "X / {stepCount}" both read from the dynamic count, so 4 vs 3 follows automatically. `main`'s width is selected by `currentOriginal === 3 ? "max-w-5xl" : "max-w-2xl"`, which is true only on the Horario section regardless of the 3- or 4-step mapping.
|
||||
|
||||
## Deviations
|
||||
- `ActionBar`'s previously-unused `stepCount` prop (the spec required ActionBar to accept it, but the plan's literal snippet didn't read it) is now surfaced in the empty-state copy. This both satisfies TS6133 and improves UX consistency.
|
||||
- No commit performed (per task constraints).
|
||||
|
||||
## Self-review
|
||||
- **OFF = 4 steps?** Yes — `stepsFor(false)` maps all 4 `FULL_STEPS` to `{1:Servicio, 2:Especialista, 3:Horario, 4:Datos}` with `original` matching `n`. `currentOriginal` drives section rendering, so the 4-step Servicio→Especialista→Horario→Datos flow is preserved exactly. The "Cualquiera" path still hits the server ranking (server-side, unchanged here).
|
||||
- **ON = 3 steps?** Yes — `stepsFor(true)` filters out Especialista, renumbering to `{1:Servicio (orig 1), 2:Horario (orig 3), 3:Datos (orig 4)}`. The `currentOriginal === 2` branch (Especialista) never matches because no step maps to original 2.
|
||||
- **currentOriginal before slotsQuery?** Yes — declared at line 83, slotsQuery at line 90 references it in `enabled` at line 93.
|
||||
- **handleBook sends undefined employee_id when autoAssign?** Yes — line 198 `employee_id: autoAssign ? undefined : selectedSlot.employee_id`. `BookPayload.employee_id` is optional, so this typechecks.
|
||||
- **Confirmation shows reasons badges?** Yes — guarded by `appointment.reasons && appointment.reasons.length > 0`, renders `<span>` chips with Sparkles icon under the Especialista row.
|
||||
- **main widened only on Horario?** Yes — `cn("…", currentOriginal === 3 ? "max-w-5xl" : "max-w-2xl")`. Horario is always original 3 in both flows.
|
||||
- **Disabled-booking screen, loading/error states, reset() preserved?** Yes — only the TopBar call in the disabled screen was updated to pass the new required props; the rest of that branch is untouched. Loading/error branches and `reset()` are unchanged.
|
||||
- **No git commit.** Confirmed — only file edits + npm runs.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Task 13 Report — SettingsPage (auto-assign + working hours editor)
|
||||
|
||||
## Files changed
|
||||
1. **CREATED** `src/lib/workingHours.ts` — exports `DEFAULT_WH`, `DAYS`, `parseWh`. Pure data/helpers, no React/UI.
|
||||
2. **MODIFIED** `src/pages/SettingsPage.tsx`:
|
||||
- Added `Wand2`, `Clock` to the lucide-react imports.
|
||||
- Imported `DEFAULT_WH`, `DAYS`, `parseWh` from `../lib/workingHours`.
|
||||
- Added local `wh` state (`useState<Record<number, {start,end}|null>>(DEFAULT_WH)`).
|
||||
- Replaced the `useEffect` so it now also calls `setWh(parseWh(data.settings.working_hours))`.
|
||||
- Extended the PATCH `api.settings.update` payload with `auto_assign_specialist` (0/1) and `working_hours: wh` (object).
|
||||
- Added, inside the "Reservas online" card (after the URL/slug block): a "Asignar especialista automáticamente" checkbox (Wand2 icon) and a "Horario del negocio" block rendering `<WorkingHoursEditor value={wh} onChange={setWh} />`.
|
||||
- Added a local `WorkingHoursEditor` subcomponent (per-day on/off + start/end `<input type="time">`, "Cerrado" label when off).
|
||||
|
||||
## Typecheck / build
|
||||
- `npm run typecheck` → 0 errors.
|
||||
- `npm run build` → succeeded (vite build OK, all chunks emitted).
|
||||
|
||||
## Runtime smoke
|
||||
- **Not executed in this subagent session** (subagent has no browser; dev server would need to be started manually by the orchestrator). The plan's Task 13 Step 4 lists this as the runtime check:
|
||||
- Boot `npm run dev`, login owner → `/settings`.
|
||||
- Toggle the auto-assign checkbox; edit working hours (e.g. Saturday on 10:00–14:00); Save; reload.
|
||||
- Expect: GET `/settings` reflects `auto_assign_specialist=1` and `working_hours='{"1":{"start":"09:00","end":"20:00"},...,6:{...},7:null}'` (or whatever was entered).
|
||||
- Expect on `/b/<slug>`: wizard collapses to 3 steps (Servicio/Horario/Datos) when toggle is on, and the slot list is constrained to the configured working hours (e.g. Saturday shows only 10:00–14:00 slots).
|
||||
- **Backend prerequisites (must already be in place for persistence to work):** Task 7 must have run — `FIELDS` whitelist in `server/routes/settings.ts` includes `auto_assign_specialist` and `working_hours`, both SELECTs return them, and the PATCH handler serializes the object → JSON string and coerces the boolean to 0/1. If Task 7 has NOT been applied yet, the PATCH will silently drop both fields (whitelist) and persistence will not occur.
|
||||
|
||||
## Deviations
|
||||
- **Improvement applied (per task spec):** `DEFAULT_WH`/`DAYS`/`parseWh` live in `src/lib/workingHours.ts` from the start, imported by SettingsPage — instead of being defined inline here and refactored out in Task 14. Task 14 (EmployeeModal) will import the same helpers.
|
||||
- No other deviations from plan Task 13. `WorkingHoursEditor` stays a local component inside SettingsPage (not exported from `workingHours.ts`).
|
||||
|
||||
## Self-review
|
||||
- **Does the PATCH send `working_hours` as an object?** Yes — `working_hours: wh` where `wh` is a `Record<number, {start,end}|null>`. Backend (Task 7 Step 1) is responsible for `JSON.stringify`-ing it before SQL. Since `api.settings.update` is typed `any`, TS does not complain.
|
||||
- **Does `parseWh` handle the JSON-string returned by GET?** Yes — when `v` is a string it `JSON.parse`s it (catching syntax errors → null), and when null/non-object it falls back to `{...DEFAULT_WH}`. It also tolerates partial maps (missing day keys → `null`).
|
||||
- **Does the editor toggle per day?** Yes — `toggle(n)` flips `value[n]` between `null` and `{start:"09:00", end:"18:00"}`; when off, the day row shows "Cerrado".
|
||||
- **First-load safety:** `wh` initialises from `DEFAULT_WH`, so even before the GET resolves the editor renders sane values (Lun–Vie 09–20, Sáb/Dom closed) rather than crashing on `undefined`.
|
||||
- **Scope discipline:** only `src/lib/workingHours.ts` (new) and `src/pages/SettingsPage.tsx` were touched. `EmployeesPage.tsx`, `BookingPage.tsx`, CSS, and any backend files were left alone.
|
||||
- **No commit performed** (per CRITICAL constraint #1).
|
||||
|
||||
## Concerns / handoff notes for orchestrator
|
||||
1. **Backend coupling:** this task is only the UI half. Persistence depends on Task 7 having extended `FIELDS` + SELECTs + the serialization block in `server/routes/settings.ts`. Verify Task 7 landed before doing the runtime smoke.
|
||||
2. **`auto_assign_specialist` initial value from GET:** backend returns it as 0/1 (INTEGER). The checkbox does `!!f.auto_assign_specialist` which coerces correctly. PATCH sends `f.auto_assign_specialist ? 1 : 0`, also correct.
|
||||
3. **Visual check:** the new "auto-assign" checkbox + working-hours block sit inside the existing `space-y-4 p-5 pt-0` container of the "Reservas online" card, so vertical rhythm is preserved without further CSS work.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Task 14 — `EmployeeModal`: especialidades, horario, eficiencia
|
||||
|
||||
## File changed
|
||||
- `src/pages/EmployeesPage.tsx` — extended `EmployeeModal` with three new controls (specialties chips, efficiency range, working hours editor with inherit toggle); added import of `DEFAULT_WH`, `DAYS`, `parseWh` from `../lib/workingHours`; extended `useEffect` sync and mutation payload.
|
||||
|
||||
No other files touched. `src/lib/workingHours.ts` was already created in Task 13 and is only imported.
|
||||
|
||||
## Typecheck / build
|
||||
- `npm run typecheck` → **0 errors**.
|
||||
- `npm run build` → **succeeds** (2462 modules transformed, 5.11s).
|
||||
- `npm run lint` → fails with a pre-existing repo-level config error (`ESLint couldn't find an eslint.config.(js|mjs|cjs)` — ESLint 9 migration not done). Not caused by this task; same result on `main`.
|
||||
|
||||
## Runtime smoke
|
||||
Not executed manually (no browser session in this subagent run). Static verification instead:
|
||||
- Backend wiring confirmed in `server/routes/employees.ts`:
|
||||
- POST accepts `specialties`, `working_hours`, `efficiency_score` (line 76).
|
||||
- PATCH handles `working_hours === null` → stored as `null` (line 119) — exactly the "inherit from business" semantics the UI sends.
|
||||
- `efficiency_score` coerced via `Number(...)` server-side; `specialties` JSON-stringified if array.
|
||||
- GET `/employees` selects these columns (Task 7), so reopening the modal will prefill via the `useEffect` sync branch.
|
||||
- The `Employee` type in `shared/types.ts` declares all three fields optional (lines 53–55), so the prefill reads (`employee.specialties`, `employee.efficiency_score`, `employee.working_hours`) typecheck cleanly.
|
||||
|
||||
Recommended manual follow-up (per task spec): boot `npm run dev`, owner → `/employees`, edit an employee — add "Corte" + "Coloración", set efficiency 85, uncheck inherit and set custom hours, save, reopen to confirm persistence; toggle inherit back on, save, confirm `working_hours` column is `null` in DB.
|
||||
|
||||
## Deviations
|
||||
- Plan Step 1 said to *create* `src/lib/workingHours.ts` and refactor SettingsPage. Per the task brief, the file already exists (Task 13) and SettingsPage already imports from it — so I only added the import to `EmployeesPage.tsx`. No other files modified.
|
||||
- Lint not run green (pre-existing repo config issue, not introduced here).
|
||||
|
||||
## Self-review
|
||||
- **Payload shape:**
|
||||
- `specialties` sent as a `string[]` (the state is `string[]`, no manual serialization). ✓
|
||||
- `efficiency_score` sent as `number` (`efficiency` is `number`, initialized via `Number(e.target.value)`). ✓
|
||||
- `working_hours` sent as `null` when `inheritHours === true`, otherwise the live `wh` map. ✓ Matches the backend's `working_hours === null ? null : …` branch.
|
||||
- **Edit-mode prefill:** `useEffect[open, employee]` reads `employee.specialties` (with `Array.isArray` guard), `employee.efficiency_score` (with `typeof === "number"` guard, default 50), and `parseWh(employee.working_hours)` plus a `hasOwn` check so a stored `null`/empty map flips `inheritHours` back to `true` and resets `wh` to `DEFAULT_WH`. ✓
|
||||
- **Duplicate prevention in chips input:** `setSpecialties((a) => [...new Set([...a, specialtyInput.trim()])])` — `Set` dedupes by value; case-sensitive (so "Corte" ≠ "corte"), which matches how the backend stores them. ✓
|
||||
- **Reset on close/new:** the `else` branch of the `useEffect` clears `specialties`, `specialtyInput`, `efficiency`, `inheritHours`, and `wh` — no stale state leaks between create/edit sessions. ✓
|
||||
- **Type safety on `wh[n]`:** narrowed with `!(wh[n])` / `wh[n]!.start` and an explicit type cast on the spread (`as { start: string; end: string }`) to avoid `null`-widening complaints — typecheck confirms.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Task 2 Report — scheduling.ts pure functions + unit tests
|
||||
|
||||
## Status
|
||||
✅ COMPLETE — all acceptance criteria met.
|
||||
|
||||
## Files changed
|
||||
- **Created** `server/lib/scheduling.ts` — 15 pure exports (types + functions), zero imports. ~95 lines.
|
||||
- **Created** `server/lib/scheduling.test.ts` — 15 `test()` blocks using `node:test` + `node:assert/strict`.
|
||||
- **Modified** `package.json` — added `"test:unit": "node --import tsx --test server/lib/scheduling.test.ts"` to `scripts`.
|
||||
|
||||
## Verification
|
||||
|
||||
### `npm run typecheck` → 0 errors
|
||||
```
|
||||
> [email protected] typecheck
|
||||
> tsc -b --noEmit
|
||||
(clean — no diagnostics)
|
||||
```
|
||||
`allowImportingTsExtensions: true` is set in `tsconfig.json`, so `import { ... } from "./scheduling.ts"` resolves correctly under tsc.
|
||||
|
||||
### `npm run test:unit` → all PASS
|
||||
```
|
||||
✔ parseWorkingHours: json válido → mapa 1..7
|
||||
✔ parseWorkingHours: null/invalid → null
|
||||
✔ isoDayOfWeek: lunes=1, domingo=7
|
||||
✔ getWorkingHoursForDate: empleado tiene prioridad sobre negocio
|
||||
✔ getWorkingHoursForDate: día cerrado → null
|
||||
✔ normalizeText/tokens: quita acentos y lowercase
|
||||
✔ specialtyMatch: coincidencia exacta de etiqueta → 1
|
||||
✔ specialtyMatch: etiqueta con acento/case → 1
|
||||
✔ specialtyMatch: sin etiquetas → 0.5
|
||||
✔ specialtyMatch: etiqueta no relacionada → 0.5
|
||||
✔ overlaps: bordes inclusivos de no-traslape
|
||||
✔ hasConflict: lista vacía → false
|
||||
✔ scoreCandidate: pesos 50/30/20
|
||||
✔ scoreCandidate: mayor efficiency → mayor score
|
||||
✔ scoreCandidate: menor load (más disponible) → mayor score
|
||||
ℹ tests 15
|
||||
ℹ pass 15
|
||||
ℹ fail 0
|
||||
ℹ duration_ms 201.8453
|
||||
```
|
||||
|
||||
**Summary line:** `# tests 15 | # pass 15 | # fail 0 | duration 201.8 ms`
|
||||
|
||||
## Deviations from plan
|
||||
- **None.** The plan said "≈14 tests" but the spec actually contains 15 `test()` blocks — all 15 are present and pass. Code is verbatim from the plan (only the pure functions; no DB imports added — those belong to Task 3).
|
||||
|
||||
## Self-review
|
||||
|
||||
- **All 15 tests present?** ✅ Counted 15 `test(` calls in the file; runner reports `tests 15 / pass 15`.
|
||||
- **`scoreCandidate` verifies 50/30/20 weights?** ✅ The "pesos 50/30/20" test asserts three exact points on the response surface:
|
||||
- `(1, 1, 0)` → 100 (50·1 + 30·1 + 20·1)
|
||||
- `(0, 0, 1)` → 0 (all terms zero)
|
||||
- `(0.5, 0, 1)` → 25 (only specialty term contributes: 50·0.5 = 25)
|
||||
These three points uniquely pin the 50/30/20 coefficients — any other weights would fail at least one assertion. The two follow-up tests ("mayor efficiency", "menor load") additionally confirm the monotonic direction of each knob.
|
||||
- **`overlaps` boundary case verified?** ✅ The "bordes inclusivos de no-traslape" test explicitly asserts `overlaps(100, 200, 200, 300) === false` — i.e. one window ending exactly when another starts is **not** an overlap. Two true-overlap cases (forward and reverse order) are also covered.
|
||||
- **No DB leakage?** ✅ `scheduling.ts` has zero `import` statements and does not reference `DatabaseSync`, `db`, or any module. Task 3 can append the DB section cleanly.
|
||||
|
||||
## Concerns
|
||||
None. Ready for Task 3.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Task 3 Report — scheduling.ts DB functions
|
||||
|
||||
## Files changed
|
||||
- `server/lib/scheduling.ts` — appended DB-backed functions from Task 3 plan (`CandidateInfo`, `safeArr`, `getCandidates`, `getExistingBusy`, `isAvailable`, `rankOne`, `pickBestSlotEmployee`, `AutoAssignResult`, `AutoAssignCtx`, `autoAssign`, `runInTransaction`) + `import type { DatabaseSync } from "node:sqlite"`.
|
||||
|
||||
## Typecheck
|
||||
`npm run typecheck` → **0 errors** (after the deviation below).
|
||||
|
||||
## test:unit
|
||||
`npm run test:unit` → **15/15 pass** (pure tests unaffected).
|
||||
|
||||
## tx smoke-test output
|
||||
Temp file `_tx-smoke.mjs` (deleted after run). It imports `runInTransaction` and `db`, exercises happy path + throw path, and verifies a real `UPDATE` was rolled back.
|
||||
|
||||
```
|
||||
PASS runInTransaction returns fn value (42)
|
||||
PASS no active tx after commit
|
||||
PASS runInTransaction rethrows on error
|
||||
PASS no active tx after rollback
|
||||
PASS rollback undid the UPDATE
|
||||
5/5 passed
|
||||
EXIT=0
|
||||
```
|
||||
|
||||
`PRAGMA active_transaction` was used (newer node:sqlite exposes it; falls back to a no-row / 0 object) to confirm no dangling transaction after both commit and rollback. The real-state rollback check (UPDATE `_tx_probe SET v=999` inside a throwing fn) proves the ROLLBACK is functional, not just emitted.
|
||||
|
||||
## Deviations
|
||||
1. **Plan typo fix in `autoAssign` (line 256).** The plan literally wrote:
|
||||
```ts
|
||||
if (!best || ranked.score > best.score || (ranked.score === best.score && c.info.id < best.info.id)) {
|
||||
```
|
||||
but inside `autoAssign` the loop variable `c` is a `CandidateInfo` (no `.info` field), so `c.info.id` is a type error (TS2339). Fixed to `c.id < best.info.id`. `best` is still a `RankedCandidate` so `best.info.id` is correct. The semantic intent (tie-break by lowest employee id) is preserved exactly. Note: the sibling function `pickBestSlotEmployee` uses `c.id` correctly in the plan — confirming the autoAssign line was a typo, not an intentional shape.
|
||||
|
||||
No other deviations. The `import type { DatabaseSync }` is placed mid-file (after `scoreCandidate`), which is valid because ES module `import` declarations are hoisted; `tsc -b --noEmit` and `tsx` both accept it.
|
||||
|
||||
## Self-review
|
||||
- **Does `autoAssign` return `null` when no candidate is free?** YES. Two null paths:
|
||||
1. `candidates.length === 0` → immediate `return null` (line 245).
|
||||
2. After the loop, `if (!best) return null` (line 260) covers the case where every candidate was filtered out (closed that day, slot outside working window, or has a conflict).
|
||||
- **Does it respect per-employee working hours?** YES. For each candidate it calls `getWorkingHoursForDate(c.empWh, ctx.bizWh, new Date(ctx.startMs))` — employee override wins, business is the fallback, and a closed day returns `null` → candidate is `continue`d. Then the slot must satisfy `ctx.startMs >= wh.startMs && ctx.endMs <= wh.endMs`, otherwise the candidate is skipped.
|
||||
- **Does `runInTransaction` roll back on throw?** YES. Verified by smoke test: a thrown error inside `fn` causes `db.exec("ROLLBACK")` to run, the error is re-thrown, and a real `UPDATE` made inside the throwing fn is undone (the probe row retained its pre-tx value).
|
||||
@@ -0,0 +1,89 @@
|
||||
# Task 4 Report — backend `booking.ts`: slots con working_hours + `pickBestSlotEmployee`
|
||||
|
||||
**Branch:** `feat/auto-assign-specialist`
|
||||
**Date:** 2026-07-26
|
||||
**Plan:** `docs/superpowers/plans/2026-07-26-auto-assign-specialist-booking-redesign.md` (Task 4)
|
||||
|
||||
## Files changed
|
||||
- `server/routes/booking.ts` — only file modified by this task. Diff: **+56 / −31** lines (`git diff --stat`).
|
||||
|
||||
No other files touched. `POST /:slug/book` left exactly as-is (Task 5 territory).
|
||||
|
||||
## What changed
|
||||
1. **Imports (top of file).** Added scheduling imports from `../lib/scheduling.ts`:
|
||||
`parseWorkingHours, getWorkingHoursForDate, getCandidates, getExistingBusy, pickBestSlotEmployee, type WorkingHoursMap`.
|
||||
2. **`publicBusiness(slug)`** now SELECTs two extra columns: `auto_assign_specialist, working_hours` (so the public business payload exposes them for the frontend).
|
||||
3. **`GET /:slug/slots`** computation block fully rewritten:
|
||||
- `bizWh = parseWorkingHours(biz.working_hours)` — real business working-hours map.
|
||||
- When `employeeId` is **absent** (`useRanking = !employeeId`): pre-fetches ranked `CandidateInfo[]` via `getCandidates(...)` and the per-employee busy map via `getExistingBusy(...)`, then for every grid step calls `pickBestSlotEmployee(...)` to attach the best-ranked free specialist.
|
||||
- When `employeeId` is **provided**: resolves the per-employee working-hours window (falling back to business hours) and uses the existing busy list with the local `overlapsRange` helper.
|
||||
- Day window comes from `getWorkingHoursForDate(...)` — **no more hardcoded `09:00`/`20:00`**.
|
||||
- If the resolved window is `null` (business closed that day) → `res.json({ slots: [], service: {...} })`, no throw.
|
||||
- 30-min grid, `t + dur*60000 <= dayEnd` end-clamp, `t < now+30min` skip, 40-slot cap, and the response shape `{ slots: [{time, iso, employee_id, employee_name}], service: {id, name, price, duration_min} }` are all preserved.
|
||||
4. Added a tiny module-local `overlapsRange(a,b,c,d)` helper (equivalent to `overlaps` in scheduling.ts; the plan suggested this to keep imports minimal).
|
||||
|
||||
## Verification
|
||||
|
||||
### `npm run typecheck`
|
||||
```
|
||||
> [email protected] typecheck
|
||||
> tsc -b --noEmit
|
||||
```
|
||||
**0 errors.** ✓
|
||||
|
||||
### Smoke test (live server on :3000, seeded business `lumiere-estetica-spa`)
|
||||
Wrote a throwaway `.mjs` script (deleted after) that logs in as owner, reads `/api/settings` for the slug, then hits `/api/public/<slug>` and `/api/public/<slug>/slots`.
|
||||
|
||||
**Public business now exposes the new fields:**
|
||||
```
|
||||
[info] public.business.auto_assign_specialist = 0
|
||||
[info] public.business.working_hours = string
|
||||
[ok] publicBusiness() exposes auto_assign_specialist + working_hours
|
||||
```
|
||||
|
||||
**Slots — auto-assign path (no `employee_id`, uses `pickBestSlotEmployee`):**
|
||||
Service `id=6` "Corte + arreglo de barba" (Barbería, 45 min), date `2026-07-27` (Monday, dow=1).
|
||||
```
|
||||
[info] slots status=200 count=18
|
||||
[info] service in resp = { id: 6, name: 'Corte + arreglo de barba', price: 280, duration_min: 45 }
|
||||
[sample] first slot = { time: '09:00 a.m.', iso: '2026-07-27T15:00:00.000Z', employee_id: 2, employee_name: 'Mateo Herrera' }
|
||||
[sample] last slot = { time: '07:00 p.m.', iso: '2026-07-28T01:00:00.000Z', employee_id: 2, employee_name: 'Mateo Herrera' }
|
||||
[ok] all 18 slots have employee_id+employee_name and iso within 09:00-20:00 (inWindow=true)
|
||||
```
|
||||
- 18 slots, each carries `employee_id` + `employee_name` (resolved by `pickBestSlotEmployee`).
|
||||
- Window strictly within the seed's Lun-Vie 09:00–20:00.
|
||||
- Only Mateo Herrera offers service #6 in the seed, so the ranker correctly resolves every slot to him.
|
||||
|
||||
**Closed-day path (Saturday, default seed = closed):**
|
||||
```
|
||||
[info] Saturday 2026-08-01 slots count=0 (expected 0 if Sat closed)
|
||||
[ok] closed day → { slots: [] } with service object (no throw)
|
||||
```
|
||||
The endpoint returns `{ slots: [], service: {...} }` — no exception, matching constraint #5.
|
||||
|
||||
**Specific-employee path (`employee_id=2`, the only employee offering svc #6):**
|
||||
```
|
||||
status: 200 count: 18
|
||||
all match eid: true
|
||||
first: { time: '09:00 a.m.', iso: '2026-07-27T15:00:00.000Z', employee_id: 2, employee_name: 'Mateo Herrera' }
|
||||
service in resp: { id: 6, name: 'Corte + arreglo de barba', price: 280, duration_min: 45 }
|
||||
```
|
||||
Every slot's `employee_id === 2`; service object included.
|
||||
|
||||
## Deviations from the plan
|
||||
1. **Dropped `isoDateStr` from the Task-4 import list.** The plan's Task-4 import block includes `isoDateStr`, but the Task-4 code never references it, and `tsconfig.json` has `"noUnusedLocals": true` — keeping it would fail the `npm run typecheck` verification step. Task 5 ("Ampliar imports de scheduling") explicitly adds more imports and can re-introduce `isoDateStr` at that point when it's actually used by the `POST /book` rewrite. Functionality identical.
|
||||
2. **Renamed `const any = …` → `const anyEmp = …`** in the fallback query inside the candidates block. `any` is a reserved-ish TypeScript type name and using it as a value identifier is confusing and risky under strict mode. Purely cosmetic; behavior identical.
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Does the day window come from `working_hours` (not the old hardcoded 09/20)?** Yes. The old `new Date(${date}T09:00:00)` / `T20:00:00` literals are gone. The window now comes from `getWorkingHoursForDate(singleEmpWh | null, bizWh, dateObj)`, which reads `parseWorkingHours(biz.working_hours)`. Confirmed empirically: a Saturday (closed in seed) returns 0 slots, while a Monday returns 18 slots within 09:00–20:00.
|
||||
- **When `employeeId` is null, does it use `pickBestSlotEmployee`?** Yes. `useRanking = !employeeId` gates the branch; when true, the per-slot resolver is `chosen = pickBestSlotEmployee(candInfos, candBusyMap, bizWh, {name, category}, slotStart, slotEnd)`. The previous "first free employee" inner loop is removed.
|
||||
- **Response shape preserved?** Yes — `{ slots: [{time, iso, employee_id, employee_name}], service: {id, name, price, duration_min} }` verified in all three paths (ranking, specific-employee, closed-day).
|
||||
- **30-min grid, 40-slot cap, skip-past/<30min-from-now?** All preserved (loop body unchanged in spirit; observed 18 slots, well under 40).
|
||||
- **Closed day → `{slots: []}` + service object, no throw?** Confirmed.
|
||||
- **POST /book untouched?** Yes — diff shows the entire POST handler is byte-identical to the pre-task version.
|
||||
|
||||
## Concerns
|
||||
- The seed only has one employee (Mateo Herrera) offering service #6, so the ranker resolves every slot to the same person. To genuinely exercise the multi-candidate ranking in integration, Task 7's `booking-e2e.mjs` (or a manual second employee with overlapping service + different `efficiency_score` / `specialties`) would be needed. Out of scope for Task 4.
|
||||
- `biz.working_hours` is returned to the frontend as a raw JSON **string** (not parsed). Frontend will need to `JSON.parse` it — that's expected per `shared/types.ts` (`working_hours?: WorkingHoursMap | string | null`) and consistent with how Settings already returns it. No action needed here.
|
||||
- A leftover orphan `tsx watch` process from an initial failed `Start-Process` attempt was killed during cleanup; the user's main `npm run dev` (concurrently) was left untouched and is still serving on :3000.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Task 5 Report — backend `booking.ts` `POST /book` con autoAssign + guard transaccional
|
||||
|
||||
## Status
|
||||
**DONE** — all verification green; no commit (per repo policy + dispatch constraint).
|
||||
|
||||
## Files changed
|
||||
- `server/routes/booking.ts` — only the `POST /:slug/book` handler rewritten + 3 symbols added to the existing scheduling import. **No other file touched by this task.**
|
||||
|
||||
## What changed
|
||||
|
||||
### Import (line 3-7)
|
||||
Added `autoAssign, isAvailable, runInTransaction` to the existing scheduling import list (Task 4 had already imported `parseWorkingHours`, `getWorkingHoursForDate`, `getCandidates`, `getExistingBusy`, `pickBestSlotEmployee`, `WorkingHoursMap`).
|
||||
|
||||
### `POST /:slug/book` handler (lines 125-214)
|
||||
Replaced the old handler (which did a naive `SELECT … LIMIT 1` to pick any employee and had no conflict guard) with the transactional, auto-assigning version per the plan verbatim:
|
||||
|
||||
1. **Resolve specialist inside `runInTransaction(db, …)`** (BEGIN IMMEDIATE … COMMIT/ROLLBACK):
|
||||
- `autoAssignOn = !!biz.auto_assign_specialist`
|
||||
- `clientChose = !autoAssignOn && employee_id ? Number(employee_id) : null`
|
||||
- If `!clientChose` → call `autoAssign(db, ctx)` with `bizWh = parseWorkingHours(biz.working_hours)`. If it returns null (no candidate free) → `throw { status: 409, error: "ESE_HORARIO_OCUPADO" }`.
|
||||
- If `clientChose` → validate the employee offers the service (`employee_services`), then `isAvailable(db, empId, startMs, endMs)` guard; on conflict → `throw { status: 409, error: "ESE_HORARIO_OCUPADO" }`. `reasons = ["Tu especialista elegido"]`.
|
||||
2. **Client resolution preserved verbatim**: lookup by `phone` (priority) else `email`, else `INSERT … tags='Online' RETURNING id`.
|
||||
3. **INSERT appointments** uses `new Date(endMs/startMs).toISOString().replace(/\.\d{3}Z$/, "Z")` for both ISOs (matches existing convention; confirmed in smoke response `2026-07-27T15:00:00Z`).
|
||||
4. **201 response** now includes `appointment.employee_id` (number) and `appointment.reasons: string[]` alongside the previous fields.
|
||||
5. **catch block**: if `e && typeof e === "object" && e.status` → `res.status(e.status).json({ error: e.error === "ESE_HORARIO_OCUPADO" ? "Esa hora acaba de ocuparse, elige otra." : e.error })`; otherwise `throw e` (rethrow genuine errors → handled by Express error middleware).
|
||||
|
||||
## Verification
|
||||
|
||||
### 1. `npm run typecheck` → **0 errors** ✓
|
||||
|
||||
### 2. Integration smoke (temp `.mjs`, deleted after)
|
||||
Booted a **fresh one-shot `tsx server/index.ts`** on `PORT=3007 HOST=127.0.0.1` (did NOT touch the user's existing dev server on port 3000, which is a non-watch `tsx server/index.ts` running stale code). Login `[email protected]`/`demo1234` → slug from `/api/settings` → first service from `/api/public/<slug>`.
|
||||
|
||||
- **(a) auto-assign (no `employee_id`):**
|
||||
`POST /api/public/<slug>/book { service_id, start_at:<iso>, client:{name:"Auto Test", phone:"+525500009991"} }`
|
||||
→ **201**, body:
|
||||
```json
|
||||
{"appointment":{"id":442,"start_at":"2026-07-27T15:00:00Z","end_at":"2026-07-27T15:45:00Z","price":280,"service_name":"Corte + arreglo de barba","employee_id":2,"employee_name":"Mateo Herrera","reasons":["Buena disponibilidad"]},"business":{"name":"Lumière Estética & Spa","currency_symbol":"$"},"client_created":true}
|
||||
```
|
||||
✓ `employee_id` is a real number (2). ✓ `reasons` is a non-empty array (["Buena disponibilidad"]).
|
||||
- **(b) double-booking guard (same slot + same `employee_id=2`):**
|
||||
`POST /api/public/<slug>/book { service_id, employee_id:2, start_at:<same iso>, client:{name:"Auto Test 2", phone:"+525500009992"} }`
|
||||
→ **409**, body: `{"error":"Esa hora acaba de ocuparse, elige otra."}` ✓ friendly message present.
|
||||
|
||||
The slots pre-fetch used `GET /api/public/<slug>/slots?service_id=X&date=2026-07-27` → returned a ranked slot (iso `2026-07-27T15:00:00.000Z`, pre-assigned emp=2), confirming the slots endpoint from Task 4 still works.
|
||||
|
||||
### 3. `npm run test:unit` → **15/15 PASS** ✓ (no regression)
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Is the whole booking + guard inside the transaction?** Yes. `runInTransaction(db, () => { … })` wraps: specialist resolution (autoAssign OR employee-services check OR isAvailable guard), client resolution, and the `INSERT INTO appointments`. Any thrown `{status,error}` triggers ROLLBACK before the catch translates it to HTTP.
|
||||
- **Does autoAssign run when `employee_id` is omitted?** Yes. `clientChose` is null when `employee_id` is absent (or when `auto_assign_specialist` is on, regardless of `employee_id`), and the `if (!empId)` branch calls `autoAssign(db, ctx)`. Verified by smoke (a): no `employee_id` sent → got back `employee_id=2` picked by the algorithm.
|
||||
- **Does the 409 path work?** Yes — verified two ways: (i) `autoAssign` returning null yields 409 (the "no specialist available" branch), and (ii) the `isAvailable` guard yields 409 on a concrete double-book (smoke b returned exactly 409 with the friendly message, and the ROLLBACK prevented the second INSERT).
|
||||
- **Does a client-chosen employee still get isAvailable-guarded?** Yes. When `auto_assign_specialist` is off and `employee_id` is provided, the `else` branch runs: employee_services validation, then `if (!isAvailable(db, empId, startMs, endMs)) throw {status:409,…}`. Smoke (b) exercised exactly this path (client chose emp=2, slot was already taken by smoke a → 409).
|
||||
- **Constraint: `auto_assign_specialist=1` overrides client choice.** Yes — `clientChose = !autoAssignOn && employee_id ? … : null` is null when `autoAssignOn`, so even an explicit `employee_id` is ignored and autoAssign runs. (Not exercised live in the smoke because the seed has `auto_assign_specialist=0`; the logic is straight from the plan.)
|
||||
- **Preserved client-resolution logic?** Yes — identical phone-then-email lookup with `tags='Online'` on create.
|
||||
- **ISO format?** Yes — `.replace(/\.\d{3}Z$/, "Z")` applied to both `startIso` and `endIso`; confirmed in the 201 response (`...T15:00:00Z`, no millis).
|
||||
- **Scope discipline?** Yes — only `POST /:slug/book` and the import list touched. Slots endpoint, `publicBusiness`, `publicBusiness`-exposing `GET /:slug`, and `overlapsRange` helper are untouched.
|
||||
- **No commit made?** Confirmed — `git log` shows latest commit is still `e8d2435` (v1.0 base); all work is in the working tree.
|
||||
|
||||
## Deviations
|
||||
None. The handler is transcribed verbatim from the plan (lines 776-866 of the plan). The import addition merges into the existing multi-line import rather than adding a second `import { autoAssign, isAvailable, runInTransaction } …` line (the plan's Step 1 phrasing is "Añadir a la lista de imports de Task 4" — i.e. extend the existing list — which is what I did; a duplicate import statement would have been a TypeScript error).
|
||||
|
||||
## Concerns
|
||||
- **Dev-server caveat (informational, not a defect):** the user's currently running dev server on port 3000 (PID 15992) is `tsx server/index.ts` (NOT `tsx watch`), so it has NOT picked up my booking.ts change. It will need a restart to serve the new code. I ran my smoke against a separate one-shot instance on port 3007 (now killed) to verify without disturbing the user's process. All my temp processes and log files have been cleaned up; port 3000 still belongs to the user's original process.
|
||||
- **DB side-effect:** the smoke test inserted 2 appointments (ids ~442) and 2 clients into the dev SQLite DB. This is expected per the plan's verification steps (the plan's own e2e script and seed note acknowledge this). A `RESET_DB=1 npm run seed` will reset if a clean DB is desired.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Task 6 — backend `appointments.ts` conflict guard + autoAssign fallback
|
||||
|
||||
## Files changed
|
||||
- `server/routes/appointments.ts`
|
||||
- Added import: `import { autoAssign, isAvailable, runInTransaction, parseWorkingHours } from "../lib/scheduling.ts";`
|
||||
- Rewrote the `POST /` handler tail (from the old `// Resolve employee` comment through the handler's closing `})`) so that:
|
||||
- `start`/`end`/`startMs`/`endMs`/`finalStatus`/`price` are computed first (outside the tx).
|
||||
- The employee resolution + guard + INSERT + completed→ticket + `joinAppointment` all run inside `runInTransaction(db, …)`.
|
||||
- Employee resolution keeps the same priority chain (`employee_id` from body → `employee.role` self-assign → `autoAssign` fallback). The old `LIMIT 1` lookup over `employee_services` is removed; when no employee is resolved, `autoAssign(db, {…})` picks the best available specialist.
|
||||
- Before the INSERT, `isAvailable(db, empId, startMs, endMs)` is checked; on conflict it throws `{status:409, error:"Ese horario ya está ocupado para el especialista"}`.
|
||||
- The catch translates any `{status,error}` throw into `err(res, status, error)` and rethrows everything else.
|
||||
- `GET /`, `GET /:id`, `PATCH /:id`, `POST /:id/complete`, `DELETE /:id`, `GET /:id/cancellation-policy` are **untouched**. `joinAppointment` and `computeCommission` are untouched.
|
||||
|
||||
## Deviations from the plan's literal text
|
||||
1. **Replaced a wider window than "lines 129-167".** The plan said to replace from `const start = new Date(start_at);` (line 129) through the handler end, but the verbatim block it provided *re-resolves* `empId` inside the transaction. If I had kept the original outer `empId` resolution (lines 116-127, including the `LIMIT 1` lookup and the early `return err(res,400,…)`), the outer code would (a) shadow the inner `empId`, (b) leave the new `autoAssign` path as dead code, and (c) directly contradict the task summary ("when still no employee, call `autoAssign(...)` instead of the old `LIMIT 1` lookup"). So I replaced the outer block too. This matches the verbatim block's own comment ("Resolver empleado (igual que antes, pero usando autoAssign si ninguno)") and the task summary.
|
||||
2. **`businessId: Number(req.user!.business_id)` instead of `businessId: req.user!.business_id`.** `AuthedRequest.user.business_id` is typed `number | null` (from `shared/types.ts`), but `AutoAssignCtx.businessId` requires `number`. `Number(...)` is safe because `authRequired` guarantees an authenticated user with a business. This is a literal-text bug in the plan; without the coercion `tsc` errors with TS2322.
|
||||
|
||||
## Verification
|
||||
|
||||
### `npm run typecheck`
|
||||
**PASS — 0 errors.** (After the `Number(...)` coercion; raw plan text produced `TS2322: Type 'number | null' is not assignable to type 'number'`.)
|
||||
|
||||
### `npm run test:unit`
|
||||
**PASS — 15/15.** `scheduling.test.ts` unchanged and green.
|
||||
|
||||
### `npm run test:e2e`
|
||||
**FAIL — but NOT a code bug; it is the seed/test-data concern the task anticipated.**
|
||||
|
||||
- The failure is at `e2e-test.mjs:46` ("create appointment"): `POST /api/appointments { service_id:1, client_id:1, start_at:"2026-09-15T11:00:00Z" }` returns **400** `{"error":"No hay empleado asignado a este servicio"}` (i.e. `autoAssign` returned `null`), which makes line 47 throw a `TypeError` reading `.id` off `undefined`.
|
||||
- The other 16 checks up to that point all PASS (login, /me, business, employees+stats, services, clients+stats, appointments joined, dashboard ×9, tickets).
|
||||
|
||||
**Root cause (verified):** timezone mismatch between the e2e fixture and the new working-hours enforcement.
|
||||
- Server TZ: `America/Mexico_City` (UTC−6).
|
||||
- Fixture slot `2026-09-15T11:00:00Z` = **05:00 local** — before the business's 09:00 opening (Mon–Fri 09:00–20:00).
|
||||
- `autoAssign` → `getWorkingHoursForDate` builds the day window in local time (09:00–20:00 local) and filters every candidate whose slot doesn't fit; at 05:00 local nobody qualifies → `autoAssign` returns `null` → handler throws `{status:400, "No hay empleado asignado a este servicio"}`.
|
||||
- The legacy `LIMIT 1` lookup never checked working hours, so this exact fixture used to pass; the new code enforces the plan's hard constraint.
|
||||
|
||||
This is the "seed/test-data concern, not a code defect" case the task description calls out (it anticipated a 409, but the same principle applies to the 400 from the stricter autoAssign). The fix is data-side — either run the server in UTC, or update `e2e-test.mjs` to use in-hours ISO slots (the plan's own Task 7 `booking-e2e.mjs` already does this by fetching slots from `/slots`). **The guard was not weakened.**
|
||||
|
||||
### Conflict smoke (temp `.mjs`, created/verified/deleted)
|
||||
Using an in-hours slot `2027-02-01T17:00:00Z` (= 11:00 local):
|
||||
- Create #1 (`service_id:1`, owner, no `employee_id`): **201**, `autoAssign` placed it on `employee_id:1`.
|
||||
- Create #2 (same `employee_id:1` + same slot): **409** `{"error":"Ese horario ya está ocupado para el especialista"}`.
|
||||
- Create #3 (same `employee_id:1` + 2 h later, non-overlapping): **201**.
|
||||
|
||||
All three behave exactly as required.
|
||||
|
||||
## Self-review
|
||||
- **Guard inside tx?** Yes — `isAvailable(db, empId, startMs, endMs)` runs inside `runInTransaction(db, …)`, before the INSERT. Throwing inside rolls back via the helper's ROLLBACK path.
|
||||
- **autoAssign fallback when no employee?** Yes — after the body's `employee_id` and the employee-role self-assign, if `!empId`, the handler loads `businesses.working_hours`, parses it, and calls `autoAssign(db, {businessId, serviceId, serviceName, serviceCategory, startMs, endMs, bizWh})`. `empId = aa?.employeeId ?? null`; if still null, throws `{status:400,…}`.
|
||||
- **Employee-role self-assign preserved?** Yes — `if (!empId && req.user!.role === "employee" && req.user!.employee_id) empId = req.user!.employee_id;` is intact.
|
||||
- **Owner with no employee_id → autoAssign (not LIMIT 1)?** Yes — the old `SELECT … FROM employee_services … LIMIT 1` block is removed.
|
||||
- **completed→ticket still works?** Yes — the `if (finalStatus === "completed") { INSERT INTO tickets … }` block is preserved verbatim inside the tx, keyed on the freshly `RETURNING *`-ed `inserted.id`.
|
||||
- **Price override preserved?** Yes — `price = price_override !== undefined ? Number(price_override) : service.price` computed before the tx and passed into the INSERT.
|
||||
- **Client resolution preserved?** Yes — untouched above the replaced block (existing `client_id` or `new_client` create).
|
||||
- **`joinAppointment` on result?** Yes — called on `inserted` inside the tx, then returned; `res.status(201).json({ appointment: r })` outside.
|
||||
- **Other endpoints untouched?** Yes — GET/PATCH/DELETE/complete/cancellation-policy are byte-identical to before.
|
||||
- **Response on success?** Yes — `res.status(201).json({ appointment: r })` unchanged.
|
||||
- **No `git commit` run?** Correct — no commit, no stage.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Task 7 — backend settings + employees (campos nuevos) + script de integración
|
||||
|
||||
## Status
|
||||
**COMPLETE** — all verification green, no commits made.
|
||||
|
||||
## Files changed
|
||||
- `server/routes/settings.ts` — extended `FIELDS` with `auto_assign_specialist`, `working_hours`; added both columns to GET and final-PATCH SELECTs; serialized `working_hours` to JSON when received as object; coerced `auto_assign_specialist` to `0`/`1`.
|
||||
- `server/routes/employees.ts` — `POST /` and `PATCH /:id` now accept and persist `specialties`, `working_hours`, `efficiency_score` (JSON-serialized arrays/maps, `Number(...)` coercion for efficiency).
|
||||
- `server/scripts/booking-e2e.mjs` — NEW. Slot-aware integration test covering auto-assign, double-booking 409, and `auto_assign_specialist` exposure on the public business endpoint.
|
||||
- `package.json` — added `"test:booking": "node server/scripts/booking-e2e.mjs"`.
|
||||
- `e2e-test.mjs` — fixture adaptation only: added `findSlot(slug, serviceId, maxDays=30)` helper that queries `/public/<slug>/slots` for the next 30 days and returns the first available slot iso; replaced the two hard-coded `start_at` ISO strings (`2026-09-15T11:00:00Z`, `2026-09-20T10:00:00Z`) with slot-aware values. Added a `GET /settings` call after login to obtain the business `slug`. Employee-service fallback: tries service_id 5 first, falls back to 6 (both offered only by Mateo Herrera, so the self-assign assertion still holds).
|
||||
|
||||
## Verification results
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `npm run typecheck` | **0 errors** |
|
||||
| `npm run test:unit` | **15/15 passed** |
|
||||
| `npm run test:e2e` | **25/25 passed** (was failing before the fixture fix) |
|
||||
| `npm run test:booking` | **9/9 passed** |
|
||||
|
||||
## Manual API check (temp .mjs, run + deleted)
|
||||
- `PATCH /settings { auto_assign_specialist: 1, working_hours: {1:{start:"09:00",end:"13:00"}, ...} }` → PATCH response reflected `auto_assign_specialist = 1` and `working_hours` as a JSON string. `GET /settings` returned the same. `working_hours` parsed back to `{1:{start:"09:00",end:"13:00"}, 3:null, ...}`.
|
||||
- `PATCH /employees/:id { specialties: ["Cabello","Coloración"], efficiency_score: 87, working_hours: {...} }` → reflected `specialties = ["Cabello","Coloración"]`, `efficiency_score = 87` with `typeof === "number"`, and `working_hours` JSON. `GET /employees/:id` confirmed persistence. Restored afterwards.
|
||||
|
||||
## Self-review
|
||||
- **settings serializes `working_hours`?** YES — `merged.working_hours = JSON.stringify(merged.working_hours)` when not already a string; stored as TEXT, returned verbatim by both SELECTs.
|
||||
- **settings coerces `auto_assign_specialist` to 0/1?** YES — `merged.auto_assign_specialist = merged.auto_assign_specialist ? 1 : 0`.
|
||||
- **employees coerces `efficiency_score` to `Number`?** YES — both POST (`typeof === "number" ? efficiency_score : 50`) and PATCH (`Number(efficiency_score)` with fallback to `existing.efficiency_score ?? 50`). Confirmed via manual check: returned value has `typeof === "number"`.
|
||||
- **employees handles `specialties`/`working_hours` JSON?** YES — POST stringifies the array/object; PATCH merges with existing and accepts string, object/array, or `null` (for `working_hours`).
|
||||
- **booking-e2e covers auto-assign + 409 + `auto_assign_specialist` exposure?** YES — step 4 books without `employee_id` and asserts the response contains `employee_id` + non-empty `reasons` (auto-assign); step 5 re-books the same slot/employee and asserts `409`; step 6 toggles `auto_assign_specialist` to 1 via PATCH and asserts the public `/public/<slug>` response exposes `business.auto_assign_specialist === 1`.
|
||||
- **e2e-test now passes without weakening guards?** YES — no guards were changed (only `settings.ts`, `employees.ts`, `e2e-test.mjs`, new `booking-e2e.mjs`, and `package.json` were touched; `booking.ts`, `appointments.ts`, `scheduling.ts`, `db.ts`, `types.ts` untouched). The fix is purely test-fixture: instead of hard-coding out-of-working-hours ISO strings (`11:00Z` ≈ 05:00 local, and `2026-09-20` is Sunday — both correctly rejected by the new working-hours enforcement), the test now fetches a real available slot from the same public endpoint that respects the guards. All 25 original assertions preserved; 4 new helper assertions added (slot lookups + slug).
|
||||
|
||||
## Deviations
|
||||
- **e2e-test.mjs employee-service fallback (svc 5 → svc 6).** The plan does not mandate this; the additional sub-task says "If service_id 5 has no slots, pick another service the employee offers". Implemented as: try service 5 across 30 days; if null, try service 6. In practice service 5 always yields slots in the seed data, so the fallback never fires — but it makes the test robust to seed variation. The self-assign assertion still holds because both services 5 and 6 are offered exclusively by Mateo Herrera.
|
||||
- **`findSlot` searches 30 days** (vs. a single weekday in the suggested approach) — gives the fixture more headroom to dodge dense seed bookings without weakening any guard.
|
||||
|
||||
## Concerns
|
||||
None.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Task 8 — Fix de iconos solapados (`@layer components` + `pl-10`)
|
||||
|
||||
## Files changed
|
||||
1. `src/index.css` — wrapped the `.input, .select, .textarea { ... }` block AND its `:focus` rule in `@layer components { ... }` (now lines 272–292).
|
||||
2. `src/pages/public/BookingPage.tsx` — in `DetailsForm`, changed the three `className="input pl-9"` → `className="input pl-10"` (Nombre, Teléfono, Correo; lines 664, 678, 692).
|
||||
|
||||
## Verification results
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `npm run typecheck` | ✅ 0 errors (`tsc -b --noEmit` exited clean) |
|
||||
| `npm run build` | ✅ Succeeded in 5.06s — `dist/assets/index-*.css` emitted (36.19 kB). Confirms `@layer components` is valid CSS and Tailwind processes it. |
|
||||
| Grep: `padding: 0.55rem 0.75rem` outside `@layer components` | ✅ Only occurrence is on **line 280**, which is INSIDE the `@layer components { ... }` block (lines 272–292). No bare occurrence remains. |
|
||||
| Grep: BookingPage `input pl-` | ✅ All three now read `input pl-10` (lines 664, 678, 692). No `pl-9` remains. |
|
||||
|
||||
## Untouched (pre-existing WIP, not modified by this task)
|
||||
|
||||
### FullCalendar section in `src/index.css` (lines 131–163) — unchanged
|
||||
Shown verbatim from the file after my edit:
|
||||
|
||||
```css
|
||||
.fc-event {
|
||||
border: none !important;
|
||||
border-radius: 0.45rem !important;
|
||||
padding: 2px 5px !important;
|
||||
font-size: 0.74rem !important;
|
||||
font-weight: 600 !important;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s, box-shadow 0.12s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fc-event:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.fc-daygrid-event {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.fc .fc-timegrid-slot {
|
||||
height: 2.4rem;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
```
|
||||
|
||||
> Note: `git diff src/index.css` does show these FullCalendar lines as additions vs. HEAD, but they were **already present in the working tree before Task 8 began** (pre-existing WIP on the branch). My `edit` call only targeted the `.input/.select/.textarea` block — its `oldString`/`newString` did not include any FullCalendar text — so I did not modify this section.
|
||||
|
||||
### `src/components/AppointmentModal.tsx` and `src/pages/CalendarPage.tsx`
|
||||
Listed as modified in `git status` (pre-existing WIP from earlier tasks on this branch). I did not open, read, or edit either file during Task 8 — my only tool calls against the repo were the two `edit` operations on `src/index.css` and `src/pages/public/BookingPage.tsx`, plus read-only verification commands.
|
||||
|
||||
## Diff of MY two files (only the parts I changed)
|
||||
|
||||
```diff
|
||||
--- a/src/index.css
|
||||
+++ b/src/index.css
|
||||
@@ -258,24 +269,26 @@ body {
|
||||
background: #fecaca;
|
||||
}
|
||||
|
||||
-.input,
|
||||
-.select,
|
||||
-.textarea {
|
||||
- width: 100%;
|
||||
- ...
|
||||
- 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%;
|
||||
+ ...
|
||||
+ outline: none;
|
||||
+ }
|
||||
+ .input:focus,
|
||||
+ .select:focus,
|
||||
+ .textarea:focus {
|
||||
+ border-color: #3b66ff;
|
||||
+ box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
|
||||
+ }
|
||||
}
|
||||
|
||||
--- a/src/pages/public/BookingPage.tsx
|
||||
+++ b/src/pages/public/BookingPage.tsx
|
||||
@@ -661,7 +661,7 @@
|
||||
- className="input pl-9"
|
||||
+ className="input pl-10"
|
||||
@@ -675,7 +675,7 @@
|
||||
- className="input pl-9"
|
||||
+ className="input pl-10"
|
||||
@@ -689,7 +689,7 @@
|
||||
- className="input pl-9"
|
||||
+ className="input pl-10"
|
||||
```
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Does `@layer components` now wrap the input rule?**
|
||||
YES. `src/index.css` lines 272–292 open with `@layer components {` and close with `}`; the `.input/.select/.textarea` block and the `:focus` rule are both inside it, indented one level.
|
||||
|
||||
- **Will `pl-9`/`pl-10` utilities now override the component `padding` shorthand?**
|
||||
YES. Tailwind emits its utilities through `@tailwind utilities;` (line 3 of `index.css`), which places them in the `utilities` CSS cascade layer. Per the CSS cascade-layer ordering rule, declarations in a layer declared **later** beat declarations in an **earlier** layer at equal specificity, regardless of source order. The layer order established by the three `@tailwind` directives is `base → components → utilities`, so the `utilities` layer (containing `.pl-10 { padding-left: 2.5rem }`) wins over the `components` layer (containing `.input { padding: 0.55rem 0.75rem }`). Before this change, `.input` was declared outside any layer, which made it an *unlayered* style — and unlayered styles beat layered styles in the cascade, so the utility was losing. Wrapping in `@layer components` fixes this app-wide for every `.input`/`.select`/`.textarea` that uses `pl-*`/`pr-*` (LoginPage, BookingPage, etc.).
|
||||
|
||||
- **Why `pl-10` (2.5rem = 40px) instead of `pl-9` (2.25rem = 36px)?**
|
||||
Icons sit at `left-3` (12px) and are `h-4 w-4` (16px), so they occupy roughly 12–28px of horizontal space. `pl-9` left only an 8px gap between icon edge and text start, which clipped ascenders/descenders and made placeholders appear to overlap the icon. `pl-10` gives a comfortable 12px gap (text starts at 40px, icon ends ~28px). Combined with the layer fix above, the utility now actually applies.
|
||||
|
||||
## Concerns
|
||||
None. No commit performed (per instructions). Build and typecheck both green. Pre-existing WIP files left exactly as found.
|
||||
```
|
||||
```
|
||||
@@ -0,0 +1,64 @@
|
||||
# Task 9 Report — `publicApi.ts` tipos actualizados
|
||||
|
||||
## Status
|
||||
✅ **DONE** — typecheck passes with 0 errors.
|
||||
|
||||
## File changed
|
||||
- `src/lib/publicApi.ts` (only this file; no consumers touched)
|
||||
|
||||
## Typecheck result
|
||||
```
|
||||
$ npm run typecheck
|
||||
> [email protected] typecheck
|
||||
> tsc -b --noEmit
|
||||
```
|
||||
**0 errors.** The changes are additive/loosening, so no consumer breakage:
|
||||
- `PublicBusiness.auto_assign_specialist` (added required field) is safe — the only consumer reading `PublicBusiness` is `BookingPage.tsx`, which does not yet destructure this field (it'll be added in Task 12). Adding a required field to an interface does not error on existing readers; it only errors on object *literals* that lack it, and there are no literals of type `PublicBusiness` in the codebase (they come from `request<PublicBusinessResponse>`).
|
||||
- `BookPayload.employee_id` made optional → strictly loosening; existing callers passing `employee_id` still typecheck.
|
||||
- `BookResponse.appointment` widened with `employee_id`/`reasons` → existing readers of `employee_name` still work; new readers will be added in Task 12.
|
||||
|
||||
No consumer errors observed.
|
||||
|
||||
## Interface diffs (verbatim from plan)
|
||||
|
||||
### 1. `PublicBusiness` — added `auto_assign_specialist`
|
||||
```diff
|
||||
require_deposit: boolean;
|
||||
deposit_pct: number;
|
||||
+ auto_assign_specialist: number | boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `BookPayload` — `employee_id` now optional
|
||||
```diff
|
||||
export interface BookPayload {
|
||||
service_id: number;
|
||||
- employee_id: number;
|
||||
+ employee_id?: number;
|
||||
start_at: string;
|
||||
client: BookClient;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. `BookResponse.appointment` — added `employee_id` + `reasons`
|
||||
```diff
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
## Consumer errors
|
||||
None. (Plan's Step 2 note had anticipated possible errors in `BookingPage.tsx`, but none materialized — the optional `employee_id` and the widened `BookResponse` are backward-compatible with the current consumer code, which will be properly updated in Tasks 11–12.)
|
||||
|
||||
## Concerns
|
||||
None. Ready for Tasks 10–12 to consume these new types.
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# ---- Build frontend ----
|
||||
FROM node:22-slim AS web-build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---- Runtime ----
|
||||
FROM node:22-slim
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
# Built frontend (vite outputs to dist/)
|
||||
COPY --from=web-build /app/dist ./dist
|
||||
|
||||
# Server source (tsx runs TS directly in production)
|
||||
COPY server ./server
|
||||
COPY shared ./shared
|
||||
COPY tsconfig.json ./
|
||||
|
||||
# Persistent SQLite data
|
||||
RUN mkdir -p /app/data
|
||||
VOLUME /app/data
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npx", "tsx", "server/index.ts"]
|
||||
@@ -1,4 +1,4 @@
|
||||
# AgendaPro
|
||||
# AgendaMax
|
||||
|
||||
Aplicación web **multi-tenant (SaaS)** para la gestión de negocios de servicios (estética, spa, barbería, clínicas, etc.):
|
||||
calendario de citas con **arrastrar y soltar**, gestión de **empleados**, **servicios**, **clientes** y **tickets**,
|
||||
@@ -83,7 +83,7 @@ Todas usan la contraseña **`demo1234`**.
|
||||
|
||||
## Multi-tenant y consola de administrador
|
||||
|
||||
AgendaPro es **multi-tenant**: cada negocio es un tenant aislado (todos los datos llevan `business_id`
|
||||
AgendaMax es **multi-tenant**: cada negocio es un tenant aislado (todos los datos llevan `business_id`
|
||||
y las consultas se filtran por el negocio del usuario). El **administrador de plataforma** gestiona
|
||||
todos los negocios desde `/admin`:
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
# Auto-asignación de especialista + rediseño de la página de reservas
|
||||
|
||||
**Fecha:** 2026-07-26
|
||||
**Estado:** Borrador para revisión del usuario
|
||||
**Alcance:** Página pública de reservas (`/b/:slug`) + sección de Configuración + modelo de datos
|
||||
|
||||
## 1. Objetivo
|
||||
|
||||
1. Añadir en Configuración un check **"Asignar especialista automáticamente"** que, al activarse, **omite el paso de elección de especialista** para el cliente en la URL pública de reservas.
|
||||
2. La asignación se resuelve con un **algoritmo matemático** que considera especialidad, eficiencia y —sobre todo— **disponibilidad**, garantizando que **un especialista nunca atienda a dos clientes a la vez** (sin traslape con citas ya agendadas).
|
||||
3. En "Elige fecha y hora", reemplazar el `<input type="date">` nativo por un **calendario de mes siempre visible**.
|
||||
4. En **escritorio**, dividir "Elige fecha y hora" en **dos columnas** (calendario + horarios) para aprovechar el ancho de pantalla; en móvil se mantiene el stack actual.
|
||||
5. **Colores indicativos llamativos** (alto contraste, mayor tamaño) para ayudar a usuarios mayores de 50, **manteniendo** el estilo moderno/fresco de AgendaMax y **sin alterar** los bloques de horario actuales.
|
||||
6. **Fix:** iconos de "Tus datos" (nombre/teléfono/correo) que se solapan con el placeholder gris.
|
||||
|
||||
## 2. No-objetivos (YAGNI)
|
||||
|
||||
- Pasarela de pago / depósitos (`require_deposit` queda sin aplicar).
|
||||
- Política de cancelación automatizada (fuera de alcance aquí).
|
||||
- Recurrencia de citas, descansos/almuerzos puntuales.
|
||||
- Vista de disponibilidad tipo FullCalendar en el flujo público (se usa una grilla de mes propia, más simple y enfocada).
|
||||
|
||||
## 3. Arquitectura
|
||||
|
||||
Nuevo módulo **`server/lib/scheduling.ts`** con funciones puras y testeables que centralizan toda la "data science":
|
||||
|
||||
- `getWorkingHours(business, employee|null, date) → { start, end } | null` (resuelve horario: empleado → si no, negocio; si el día es `null`, fuera de servicio).
|
||||
- `isAvailable(db, employeeId, startIso, endIso) → boolean` (verifica traslape contra citas `(scheduled, completed)`).
|
||||
- `computeOccupiedMinutes(db, employeeId, date) → number` (para load balance).
|
||||
- `specialtyMatch(employee, service) → 0..1`.
|
||||
- `scoreCandidate(ctx) → { score, reasons }`.
|
||||
- `autoAssign(db, { business, service, startIso, endIso }) → { employeeId, score, reasons } | null` (filtra por hard constraints, luego scorea).
|
||||
|
||||
Se descarta una versión SQL-pura (window functions): el scoring ponderado es más legible y mantenible en TypeScript y el número de especialistas por negocio es pequeño (cabe en memoria). El módulo es importado por `server/routes/booking.ts` y `server/routes/appointments.ts`.
|
||||
|
||||
## 4. Modelo de datos (migración V3 → V4)
|
||||
|
||||
Función `migrateV3ToV4` en `server/db.ts` (sigue el patrón de `migrateV2ToV3`).
|
||||
|
||||
### Tabla `businesses` — 2 columnas
|
||||
| Columna | Tipo | Default | Notas |
|
||||
|---|---|---|---|
|
||||
| `auto_assign_specialist` | `INTEGER NOT NULL` | `0` | El check que omite el paso de especialista. |
|
||||
| `working_hours` | `TEXT` (JSON) | ver backfill | Horario default del negocio por día. |
|
||||
|
||||
Formato JSON: `{"1":{"start":"09:00","end":"20:00"}, "2":{...}, ..., "6":null, "7":null}` (1=Lun … 7=Dom; `null` = cerrado).
|
||||
|
||||
### Tabla `employees` — 3 columnas
|
||||
| Columna | Tipo | Default | Notas |
|
||||
|---|---|---|---|
|
||||
| `specialties` | `TEXT` (JSON array) | `'[]'` | Etiquetas: `["Corte","Barba"]`. Se comparan con `service.category` y tokens de `service.name`. |
|
||||
| `working_hours` | `TEXT` (JSON) | `NULL` | Horario individual; `NULL` = hereda el del negocio. |
|
||||
| `efficiency_score` | `REAL NOT NULL` | `50` | 0–100. Lo captura el dueño. |
|
||||
|
||||
### Backfill (en la migración)
|
||||
- `auto_assign_specialist = 0` para todos.
|
||||
- `businesses.working_hours` = `{"1":{"start":"09:00","end":"20:00"},...,"5":{...},"6":null,"7":null}` (replica el `09:00–20:00` Lun–Vie actualmente hardcodeado en `booking.ts:57-58`).
|
||||
- `employees.specialties = '[]'`, `employees.working_hours = NULL`, `employees.efficiency_score = 50`.
|
||||
|
||||
### Tipos (`shared/types.ts`)
|
||||
- `Business` añade `auto_assign_specialist: boolean`, `working_hours: WorkingHours | null`.
|
||||
- `Employee` añade `specialties: string[]`, `working_hours: WorkingHours | null`, `efficiency_score: number`.
|
||||
- `WorkingHours` = `Record<1..7, { start: string; end: string } | null>`.
|
||||
|
||||
### Settings API (`server/routes/settings.ts`)
|
||||
- La lista blanca (`settings.ts:8-20`) añade: `auto_assign_specialist`, `working_hours`.
|
||||
|
||||
## 5. Algoritmo de auto-asignación (la "data science")
|
||||
|
||||
Restricción dura + score ponderado, para una cita candidata (servicio `S`, intervalo `[t0, t1)`):
|
||||
|
||||
### 5.1 Hard constraints — candidato válido si:
|
||||
1. El empleado está `active = 1`.
|
||||
2. Ofrece el servicio: existe fila en `employee_services(employee_id, service_id)`.
|
||||
3. `[t0, t1)` cae **dentro** de su horario laboral ese día (`getWorkingHours`). Si el día es cerrado → descartado.
|
||||
4. **No traslapa** ninguna cita existente con `status IN ('scheduled','completed')`: para toda cita `c`, es falso que `t0 < c.end_at && t1 > c.start_at` (`isAvailable`).
|
||||
|
||||
→ Garantiza que **un especialista jamás atiende a dos personas a la vez**.
|
||||
|
||||
### 5.2 Score (0–100) de cada candidato válido
|
||||
```
|
||||
score = 50 * specialtyMatch + 30 * efficiency + 20 * (1 - loadBalance)
|
||||
```
|
||||
- **specialtyMatch (0..1):** `1.0` si una etiqueta de `employee.specialties` coincide (normalizado: lowercase, sin acentos, match por token/subcadena) con `service.category` o un token de `service.name`; `0.5` si ofrece el servicio (paso 2) pero sin coincidencia de etiqueta.
|
||||
- **efficiency (0..1):** `employee.efficiency_score / 100`.
|
||||
- **loadBalance (0..1):** `min(1, ocupadoHoy / laborableHoy)`, donde `ocupadoHoy = computeOccupiedMinutes` (suma de duraciones de citas del día) y `laborableHoy = minutos del turno ese día`. A menor carga → mayor `(1 - loadBalance)` → premia reparto justo.
|
||||
|
||||
### 5.3 Desempate y resultado
|
||||
- Mayor `score` gana. Empate → menor `id` (determinista).
|
||||
- `autoAssign` devuelve `{ employeeId, score, reasons: string[] }` para auditoría/UI.
|
||||
|
||||
### 5.4 Pesos
|
||||
`specialty 50 / efficiency 30 / load 20`. Disponibilidad es **restricción dura** (peso infinito): nunca se viola.
|
||||
|
||||
## 6. Guard anti double-booking (transaccional)
|
||||
|
||||
Hoy `POST /api/public/:slug/book` (`booking.ts:100-153`) y `POST /api/appointments` (`appointments.ts:75-167`) insertan **sin re-verificar** conflictos (race condition). Fix:
|
||||
|
||||
- Envolver la creación en una **transacción** de SQLite (`db.exec('BEGIN IMMEDIATE')` o el equivalente de `node:sqlite`).
|
||||
- Antes del `INSERT`, llamar a `isAvailable(employeeId, t0, t1)`.
|
||||
- Si ya no está libre → **409 Conflict** con `{ error: "ESE_HORARIO_OCUPADO" }`. El cliente público ve "Esa hora acaba de ocuparse, elige otra" y vuelve al paso de horario.
|
||||
- En auto-asignación, `autoAssign` corre **dentro** de la misma transacción, sobre el estado ya bloqueado.
|
||||
|
||||
## 7. Flujo del wizard público (`src/pages/public/BookingPage.tsx`)
|
||||
|
||||
### 7.1 Pasos
|
||||
- `auto_assign_specialist = ON` → **3 pasos**: Servicio → **Horario** → Datos (sin paso Especialista).
|
||||
- `OFF` → 4 pasos actuales, pero la card "Cualquiera" usa `autoAssign` (no más `LIMIT 1`).
|
||||
|
||||
### 7.2 Slots (`GET /api/public/:slug/slots`)
|
||||
- Se mantiene la firma y la grilla de **30 min**, pero el rango ahora se lee de `getWorkingHours` (negocio/empleado) en vez del hardcodeado `09:00–20:00`.
|
||||
- Cuando se omite el especialista, los candidatos son **todos** los empleados que ofrecen el servicio; un slot se muestra si **al menos uno** está libre (unión de disponibilidades). La asignación concreta se resuelve al confirmar.
|
||||
- El payload del slot puede incluir un flag opcional `hasMultiple` para mostrar "varios especialistas disponibles" (informativo).
|
||||
|
||||
### 7.3 Confirmación
|
||||
- Muestra el especialista asignado: avatar/color, nombre, rol, y 1–2 `reasons` legibles (p. ej. "Especialista en Corte · alta eficiencia · disponible"). El cliente nunca queda sin saber a quién le tocó.
|
||||
|
||||
## 8. UI — `DateTimePicker` (Elige fecha y hora)
|
||||
|
||||
### 8.1 Calendario de mes siempre visible (`MonthCalendar`)
|
||||
- Componente nuevo (sin librería externa). Grilla **Lun–Dom**, header con mes/año + flechas ‹ › (limite ±3 meses).
|
||||
- Días pasados o fuera de horario: atenuados (`slate-100`, texto `slate-300`), no seleccionables.
|
||||
- Fin de semana: color tenue (no bloqueado salvo que `working_hours` sea `null`).
|
||||
- Día con disponibilidad (≥1 slot): **punto `accent`** (naranja AgendaMax) bajo el número.
|
||||
- Día seleccionado: relleno `brand-500`, texto blanco, `ring-2 ring-brand-500/30`, `shadow-soft`.
|
||||
- Tipografía base >=16px; celda táctil >=44px (accesibilidad 50+).
|
||||
|
||||
### 8.2 Dos columnas en escritorio
|
||||
- En el paso Horario, `main` pasa de `max-w-2xl` a **`max-w-5xl`** (solo en este paso).
|
||||
- `DateTimePicker`: `md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:gap-8` → **calendario izquierda**, **tarjeta de horarios derecha**.
|
||||
- Tarjeta de horarios: encabezado con la fecha elegida en grande + agrupación visual de slots por **Mañana / Tarde** (etiquetas con color `accent`/`brand` suave). **La grilla de bloques actual no se modifica** (`grid-cols-3 sm:grid-cols-4`, estilos seleccionado/disponible idénticos).
|
||||
- En `<md` se conserva el stack vertical actual (aprobado por el usuario).
|
||||
- `TopBar` (`max-w-3xl`) y `ActionBar` (`max-w-2xl`) se ensanchan a `max-w-5xl` para acompañar.
|
||||
|
||||
### 8.3 Colores indicativos (alta legibilidad, sin romper la marca)
|
||||
- No se introducen colores nuevos estridentes; se **refuerza contraste y tamaño** y se usa `accent` como señal secundaria.
|
||||
- Seleccionado: `bg-brand-500 text-white shadow-soft` + badge `Check` (más grande).
|
||||
- Disponible: `border-slate-200 bg-white`, hover → `border-brand-400 bg-brand-50`.
|
||||
- Indisponible: `bg-slate-100 text-slate-300` + ícono de candado sutil.
|
||||
- Texto de etiquetas y campos >=16px; estado enfocado con `ring` más visible.
|
||||
|
||||
## 9. UI — Fix de iconos en "Tus datos" (y app-wide)
|
||||
|
||||
### 9.1 Causa raíz
|
||||
`src/index.css:272-284`: `.input { padding: 0.55rem 0.75rem }` (shorthand) tiene la misma especificidad (0,1,0) que `.pl-9` de Tailwind. Como `@tailwind utilities;` se emite **antes** en el archivo, la regla `.input` (posterior) **gana** y fija `padding-left: 0.75rem` (12px), justo donde inicia el icono (`left-3` = 12px, ancho 16px → ocupa 12–28px). El texto arranca en 12px → **solape**. Afecta a **9 inputs** de la app.
|
||||
|
||||
### 9.2 Fix
|
||||
1. Envolver `.input`, `.select`, `.textarea` (y demás reglas de componente custom) en **`@layer components { … }`** en `src/index.css`. Así las utilidades del layer `utilities` (incluido `pl-*`) **siempre** ganan, independientemente del orden de fuente. Resuelve los 9 sitios a la vez.
|
||||
2. En los 3 fields de "Tus datos" subir `pl-9` → **`pl-10`** (icono `left-3` + 16px → texto a 40px, ~12px de holgura, mejor para 50+). Opcionalmente replicar `pl-10` en los otros 6 inputs con icono para consistencia.
|
||||
|
||||
## 10. UI — Configuración (`src/pages/SettingsPage.tsx`)
|
||||
|
||||
- Card **"Reservas online"** (`:64-101`): añade
|
||||
- Check **"Asignar especialista automáticamente"** con helper "El cliente no elige especialista; lo asigna el sistema según especialidad, eficiencia y disponibilidad".
|
||||
- Editor de **horario del negocio**: grilla 7 días, cada uno con switch activo + `start`/`end` (type=time). Persiste en `businesses.working_hours`.
|
||||
- El listado de campos del mutation PATCH (`SettingsPage.tsx:28-49`) incluye los nuevos.
|
||||
|
||||
## 11. UI — Ficha de Especialista (edición de empleado)
|
||||
|
||||
- Vista de edición de empleado añade:
|
||||
- **Especialidades** (input de chips/tags → array).
|
||||
- **Horario laboral** (default: "Heredar del negocio"; si se edita, grilla 7 días como en el negocio).
|
||||
- **Eficiencia** (slider 0–100 con etiqueta descriptiva).
|
||||
- Endpoints existentes `PATCH /api/employees/:id` se extienden con los 3 campos en la lista blanca.
|
||||
|
||||
## 12. Plan de pruebas
|
||||
|
||||
- **Unit (scheduling):** `isAvailable` (casos de traslape en bordes), `specialtyMatch` (acentos/case), `scoreCandidate` (monotocidad de cada factor), `autoAssign` (elige óptimo, desempate determinista, respeta horario, ninguno disponible → null).
|
||||
- **Integración (rutas):** reservar la misma hora dos veces → la 2ª da 409; reservar con auto-assign activo asigna y no traspasa.
|
||||
- **Build/estático:** `npm run typecheck` y `npm run lint` limpios.
|
||||
- **E2E si aplica:** flujo público completo con check activo (3 pasos) y sin check (4 pasos).
|
||||
|
||||
## 13. Riesgos y mitigaciones
|
||||
|
||||
| Riesgo | Mitigación |
|
||||
|---|---|
|
||||
| Datos vacíos al migrar (sin especialidades/eficiencia) | Defaults neutros: `specialties=[]`, `efficiency=50`. El algoritmo degrada a disponibilidad + load balance. |
|
||||
| Race condition de doble reserva | Transacción `BEGIN IMMEDIATE` + `isAvailable` dentro de la misma. |
|
||||
| Performance con muchos especialistas | N por negocio es chico; peor caso O(N·slots·citas). Cache de `computeOccupiedMinutes` por (employee, date). |
|
||||
| Cambio de `working_hours` rompe slots pasados | Solo afecta a fechas futuras; citas pasadas no se revalidan. |
|
||||
| `@layer components` cambia cascada | Re-auditar visualmente inputs/selects existentes; los utilities ya pensados para ganar. |
|
||||
|
||||
## 14. Entregables (resumen de archivos)
|
||||
|
||||
- **Nuevos:** `server/lib/scheduling.ts`.
|
||||
- **Migración:** `server/db.ts` (`migrateV3ToV4`).
|
||||
- **Backend rutas:** `server/routes/booking.ts`, `server/routes/appointments.ts`, `server/routes/settings.ts`, `server/routes/employees.ts`.
|
||||
- **Tipos:** `shared/types.ts`.
|
||||
- **Frontend:** `src/pages/public/BookingPage.tsx` (MonthCalendar, layout 2 col, flujo 3/4 pasos, confirmación), `src/pages/SettingsPage.tsx`, ficha de empleado, `src/lib/publicApi.ts`, `src/lib/api.ts`.
|
||||
- **Estilos:** `src/index.css` (`@layer components` + fix de padding).
|
||||
+1
-1
@@ -38,7 +38,7 @@ const emps = await req("/api/employees", { headers: H(t) }); check("employees li
|
||||
const svcs = await req("/api/services", { headers: H(t) }); check("services list", svcs.json.services.length === 12 && svcs.json.services[0].employee_ids);
|
||||
const cls = await req("/api/clients", { headers: H(t) }); check("clients list", cls.json.clients.length > 0 && cls.json.clients[0].stats?.visits >= 0);
|
||||
const appts = await req("/api/appointments?limit=5", { headers: H(t) }); check("appointments joined", appts.json.appointments[0]?.service && appts.json.appointments[0]?.client);
|
||||
const ov = await req("/api/dashboard/overview", { headers: H(t) }); check("overview KPIs", ov.json.revenue_month > 0 && ov.json.appts_upcoming > 0);
|
||||
const ov = await req("/api/dashboard/overview", { headers: H(t) }); check("overview KPIs", ov.json.revenue_range > 0 && ov.json.appts_upcoming > 0);
|
||||
const be = await req("/api/dashboard/best-employees", { headers: H(t) }); check("best-employees ranked", be.json.employees.length === 6);
|
||||
const bs = await req("/api/dashboard/best-services", { headers: H(t) }); check("best-services ranked", bs.json.services.length > 0);
|
||||
const tt = await req("/api/dashboard/top-tickets", { headers: H(t) }); check("top-tickets", tt.json.tickets.length > 0);
|
||||
|
||||
+61
-4
@@ -13,6 +13,19 @@ function check(name, cond, extra = "") {
|
||||
else { fail++; console.log(` ✗ ${name} ${extra}`); }
|
||||
}
|
||||
|
||||
// Fetch the first available slot (iso) for a service over the next `maxDays` days.
|
||||
// Slots already filter out non-working days and double-booked specialists, so any
|
||||
// returned iso is safe to use as start_at for either autoAssign or self-assign paths.
|
||||
async function findSlot(slug, serviceId, maxDays = 30) {
|
||||
for (let i = 1; i <= maxDays; i++) {
|
||||
const d = new Date(Date.now() + i * 86400000);
|
||||
const date = d.toISOString().slice(0, 10);
|
||||
const { json } = await req("GET", `/public/${slug}/slots?service_id=${serviceId}&date=${date}`);
|
||||
if (Array.isArray(json.slots) && json.slots.length > 0) return json.slots[0].iso;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// /me now works (the bug we fixed) — login first to get a real token
|
||||
const { json: login } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
|
||||
check("login owner", !!login.token && login.user?.role === "owner", JSON.stringify(login).slice(0, 100));
|
||||
@@ -20,13 +33,18 @@ const t = login.token;
|
||||
const { json: me } = await req("GET", "/auth/me", null, t);
|
||||
check("/api/auth/me returns user", me.user?.email === "[email protected]", JSON.stringify(me).slice(0, 100));
|
||||
|
||||
// Slug del negocio (para consultar slots públicos)
|
||||
const { json: settings } = await req("GET", "/settings", null, t);
|
||||
const slug = settings.settings?.slug;
|
||||
check("tiene slug", !!slug, String(slug));
|
||||
|
||||
const checks = [
|
||||
["business", "/business", (j) => j.business?.name === "Lumière Estética & Spa"],
|
||||
["employees+stats", "/employees", (j) => j.employees?.length === 6 && !!j.employees[0].stats],
|
||||
["services", "/services", (j) => j.services?.length === 12 && Array.isArray(j.services[0].employee_ids)],
|
||||
["clients+stats", "/clients", (j) => j.clients?.length > 0 && j.clients[0].stats?.visits !== undefined],
|
||||
["appointments joined", "/appointments?limit=5", (j) => j.appointments?.[0]?.service && j.appointments[0].client],
|
||||
["dashboard overview", "/dashboard/overview", (j) => j.revenue_month > 0],
|
||||
["dashboard overview", "/dashboard/overview", (j) => j.revenue_range > 0],
|
||||
["best-employees", "/dashboard/best-employees", (j) => j.employees?.length === 6],
|
||||
["best-services", "/dashboard/best-services", (j) => j.services?.length > 0],
|
||||
["top-tickets", "/dashboard/top-tickets", (j) => j.tickets?.length > 0],
|
||||
@@ -42,13 +60,21 @@ for (const [name, path, cond] of checks) {
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
const { json: created } = await req("POST", "/appointments", { service_id: 1, client_id: 1, start_at: "2026-09-15T11:00:00Z" }, t);
|
||||
const slotOwner = await findSlot(slug, 1);
|
||||
check("found slot for service 1", !!slotOwner);
|
||||
const { json: created } = await req("POST", "/appointments", { service_id: 1, client_id: 1, start_at: slotOwner }, t);
|
||||
check("create appointment", !!created.appointment?.id);
|
||||
const { json: completed } = await req("POST", `/appointments/${created.appointment.id}/complete`, { tip: 50, payment_method: "cash" }, t);
|
||||
check("complete -> ticket", completed.appointment?.status === "completed");
|
||||
const { json: empLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
|
||||
const { json: empAppt } = await req("POST", "/appointments", { service_id: 5, client_id: 2, start_at: "2026-09-20T10:00:00Z" }, empLogin.token);
|
||||
check("employee auto-assign", empAppt.appointment?.employee_id === empLogin.user?.employee_id);
|
||||
// Mateo offers services "Corte caballero" (5) and "Corte + arreglo de barba" (6).
|
||||
// Try 5 first (keeps the original assertion intent); fall back to 6 if 5 has no slots.
|
||||
let empSvcId = 5;
|
||||
let slotEmp = await findSlot(slug, empSvcId);
|
||||
if (!slotEmp) { empSvcId = 6; slotEmp = await findSlot(slug, empSvcId); }
|
||||
check("found slot for employee service", !!slotEmp);
|
||||
const { json: empAppt } = await req("POST", "/appointments", { service_id: empSvcId, client_id: 2, start_at: slotEmp }, empLogin.token);
|
||||
check("employee auto-assign", empAppt.appointment?.employee_id === empLogin.user?.employee_id, JSON.stringify(empAppt).slice(0, 150));
|
||||
const { status: badLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "wrong" });
|
||||
check("bad login 401", badLogin === 401);
|
||||
const { status: noAuth } = await req("GET", "/employees");
|
||||
@@ -56,5 +82,36 @@ check("no auth 401", noAuth === 401);
|
||||
const { status: empForb } = await req("GET", "/dashboard/overview", null, empLogin.token);
|
||||
check("employee blocked dashboard 403", empForb === 403);
|
||||
|
||||
// ---- Permission boundary (server-side enforcement) ----
|
||||
// F1: employees must NOT see commission_pct on any colleague row
|
||||
const { json: empLeak } = await req("GET", "/employees", null, empLogin.token);
|
||||
const leaked = (empLeak.employees || []).filter((e) => Object.prototype.hasOwnProperty.call(e, "commission_pct"));
|
||||
check("F1: employee cannot read commission_pct", leaked.length === 0, `${leaked.length} rows leaked commission_pct`);
|
||||
|
||||
// F2: employees cannot mutate a colleague's appointment (PATCH / complete / DELETE)
|
||||
const mateoId = empLogin.user?.employee_id;
|
||||
const { json: allAppts } = await req("GET", "/appointments?limit=80", null, t);
|
||||
const foreign = (allAppts.appointments || []).find((a) => a.employee_id !== mateoId);
|
||||
check("F2: found a colleague's appointment", !!foreign, "no foreign appt in seed");
|
||||
if (foreign) {
|
||||
const { status: sPatch } = await req("PATCH", `/appointments/${foreign.id}`, { notes: "x" }, empLogin.token);
|
||||
check("F2: employee PATCH foreign appt -> 403", sPatch === 403, `status=${sPatch}`);
|
||||
const { status: sComplete } = await req("POST", `/appointments/${foreign.id}/complete`, { tip: 0 }, empLogin.token);
|
||||
check("F2: employee complete foreign appt -> 403", sComplete === 403, `status=${sComplete}`);
|
||||
const { status: sDel } = await req("DELETE", `/appointments/${foreign.id}`, null, empLogin.token);
|
||||
check("F2: employee DELETE foreign appt -> 403", sDel === 403, `status=${sDel}`);
|
||||
}
|
||||
// positive control: employee can still mutate their OWN appointment
|
||||
if (empAppt.appointment?.id) {
|
||||
const { status: sOwn } = await req("PATCH", `/appointments/${empAppt.appointment.id}`, { notes: "nota propia" }, empLogin.token);
|
||||
check("F2: employee PATCH own appt -> 200", sOwn === 200, `status=${sOwn}`);
|
||||
}
|
||||
|
||||
// F3: employees cannot edit/delete clients (ownerOnly)
|
||||
const { status: sClientPatch } = await req("PATCH", "/clients/1", { notes: "x" }, empLogin.token);
|
||||
check("F3: employee PATCH client -> 403", sClientPatch === 403, `status=${sClientPatch}`);
|
||||
const { status: sClientDel } = await req("DELETE", "/clients/1", null, empLogin.token);
|
||||
check("F3: employee DELETE client -> 403", sClientDel === 403, `status=${sClientDel}`);
|
||||
|
||||
console.log(`\n${pass}/${pass + fail} passed${fail ? `, ${fail} FAILED` : ""}`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
C:\Users\Uriel Jareth\AppData\Local\Programs\Python\Python312\python.exe
|
||||
@@ -0,0 +1 @@
|
||||
H:\MegaSync\Proyectos\AgendaPro
|
||||
+2
-2
@@ -5,11 +5,11 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
|
||||
<meta name="theme-color" content="#3b66ff" />
|
||||
<meta name="description" content="AgendaPro — Gestión visual de citas, empleados e ingresos para tu negocio." />
|
||||
<meta name="description" content="AgendaMax — Gestión visual de citas, empleados e ingresos para tu negocio." />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<title>AgendaPro — Gestión visual de tu negocio</title>
|
||||
<title>AgendaMax — Gestión visual de tu negocio</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"seed": "node scripts/run-tsx.mjs server/scripts/seed.ts",
|
||||
"test:e2e": "node e2e-test.mjs",
|
||||
"test:admin": "node admin-test.mjs",
|
||||
"test:unit": "node --import tsx --test server/lib/scheduling.test.ts server/lib/time.test.ts server/lib/metrics.test.ts",
|
||||
"test:booking": "node server/scripts/booking-e2e.mjs",
|
||||
"audit:visual": "node visual-audit.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -222,11 +222,46 @@ function migrateV1ToV2() {
|
||||
setMeta("schema_version", "2");
|
||||
}
|
||||
|
||||
/** v4: auto-assign specialist, business & employee working hours, employee specialties/efficiency. */
|
||||
function migrateV3ToV4() {
|
||||
if (getMeta("schema_version") >= "4") return;
|
||||
|
||||
const bizCols: [string, string][] = [
|
||||
["auto_assign_specialist", "INTEGER NOT NULL DEFAULT 0"],
|
||||
["working_hours", "TEXT"],
|
||||
];
|
||||
for (const [col, def] of bizCols) {
|
||||
if (!columnExists("businesses", col)) db.exec(`ALTER TABLE businesses ADD COLUMN ${col} ${def};`);
|
||||
}
|
||||
|
||||
const empCols: [string, string][] = [
|
||||
["specialties", "TEXT"],
|
||||
["working_hours", "TEXT"],
|
||||
["efficiency_score", "REAL NOT NULL DEFAULT 50"],
|
||||
];
|
||||
for (const [col, def] of empCols) {
|
||||
if (!columnExists("employees", col)) db.exec(`ALTER TABLE employees ADD COLUMN ${col} ${def};`);
|
||||
}
|
||||
|
||||
// Backfill business working_hours: Lun-Vie 09:00-20:00 (replica del hardcodeado anterior)
|
||||
const DEFAULT_WH = JSON.stringify({
|
||||
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,
|
||||
});
|
||||
const noWh = db.prepare(`SELECT id FROM businesses WHERE working_hours IS NULL`).all() as { id: number }[];
|
||||
const upd = db.prepare(`UPDATE businesses SET working_hours = ? WHERE id = ?`);
|
||||
for (const b of noWh) upd.run(DEFAULT_WH, b.id);
|
||||
|
||||
setMeta("schema_version", "4");
|
||||
}
|
||||
|
||||
export function runMigrations() {
|
||||
// Ensure base schema exists (no-op on existing tables)
|
||||
db.exec(SCHEMA);
|
||||
migrateV1ToV2();
|
||||
migrateV2ToV3();
|
||||
migrateV3ToV4();
|
||||
}
|
||||
|
||||
/** v3: cancellation policy, commissions, cash register, notifications/reminders, booking slug. */
|
||||
|
||||
+3
-1
@@ -17,6 +17,7 @@ import { settingsRouter } from "./routes/settings.ts";
|
||||
import { cashRouter } from "./routes/cash.ts";
|
||||
import { notificationsRouter, scheduleReminders } from "./routes/notifications.ts";
|
||||
import { bookingRouter } from "./routes/booking.ts";
|
||||
import { meRouter } from "./routes/me.ts";
|
||||
import { authRequired } from "./lib/auth.ts";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -64,6 +65,7 @@ app.use("/api/employees", authRequired, employeesRouter);
|
||||
app.use("/api/services", authRequired, servicesRouter);
|
||||
app.use("/api/clients", authRequired, clientsRouter);
|
||||
app.use("/api/appointments", authRequired, appointmentsRouter);
|
||||
app.use("/api/me", authRequired, meRouter);
|
||||
app.use("/api/dashboard", authRequired, dashboardRouter);
|
||||
app.use("/api/admin", authRequired, adminRouter);
|
||||
app.use("/api/settings", authRequired, settingsRouter);
|
||||
@@ -90,5 +92,5 @@ app.use((err: any, _req: express.Request, res: express.Response, _next: express.
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
const host = process.env.HOST || "0.0.0.0";
|
||||
app.listen(port, host, () => {
|
||||
console.log(`AgendaPro API en http://${host}:${port}`);
|
||||
console.log(`AgendaMax API en http://${host}:${port}`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// server/lib/metrics.test.ts
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { cancelRate, noShowRate, completionRate } from "./metrics.ts";
|
||||
|
||||
test("cancelRate: basic ratio rounded to 1 decimal", () => {
|
||||
assert.equal(cancelRate(1, 4), 25);
|
||||
assert.equal(cancelRate(3, 7), 42.9);
|
||||
assert.equal(cancelRate(0, 10), 0);
|
||||
});
|
||||
|
||||
test("cancelRate: zero total → 0 (no NaN)", () => {
|
||||
assert.equal(cancelRate(0, 0), 0);
|
||||
assert.equal(cancelRate(5, 0), 0);
|
||||
assert.equal(cancelRate(5, -3), 0);
|
||||
});
|
||||
|
||||
test("cancelRate: all cancelled → 100", () => {
|
||||
assert.equal(cancelRate(8, 8), 100);
|
||||
});
|
||||
|
||||
test("noShowRate: basic ratio rounded to 1 decimal", () => {
|
||||
assert.equal(noShowRate(1, 10), 10);
|
||||
assert.equal(noShowRate(1, 3), 33.3);
|
||||
assert.equal(noShowRate(2, 9), 22.2);
|
||||
});
|
||||
|
||||
test("noShowRate: zero total → 0", () => {
|
||||
assert.equal(noShowRate(0, 0), 0);
|
||||
assert.equal(noShowRate(3, 0), 0);
|
||||
});
|
||||
|
||||
test("completionRate: basic ratio rounded to whole percent", () => {
|
||||
assert.equal(completionRate(7, 10), 70);
|
||||
assert.equal(completionRate(1, 3), 33);
|
||||
assert.equal(completionRate(2, 3), 67);
|
||||
});
|
||||
|
||||
test("completionRate: zero total → 0", () => {
|
||||
assert.equal(completionRate(0, 0), 0);
|
||||
assert.equal(completionRate(5, 0), 0);
|
||||
});
|
||||
|
||||
test("completionRate: all completed → 100", () => {
|
||||
assert.equal(completionRate(10, 10), 100);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
// server/lib/metrics.ts
|
||||
// Pure KPI metric definitions. All return 0 when there is no data (no divide-by-zero).
|
||||
|
||||
/** Cancellation rate: cancelled / total, rounded to 1 decimal (percent). */
|
||||
export function cancelRate(cancelled: number, total: number): number {
|
||||
if (total <= 0) return 0;
|
||||
return Math.round((cancelled / total) * 1000) / 10;
|
||||
}
|
||||
|
||||
/** No-show rate: noShow / total, rounded to 1 decimal (percent). */
|
||||
export function noShowRate(noShow: number, total: number): number {
|
||||
if (total <= 0) return 0;
|
||||
return Math.round((noShow / total) * 1000) / 10;
|
||||
}
|
||||
|
||||
/** Completion rate: completed / total, rounded to the nearest whole percent. */
|
||||
export function completionRate(completed: number, total: number): number {
|
||||
if (total <= 0) return 0;
|
||||
return Math.round((completed / total) * 100);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// server/lib/scheduling.test.ts
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
parseWorkingHours, isoDayOfWeek, getWorkingHoursForDate,
|
||||
normalizeText, tokens, specialtyMatch, overlaps, hasConflict, scoreCandidate,
|
||||
pickBestSlotEmployee, BUSY_STATUSES,
|
||||
} from "./scheduling.ts";
|
||||
import type { CandidateInfo, BusyWindow } from "./scheduling.ts";
|
||||
|
||||
test("parseWorkingHours: json válido → mapa 1..7", () => {
|
||||
const m = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" }, 6: null, 7: null }));
|
||||
assert.equal(m?.[1]?.start, "09:00");
|
||||
assert.equal(m?.[6], null);
|
||||
assert.equal(m?.[7], null);
|
||||
assert.equal(m?.[2], null); // ausente → null
|
||||
});
|
||||
|
||||
test("parseWorkingHours: null/invalid → null", () => {
|
||||
assert.equal(parseWorkingHours(null), null);
|
||||
assert.equal(parseWorkingHours("no-json"), null);
|
||||
});
|
||||
|
||||
test("isoDayOfWeek: lunes=1, domingo=7", () => {
|
||||
assert.equal(isoDayOfWeek(new Date("2026-07-27T12:00:00")), 1); // lunes
|
||||
assert.equal(isoDayOfWeek(new Date("2026-07-26T12:00:00")), 7); // domingo
|
||||
});
|
||||
|
||||
test("getWorkingHoursForDate: empleado tiene prioridad sobre negocio", () => {
|
||||
const emp = parseWorkingHours(JSON.stringify({ 1: { start: "10:00", end: "14:00" } }));
|
||||
const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
|
||||
const r = getWorkingHoursForDate(emp, biz, new Date("2026-07-27T12:00:00")); // lunes
|
||||
assert.equal(r?.startMs, new Date(2026, 6, 27, 10, 0).getTime());
|
||||
});
|
||||
|
||||
test("getWorkingHoursForDate: día cerrado → null", () => {
|
||||
const biz = parseWorkingHours(JSON.stringify({ 6: null, 7: null })); // fin de semana cerrado
|
||||
assert.equal(getWorkingHoursForDate(null, biz, new Date("2026-07-25T12:00:00")), null); // sábado
|
||||
});
|
||||
|
||||
test("normalizeText/tokens: quita acentos y lowercase", () => {
|
||||
assert.equal(normalizeText("Coloración"), "coloracion");
|
||||
assert.deepEqual(tokens("Corte de cabello"), ["corte", "de", "cabello"]);
|
||||
});
|
||||
|
||||
test("specialtyMatch: coincidencia exacta de etiqueta → 1", () => {
|
||||
assert.equal(specialtyMatch(["corte"], { name: "Corte de cabello", category: "Cabello" }), 1);
|
||||
});
|
||||
|
||||
test("specialtyMatch: etiqueta con acento/case → 1", () => {
|
||||
assert.equal(specialtyMatch(["Coloración"], { name: "Tinte", category: "Coloración" }), 1);
|
||||
});
|
||||
|
||||
test("specialtyMatch: sin etiquetas → 0.5", () => {
|
||||
assert.equal(specialtyMatch([], { name: "Corte", category: "Cabello" }), 0.5);
|
||||
});
|
||||
|
||||
test("specialtyMatch: etiqueta no relacionada → 0.5", () => {
|
||||
assert.equal(specialtyMatch(["barba"], { name: "Manicura", category: "Uñas" }), 0.5);
|
||||
});
|
||||
|
||||
test("overlaps: bordes inclusivos de no-traslape", () => {
|
||||
// una cita termina exactamente cuando empieza otra → NO traslape
|
||||
assert.equal(overlaps(100, 200, 200, 300), false);
|
||||
// traslape real
|
||||
assert.equal(overlaps(100, 250, 200, 300), true);
|
||||
assert.equal(overlaps(200, 300, 100, 250), true);
|
||||
});
|
||||
|
||||
test("hasConflict: lista vacía → false", () => {
|
||||
assert.equal(hasConflict([], 100, 200), false);
|
||||
});
|
||||
|
||||
test("scoreCandidate: pesos 40/20/40", () => {
|
||||
// todo al máximo → 100
|
||||
assert.equal(scoreCandidate({ specialtyMatch: 1, efficiency: 1, loadBalance: 0 }), 100);
|
||||
// todo al mínimo → 0
|
||||
assert.equal(scoreCandidate({ specialtyMatch: 0, efficiency: 0, loadBalance: 1 }), 0);
|
||||
// sólo specialty (0.5) → 20 (40·0.5)
|
||||
assert.equal(scoreCandidate({ specialtyMatch: 0.5, efficiency: 0, loadBalance: 1 }), 20);
|
||||
});
|
||||
|
||||
test("scoreCandidate: mayor efficiency → mayor score", () => {
|
||||
const low = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.2, loadBalance: 0.5 });
|
||||
const high = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.9, loadBalance: 0.5 });
|
||||
assert.ok(high > low);
|
||||
});
|
||||
|
||||
test("scoreCandidate: menor load (más disponible) → mayor score", () => {
|
||||
const busy = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.5, loadBalance: 0.9 });
|
||||
const free = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.5, loadBalance: 0.1 });
|
||||
assert.ok(free > busy);
|
||||
});
|
||||
|
||||
test("scoreCandidate: fairness — libre+ineficiente vence a ocupado+eficiente (misma especialidad)", () => {
|
||||
// Con los pesos viejos (50/30/20) el efficiency estático (30) aplastaba al load (20):
|
||||
// un senior ocupado siempre ganaba. Con 40/20/40, el load (40) supera al efficiency (20).
|
||||
// A: especialista ocupado (load 0.8) y muy eficiente (0.9)
|
||||
const a = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.9, loadBalance: 0.8 });
|
||||
// B: especialista libre (load 0.1) e ineficiente (0.2)
|
||||
const b = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.2, loadBalance: 0.1 });
|
||||
assert.equal(a, 40 * 0.5 + 20 * 0.9 + 40 * (1 - 0.8)); // 46
|
||||
assert.equal(b, 40 * 0.5 + 20 * 0.2 + 40 * (1 - 0.1)); // 60
|
||||
assert.ok(b > a, "el especialista libre debe vencer al ocupado+eficiente (anti-burnout)");
|
||||
});
|
||||
|
||||
test("pickBestSlotEmployee: empate en score → gana el de menor loadBalance (no por id)", () => {
|
||||
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } }));
|
||||
const startMs = new Date(2026, 6, 27, 13, 0).getTime(); // lunes, dentro de horario
|
||||
const endMs = new Date(2026, 6, 27, 14, 0).getTime();
|
||||
// A (id menor, senior): especialidad coincide (sm=1), ocupado media jornada (load=0.5)
|
||||
// → 40·1 + 20·0.5 + 40·0.5 = 70
|
||||
// B (id mayor, junior): sin especialidad (sm=0.5), libre (load=0)
|
||||
// → 40·0.5 + 20·0.5 + 40·1 = 70 → empate exacto; menor load gana
|
||||
const A: CandidateInfo = { id: 1, name: "Senior", color: "#fff", role: "stylist", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
||||
const B: CandidateInfo = { id: 2, name: "Junior", color: "#fff", role: "stylist", specialties: [], efficiency_score: 50, empWh: null };
|
||||
const busy = new Map<number, BusyWindow[]>([
|
||||
[1, [{ startMs: new Date(2026, 6, 27, 9, 0).getTime(), endMs: new Date(2026, 6, 27, 13, 0).getTime() }]],
|
||||
[2, []],
|
||||
]);
|
||||
const best = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
||||
assert.equal(best?.id, 2, "el junior libre gana el empate por menor loadBalance, NO por id menor");
|
||||
});
|
||||
|
||||
test("pickBestSlotEmployee: empate total (score+load) → decisión determinista y reproducible", () => {
|
||||
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } }));
|
||||
const startMs = new Date(2026, 6, 27, 9, 0).getTime();
|
||||
const endMs = new Date(2026, 6, 27, 10, 0).getTime();
|
||||
// Dos candidatos idénticos salvo el id → mismo score y mismo loadBalance → hash decide.
|
||||
const A: CandidateInfo = { id: 10, name: "Diez", color: "#fff", role: "s", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
||||
const B: CandidateInfo = { id: 20, name: "Veinte", color: "#fff", role: "s", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
||||
const busy = new Map<number, BusyWindow[]>([[10, []], [20, []]]);
|
||||
const r1 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
||||
const r2 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
||||
assert.ok(r1 && (r1.id === 10 || r1.id === 20), "debe elegir uno de los dos candidatos");
|
||||
assert.equal(r1?.id, r2?.id, "misma entrada → misma decisión (hash determinista, reproducible)");
|
||||
});
|
||||
|
||||
test("BUSY_STATUSES: scheduled+completed sí cuentan; no_show NO (silla vacía)", () => {
|
||||
const s: string[] = [...BUSY_STATUSES];
|
||||
assert.ok(s.includes("scheduled"));
|
||||
assert.ok(s.includes("completed"));
|
||||
assert.equal(s.includes("no_show"), false);
|
||||
assert.equal(s.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
// server/lib/scheduling.ts
|
||||
// Disponibilidad + scoring de especialistas. Las funciones puras de aquí
|
||||
// se unit-testean en scheduling.test.ts; las que tocan la DB se añaden más abajo.
|
||||
|
||||
export interface WorkingDay { start: string; end: string; } // "HH:mm"
|
||||
export type WorkingHoursMap = Record<number, WorkingDay | null>; // 1=Lun … 7=Dom
|
||||
|
||||
export interface BusyWindow { startMs: number; endMs: number; }
|
||||
export interface ScoreInput {
|
||||
specialtyMatch: number; // 0..1
|
||||
efficiency: number; // 0..1
|
||||
loadBalance: number; // 0..1 (fracción de jornada ocupada)
|
||||
}
|
||||
|
||||
export function clamp01(n: number): number {
|
||||
return Math.max(0, Math.min(1, n));
|
||||
}
|
||||
|
||||
export function parseWorkingHours(json: string | null | undefined): WorkingHoursMap | null {
|
||||
if (!json) return null;
|
||||
try {
|
||||
const obj = JSON.parse(json);
|
||||
if (typeof obj !== "object" || obj === null) return null;
|
||||
const out: WorkingHoursMap = {};
|
||||
for (let d = 1; d <= 7; d++) {
|
||||
const v = obj[String(d)];
|
||||
if (v && typeof v.start === "string" && typeof v.end === "string") out[d] = { start: v.start, end: v.end };
|
||||
else out[d] = null;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** JS getDay(): 0=Dom..6=Sáb → ISO 1=Lun..7=Dom. */
|
||||
export function isoDayOfWeek(date: Date): number {
|
||||
const j = date.getDay();
|
||||
return j === 0 ? 7 : j;
|
||||
}
|
||||
|
||||
export function isoDateStr(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** Resuelve el horario (empleado tiene prioridad; si no, negocio) para esa fecha. Devuelve ms o null si es día cerrado. */
|
||||
export function getWorkingHoursForDate(
|
||||
empWh: WorkingHoursMap | null,
|
||||
bizWh: WorkingHoursMap | null,
|
||||
date: Date
|
||||
): { startMs: number; endMs: number } | null {
|
||||
const dow = isoDayOfWeek(date);
|
||||
const day = empWh?.[dow] ?? bizWh?.[dow] ?? null;
|
||||
if (!day) return null;
|
||||
const [sh, sm] = day.start.split(":").map(Number);
|
||||
const [eh, em] = day.end.split(":").map(Number);
|
||||
const startMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), sh, sm).getTime();
|
||||
const endMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), eh, em).getTime();
|
||||
if (!isFinite(startMs) || !isFinite(endMs) || endMs <= startMs) return null;
|
||||
return { startMs, endMs };
|
||||
}
|
||||
|
||||
export function normalizeText(s: string): string {
|
||||
return (s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
|
||||
}
|
||||
|
||||
export function tokens(s: string): string[] {
|
||||
return normalizeText(s).split(/[^a-z0-9]+/).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1.0 si una especialidad coincide con la categoría o un token del nombre del servicio.
|
||||
* 0.5 si no hay etiquetas o no coincide (el caller ya garantiza que ofrece el servicio).
|
||||
*/
|
||||
export function specialtyMatch(specialties: string[], service: { name: string; category: string }): number {
|
||||
if (!specialties || specialties.length === 0) return 0.5;
|
||||
const catN = normalizeText(service.category);
|
||||
const nameTokens = tokens(service.name);
|
||||
const hay = new Set<string>([catN, ...nameTokens]);
|
||||
for (const sp of specialties) {
|
||||
const spn = normalizeText(sp);
|
||||
if (!spn) continue;
|
||||
if (hay.has(spn)) return 1;
|
||||
if (catN && (catN.includes(spn) || spn.includes(catN))) return 1;
|
||||
for (const t of nameTokens) {
|
||||
if (t.length >= 4 && (t.includes(spn) || spn.includes(t))) return 1;
|
||||
}
|
||||
}
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
export function overlaps(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
|
||||
return aStart < bEnd && aEnd > bStart;
|
||||
}
|
||||
|
||||
export function hasConflict(existing: BusyWindow[], startMs: number, endMs: number): boolean {
|
||||
return existing.some((b) => overlaps(startMs, endMs, b.startMs, b.endMs));
|
||||
}
|
||||
|
||||
/** score = 40·specialtyMatch + 20·efficiency + 40·(1−loadBalance), todo en [0,1]. Rango 0..100.
|
||||
* Pesos 40/20/40: specialty sigue mandando en matches reales, pero loadBalance (40) ahora
|
||||
* puede superar la ventaja estática de efficiency (20) → el especialista libre/menos cargado
|
||||
* gana con más frecuencia (equidad, anti-burnout). */
|
||||
export function scoreCandidate(s: ScoreInput): number {
|
||||
const sm = clamp01(s.specialtyMatch);
|
||||
const eff = clamp01(s.efficiency);
|
||||
const inv = clamp01(1 - clamp01(s.loadBalance));
|
||||
return 40 * sm + 20 * eff + 40 * inv;
|
||||
}
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
export interface CandidateInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
role: string;
|
||||
specialties: string[];
|
||||
efficiency_score: number;
|
||||
empWh: WorkingHoursMap | null;
|
||||
}
|
||||
|
||||
function safeArr(json: unknown): string[] {
|
||||
if (typeof json !== "string" || !json) return [];
|
||||
try {
|
||||
const a = JSON.parse(json);
|
||||
return Array.isArray(a) ? a.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getCandidates(db: DatabaseSync, businessId: number, serviceId: number): CandidateInfo[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT e.id, e.name, e.color, e.role, e.specialties, e.working_hours wh, e.efficiency_score
|
||||
FROM employees e
|
||||
JOIN employee_services es ON es.employee_id = e.id
|
||||
WHERE e.business_id = ? AND es.service_id = ? AND e.active = 1`
|
||||
)
|
||||
.all(businessId, serviceId) as any[];
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
color: r.color,
|
||||
role: r.role,
|
||||
specialties: safeArr(r.specialties),
|
||||
efficiency_score: typeof r.efficiency_score === "number" ? r.efficiency_score : 50,
|
||||
empWh: parseWorkingHours(r.wh),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Estados que ocupan la ventana de trabajo del empleado para load/availability.
|
||||
* NOTA: `no_show` NO está — la silla quedó vacía, así que no cuenta contra la carga
|
||||
* del empleado (además ayuda a los sobre-agendados a recuperarse). */
|
||||
export const BUSY_STATUSES = ["scheduled", "completed"] as const;
|
||||
|
||||
export function getExistingBusy(db: DatabaseSync, employeeIds: number[], dateIso: string): Map<number, BusyWindow[]> {
|
||||
const map = new Map<number, BusyWindow[]>();
|
||||
if (employeeIds.length === 0) return map;
|
||||
const startOfDay = `${dateIso}T00:00:00`;
|
||||
const endOfDay = `${dateIso}T23:59:59`;
|
||||
const statusPh = BUSY_STATUSES.map(() => "?").join(",");
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT employee_id, start_at, end_at FROM appointments
|
||||
WHERE employee_id IN (${employeeIds.map(() => "?").join(",")})
|
||||
AND status IN (${statusPh}) AND start_at >= ? AND start_at <= ?`
|
||||
)
|
||||
.all(...employeeIds, ...BUSY_STATUSES, startOfDay, endOfDay) as any[];
|
||||
for (const r of rows) {
|
||||
const arr = map.get(r.employee_id) ?? [];
|
||||
arr.push({ startMs: new Date(r.start_at).getTime(), endMs: new Date(r.end_at).getTime() });
|
||||
map.set(r.employee_id, arr);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function isAvailable(db: DatabaseSync, employeeId: number, startMs: number, endMs: number): boolean {
|
||||
const dateIso = isoDateStr(new Date(startMs));
|
||||
const busy = getExistingBusy(db, [employeeId], dateIso).get(employeeId) ?? [];
|
||||
return !hasConflict(busy, startMs, endMs);
|
||||
}
|
||||
|
||||
interface RankedCandidate { info: CandidateInfo; score: number; loadBalance: number; reasons: string[] }
|
||||
|
||||
function rankOne(
|
||||
c: CandidateInfo,
|
||||
busy: BusyWindow[],
|
||||
wh: { startMs: number; endMs: number },
|
||||
service: { name: string; category: string }
|
||||
): RankedCandidate | null {
|
||||
const workingMs = Math.max(1, wh.endMs - wh.startMs);
|
||||
let occupiedMs = 0;
|
||||
for (const b of busy) {
|
||||
occupiedMs += Math.min(b.endMs, wh.endMs) - Math.max(b.startMs, wh.startMs);
|
||||
if (occupiedMs < 0) occupiedMs = 0;
|
||||
}
|
||||
const loadBalance = clamp01(occupiedMs / workingMs);
|
||||
const sm = specialtyMatch(c.specialties, service);
|
||||
const score = scoreCandidate({ specialtyMatch: sm, efficiency: c.efficiency_score / 100, loadBalance });
|
||||
const reasons: string[] = [];
|
||||
if (sm >= 1) reasons.push("Especialidad coincidente");
|
||||
if (c.efficiency_score >= 75) reasons.push("Alta eficiencia");
|
||||
if (loadBalance <= 0.25) reasons.push("Buena disponibilidad");
|
||||
return { info: c, score, loadBalance, reasons };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash determinista (xmur3) → uint32. Sin deps. Misma entrada ⇒ misma salida en cualquier
|
||||
* plataforma, para que el desempate por hash sea reproducible por input.
|
||||
* Úselo para repartir empates sin sesgo por id/seniority. */
|
||||
export function hashStr(str: string): number {
|
||||
let h = 1779033703 ^ str.length;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
|
||||
h = (h << 13) | (h >>> 19);
|
||||
}
|
||||
h = Math.imul(h ^ (h >>> 16), 2246822507);
|
||||
h = Math.imul(h ^ (h >>> 13), 3266489909);
|
||||
return (h ^ (h >>> 16)) >>> 0;
|
||||
}
|
||||
|
||||
/** Epsilon para considerar dos loadBalance iguales (ratio de ms → posible ruido float). */
|
||||
const LOAD_EPSILON = 1e-9;
|
||||
|
||||
/**
|
||||
* ¿`a` desplaza al actual `best`? Orden de desempate (anti-burnout, sin sesgo por id):
|
||||
* 1) mayor score
|
||||
* 2) (cerca de empate) menor loadBalance — gana el menos cargado
|
||||
* 3) hash determinista de `${dateIso}|${startMs}|${empId}` — mismo slot ⇒ misma decisión,
|
||||
* pero slots distintos reparten entre empleados (no siempre cae en la misma persona).
|
||||
*/
|
||||
function beats(a: RankedCandidate, best: RankedCandidate, dateIso: string, startMs: number): boolean {
|
||||
if (a.score !== best.score) return a.score > best.score;
|
||||
if (Math.abs(a.loadBalance - best.loadBalance) > LOAD_EPSILON) return a.loadBalance < best.loadBalance;
|
||||
return hashStr(`${dateIso}|${startMs}|${a.info.id}`) < hashStr(`${dateIso}|${startMs}|${best.info.id}`);
|
||||
}
|
||||
|
||||
/** Usado por el endpoint de slots: devuelve el mejor candidato libre para un slot concreto. */
|
||||
export function pickBestSlotEmployee(
|
||||
candidates: CandidateInfo[],
|
||||
busyMap: Map<number, BusyWindow[]>,
|
||||
bizWh: WorkingHoursMap | null,
|
||||
service: { name: string; category: string },
|
||||
startMs: number,
|
||||
endMs: number
|
||||
): { id: number; name: string } | null {
|
||||
const date = new Date(startMs);
|
||||
const dateIso = isoDateStr(date);
|
||||
let best: RankedCandidate | null = null;
|
||||
for (const c of candidates) {
|
||||
const wh = getWorkingHoursForDate(c.empWh, bizWh, date);
|
||||
if (!wh) continue;
|
||||
if (startMs < wh.startMs || endMs > wh.endMs) continue;
|
||||
if (hasConflict(busyMap.get(c.id) ?? [], startMs, endMs)) continue;
|
||||
const ranked = rankOne(c, busyMap.get(c.id) ?? [], wh, service);
|
||||
if (!ranked) continue;
|
||||
if (!best || beats(ranked, best, dateIso, startMs)) {
|
||||
best = ranked;
|
||||
}
|
||||
}
|
||||
return best ? { id: best.info.id, name: best.info.name } : null;
|
||||
}
|
||||
|
||||
export interface AutoAssignResult {
|
||||
employeeId: number;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface AutoAssignCtx {
|
||||
businessId: number;
|
||||
serviceId: number;
|
||||
serviceName: string;
|
||||
serviceCategory: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
bizWh: WorkingHoursMap | null;
|
||||
}
|
||||
|
||||
/** Asigna el mejor especialista disponible (hard constraints + score). Llamar dentro de transacción. */
|
||||
export function autoAssign(db: DatabaseSync, ctx: AutoAssignCtx): AutoAssignResult | null {
|
||||
const candidates = getCandidates(db, ctx.businessId, ctx.serviceId);
|
||||
if (candidates.length === 0) return null;
|
||||
const dateIso = isoDateStr(new Date(ctx.startMs));
|
||||
const busyMap = getExistingBusy(db, candidates.map((c) => c.id), dateIso);
|
||||
let best: RankedCandidate | null = null;
|
||||
for (const c of candidates) {
|
||||
const wh = getWorkingHoursForDate(c.empWh, ctx.bizWh, new Date(ctx.startMs));
|
||||
if (!wh) continue;
|
||||
if (ctx.startMs < wh.startMs || ctx.endMs > wh.endMs) continue;
|
||||
if (hasConflict(busyMap.get(c.id) ?? [], ctx.startMs, ctx.endMs)) continue;
|
||||
const ranked = rankOne(c, busyMap.get(c.id) ?? [], wh, { name: ctx.serviceName, category: ctx.serviceCategory });
|
||||
if (!ranked) continue;
|
||||
if (!best || beats(ranked, best, dateIso, ctx.startMs)) {
|
||||
best = ranked;
|
||||
}
|
||||
}
|
||||
if (!best) return null;
|
||||
return {
|
||||
employeeId: best.info.id,
|
||||
score: best.score,
|
||||
reasons: best.reasons.length ? best.reasons : ["Disponible"],
|
||||
};
|
||||
}
|
||||
|
||||
/** Ejecuta fn dentro de una transacción BEGIN IMMEDIATE…COMMIT; ROLLBACK en error. */
|
||||
export function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const r = fn();
|
||||
db.exec("COMMIT");
|
||||
return r;
|
||||
} catch (err) {
|
||||
try { db.exec("ROLLBACK"); } catch { /* ignore */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// server/lib/time.test.ts
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
bizDateISO,
|
||||
bizTodayISO,
|
||||
bizDayBoundsSqlite,
|
||||
bizDayBoundsIso,
|
||||
wallToUtcISO,
|
||||
wallToUtcDate,
|
||||
toSqliteUtc,
|
||||
toIsoUtc,
|
||||
} from "./time.ts";
|
||||
|
||||
const MX = "America/Mexico_City"; // UTC-6 (no DST since 2023)
|
||||
const BA = "America/Buenos_Aires"; // UTC-3
|
||||
|
||||
test("bizTodayISO: UTC instant just past midnight (00:30Z) is still the previous day in MX", () => {
|
||||
// 2026-07-27T00:30:00Z == 2026-07-26T18:30 local → still the 26th
|
||||
assert.equal(bizTodayISO(MX, new Date("2026-07-27T00:30:00Z")), "2026-07-26");
|
||||
});
|
||||
|
||||
test("bizTodayISO: late afternoon UTC (17:00Z) is same biz day in MX", () => {
|
||||
assert.equal(bizTodayISO(MX, new Date("2026-07-26T17:00:00Z")), "2026-07-26");
|
||||
});
|
||||
|
||||
test("bizDateISO: Buenos Aires UTC-3 midday stays same day", () => {
|
||||
// 17:00Z == 14:00 local
|
||||
assert.equal(bizDateISO(new Date("2026-07-26T17:00:00Z"), BA), "2026-07-26");
|
||||
});
|
||||
|
||||
test("bizDateISO: Buenos Aires UTC-3 evening stays same day", () => {
|
||||
// 23:00Z == 20:00 local
|
||||
assert.equal(bizDateISO(new Date("2026-07-26T23:00:00Z"), BA), "2026-07-26");
|
||||
});
|
||||
|
||||
test("bizDateISO: UTC instant rolls to next local day", () => {
|
||||
// 2026-07-27T05:00:00Z == 2026-07-26T23:00 in MX → still 26th
|
||||
assert.equal(bizDateISO(new Date("2026-07-27T05:00:00Z"), MX), "2026-07-26");
|
||||
// 2026-07-27T06:30:00Z == 2026-07-27T00:30 in MX → now 27th
|
||||
assert.equal(bizDateISO(new Date("2026-07-27T06:30:00Z"), MX), "2026-07-27");
|
||||
});
|
||||
|
||||
test("wallToUtcISO: MX wall 18:00 → UTC next-day 00:00", () => {
|
||||
assert.equal(wallToUtcISO(MX, 2026, 7, 26, 18, 0, 0), "2026-07-27T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("wallToUtcISO: MX wall 00:00 → UTC 06:00 same date", () => {
|
||||
assert.equal(wallToUtcISO(MX, 2026, 7, 26, 0, 0, 0), "2026-07-26T06:00:00.000Z");
|
||||
});
|
||||
|
||||
test("wallToUtcDate: round-trip via bizDateISO stays on the wall day", () => {
|
||||
const utc = wallToUtcDate(MX, 2026, 7, 26, 18, 0, 0);
|
||||
assert.equal(bizDateISO(utc, MX), "2026-07-26");
|
||||
});
|
||||
|
||||
test("bizDayBoundsSqlite: today bounds capture the evening rush (UTC-6)", () => {
|
||||
// "now" is 2026-07-26T23:30:00Z == 2026-07-26T17:30 local MX → biz day 2026-07-26
|
||||
const b = bizDayBoundsSqlite(MX, 0, new Date("2026-07-26T23:30:00Z"));
|
||||
// biz-local midnight 2026-07-26 in MX == 06:00Z; end of day 23:59:59 == 2026-07-27T05:59:59Z
|
||||
assert.equal(b.start, "2026-07-26 06:00:00");
|
||||
assert.equal(b.end, "2026-07-27 05:59:59");
|
||||
});
|
||||
|
||||
test("bizDayBoundsIso: ISO-Z bounds for start_at comparisons", () => {
|
||||
const b = bizDayBoundsIso(MX, 0, new Date("2026-07-26T23:30:00Z"));
|
||||
assert.equal(b.start, "2026-07-26T06:00:00Z");
|
||||
assert.equal(b.end, "2026-07-27T05:59:59Z");
|
||||
});
|
||||
|
||||
test("bizDayBoundsSqlite: negative offset reaches into the past", () => {
|
||||
// 30 days before biz day 2026-07-26 → 2026-06-26, midnight MX == 06:00Z
|
||||
const b = bizDayBoundsSqlite(MX, -30, new Date("2026-07-26T23:30:00Z"));
|
||||
assert.equal(b.start, "2026-06-26 06:00:00");
|
||||
});
|
||||
|
||||
test("toSqliteUtc / toIsoUtc format helpers", () => {
|
||||
const d = new Date("2026-07-27T00:00:00.000Z");
|
||||
assert.equal(toSqliteUtc(d), "2026-07-27 00:00:00");
|
||||
assert.equal(toIsoUtc(d), "2026-07-27T00:00:00Z");
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
// server/lib/time.ts
|
||||
// Pure timezone helpers for business-local ("biz") date math.
|
||||
// Mexico is UTC-6, so SQLite date('now') (= UTC) misattributes the evening rush.
|
||||
// All functions are deterministic given an explicit instant (default = new Date()).
|
||||
|
||||
/** Returns the calendar date (YYYY-MM-DD) of the given instant in the given IANA tz. */
|
||||
export function bizDateISO(instant: Date, tz: string): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz || "UTC",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(instant); // en-CA yields "YYYY-MM-DD"
|
||||
}
|
||||
|
||||
/** Business "today" (YYYY-MM-DD) for a tz, at the given instant (default: server now). */
|
||||
export function bizTodayISO(tz: string, now: Date = new Date()): string {
|
||||
return bizDateISO(now, tz);
|
||||
}
|
||||
|
||||
/** tz offset (in minutes) of the given instant, east of UTC positive. */
|
||||
function tzOffsetMinutes(instant: Date, tz: string): number {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz || "UTC",
|
||||
timeZoneName: "longOffset",
|
||||
}).formatToParts(instant);
|
||||
const off = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT+00:00";
|
||||
// e.g. "GMT-06:00", "GMT+05:30", "GMT+00:00", or bare "GMT" for UTC
|
||||
const m = off.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
|
||||
if (!m) return 0; // bare "GMT" → UTC
|
||||
const sign = m[1] === "-" ? -1 : 1;
|
||||
const h = parseInt(m[2], 10);
|
||||
const min = m[3] ? parseInt(m[3], 10) : 0;
|
||||
return sign * (h * 60 + min);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a business-local wall-clock (year, month, day, hh, mm, ss) in `tz` to a UTC Date.
|
||||
* We first treat the wall-clock as if it were UTC, read the tz offset at that instant,
|
||||
* then subtract the offset: a west tz (negative offset) is "behind" UTC, so the same
|
||||
* wall-clock happens later in UTC → UTC = wall − offset.
|
||||
*/
|
||||
export function wallToUtcDate(
|
||||
tz: string,
|
||||
y: number,
|
||||
mo: number,
|
||||
d: number,
|
||||
hh: number,
|
||||
mm: number,
|
||||
ss: number
|
||||
): Date {
|
||||
const guess = new Date(Date.UTC(y, mo - 1, d, hh, mm, ss));
|
||||
const offsetMin = tzOffsetMinutes(guess, tz);
|
||||
return new Date(guess.getTime() - offsetMin * 60000);
|
||||
}
|
||||
|
||||
/** Business-day UTC bounds (start = local 00:00:00, end = local 23:59:59) for today +/- offset. */
|
||||
export function bizDayBounds(
|
||||
tz: string,
|
||||
dayOffset = 0,
|
||||
now: Date = new Date()
|
||||
): { start: Date; end: Date } {
|
||||
const today = bizDateISO(now, tz);
|
||||
const [y, m, d] = today.split("-").map(Number);
|
||||
const base = new Date(Date.UTC(y, m - 1, d));
|
||||
base.setUTCDate(base.getUTCDate() + dayOffset);
|
||||
const ty = base.getUTCFullYear();
|
||||
const tm = base.getUTCMonth() + 1;
|
||||
const td = base.getUTCDate();
|
||||
return {
|
||||
start: wallToUtcDate(tz, ty, tm, td, 0, 0, 0),
|
||||
end: wallToUtcDate(tz, ty, tm, td, 23, 59, 59),
|
||||
};
|
||||
}
|
||||
|
||||
/** Format a UTC instant as SQLite canonical "YYYY-MM-DD HH:MM:SS" (matches datetime() output). */
|
||||
export function toSqliteUtc(d: Date): string {
|
||||
return d.toISOString().slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
/** Format a UTC instant as ISO "YYYY-MM-DDTHH:MM:SSZ" (matches the stored start_at format). */
|
||||
export function toIsoUtc(d: Date): string {
|
||||
return d.toISOString().slice(0, 19) + "Z";
|
||||
}
|
||||
|
||||
/** Business-day bounds in SQLite canonical format — pair with `datetime(column) >= ?`. */
|
||||
export function bizDayBoundsSqlite(
|
||||
tz: string,
|
||||
dayOffset = 0,
|
||||
now: Date = new Date()
|
||||
): { start: string; end: string } {
|
||||
const b = bizDayBounds(tz, dayOffset, now);
|
||||
return { start: toSqliteUtc(b.start), end: toSqliteUtc(b.end) };
|
||||
}
|
||||
|
||||
/** Business-day bounds in ISO-Z format — pair with raw `start_at >= ?`. */
|
||||
export function bizDayBoundsIso(
|
||||
tz: string,
|
||||
dayOffset = 0,
|
||||
now: Date = new Date()
|
||||
): { start: string; end: string } {
|
||||
const b = bizDayBounds(tz, dayOffset, now);
|
||||
return { start: toIsoUtc(b.start), end: toIsoUtc(b.end) };
|
||||
}
|
||||
|
||||
/** Convenience: convert a business-local wall-clock to a UTC ISO string. */
|
||||
export function wallToUtcISO(
|
||||
tz: string,
|
||||
y: number,
|
||||
mo: number,
|
||||
d: number,
|
||||
hh: number,
|
||||
mm: number,
|
||||
ss: number
|
||||
): string {
|
||||
return wallToUtcDate(tz, y, mo, d, hh, mm, ss).toISOString();
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Router } from "express";
|
||||
import { db } from "../db.ts";
|
||||
import type { AuthedRequest } from "../lib/auth.ts";
|
||||
import { err } from "../lib/auth.ts";
|
||||
import { autoAssign, isAvailable, runInTransaction, parseWorkingHours } from "../lib/scheduling.ts";
|
||||
|
||||
export const appointmentsRouter = Router();
|
||||
|
||||
@@ -25,7 +26,11 @@ function joinAppointment(a: any) {
|
||||
.prepare(`SELECT id, name, color, role FROM employees WHERE id = ?`)
|
||||
.get(a.employee_id);
|
||||
a.client = db
|
||||
.prepare(`SELECT id, name, phone, email, tags FROM clients WHERE id = ?`)
|
||||
.prepare(
|
||||
`SELECT c.id, c.name, c.phone, c.email, c.notes, c.tags,
|
||||
(SELECT COUNT(*) FROM appointments a2 WHERE a2.client_id = c.id AND a2.status = 'no_show') AS no_show_count
|
||||
FROM clients c WHERE c.id = ?`
|
||||
)
|
||||
.get(a.client_id);
|
||||
return a;
|
||||
}
|
||||
@@ -114,56 +119,60 @@ appointmentsRouter.post("/", (req: AuthedRequest, res) => {
|
||||
if (!clientId) return err(res, 400, "Selecciona o crea un cliente");
|
||||
|
||||
// Resolve employee: auto-assign to self for employees; default to first eligible for owner
|
||||
let empId = employee_id ? Number(employee_id) : null;
|
||||
if (!empId && req.user!.role === "employee" && req.user!.employee_id) {
|
||||
empId = req.user!.employee_id;
|
||||
}
|
||||
if (!empId) {
|
||||
const eligible = db
|
||||
.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ? LIMIT 1`)
|
||||
.get(Number(service_id)) as { id: number } | undefined;
|
||||
empId = eligible?.id ?? null;
|
||||
}
|
||||
if (!empId) return err(res, 400, "No hay empleado asignado a este servicio");
|
||||
|
||||
const start = new Date(start_at);
|
||||
if (isNaN(start.getTime())) return err(res, 400, "Fecha de inicio inválida");
|
||||
const dur = Number(duration_min) || service.duration_min;
|
||||
const end = new Date(start.getTime() + dur * 60000);
|
||||
const endIso = end.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const startIso = start.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const startMs = start.getTime();
|
||||
const endMs = end.getTime();
|
||||
|
||||
const finalStatus = status || "scheduled";
|
||||
const price = price_override !== undefined ? Number(price_override) : service.price;
|
||||
|
||||
const r = db
|
||||
try {
|
||||
const r = runInTransaction(db, () => {
|
||||
// Resolver empleado (igual que antes, pero usando autoAssign si ninguno)
|
||||
let empId = employee_id ? Number(employee_id) : null;
|
||||
if (!empId && req.user!.role === "employee" && req.user!.employee_id) {
|
||||
empId = req.user!.employee_id;
|
||||
}
|
||||
if (!empId) {
|
||||
const bizRow = db.prepare(`SELECT working_hours wh FROM businesses WHERE id = ?`).get(req.user!.business_id) as any;
|
||||
const aa = autoAssign(db, {
|
||||
businessId: Number(req.user!.business_id), serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
|
||||
startMs, endMs, bizWh: parseWorkingHours(bizRow?.wh) as any,
|
||||
});
|
||||
empId = aa?.employeeId ?? null;
|
||||
}
|
||||
if (!empId) throw { status: 400, error: "No hay empleado asignado a este servicio" };
|
||||
|
||||
// Guard anti doble reserva
|
||||
if (!isAvailable(db, empId, startMs, endMs)) throw { status: 409, error: "Ese horario ya está ocupado para el especialista" };
|
||||
|
||||
const inserted = db
|
||||
.prepare(
|
||||
`INSERT INTO appointments
|
||||
(business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes, created_by_user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
|
||||
)
|
||||
.get(
|
||||
req.user!.business_id,
|
||||
Number(service_id),
|
||||
empId,
|
||||
clientId,
|
||||
startIso,
|
||||
endIso,
|
||||
finalStatus,
|
||||
price,
|
||||
notes || null,
|
||||
req.user!.id
|
||||
) as any;
|
||||
.get(req.user!.business_id, Number(service_id), empId, clientId, startIso, endIso, finalStatus, price, notes || null, req.user!.id) as any;
|
||||
|
||||
if (finalStatus === "completed") {
|
||||
db.prepare(
|
||||
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, 'card')`
|
||||
).run(req.user!.business_id, r.id, clientId, empId, Number(service_id), price);
|
||||
).run(req.user!.business_id, inserted.id, clientId, empId, Number(service_id), price);
|
||||
}
|
||||
|
||||
joinAppointment(r);
|
||||
joinAppointment(inserted);
|
||||
return inserted;
|
||||
});
|
||||
res.status(201).json({ appointment: r });
|
||||
} catch (e: any) {
|
||||
if (e && typeof e === "object" && e.status) return err(res, e.status, e.error);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
appointmentsRouter.patch("/:id", (req: AuthedRequest, res) => {
|
||||
@@ -172,6 +181,9 @@ appointmentsRouter.patch("/:id", (req: AuthedRequest, res) => {
|
||||
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
|
||||
.get(id, req.user!.business_id) as any;
|
||||
if (!existing) return err(res, 404, "Cita no encontrada");
|
||||
if (req.user!.role === "employee" && existing.employee_id !== req.user!.employee_id) {
|
||||
return err(res, 403, "No puedes modificar una cita que no es tuya");
|
||||
}
|
||||
|
||||
const {
|
||||
service_id,
|
||||
@@ -250,6 +262,9 @@ appointmentsRouter.post("/:id/complete", (req: AuthedRequest, res) => {
|
||||
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
|
||||
.get(id, req.user!.business_id) as any;
|
||||
if (!appt) return err(res, 404, "Cita no encontrada");
|
||||
if (req.user!.role === "employee" && appt.employee_id !== req.user!.employee_id) {
|
||||
return err(res, 403, "No puedes modificar una cita que no es tuya");
|
||||
}
|
||||
const { tip = 0, payment_method = "card" } = req.body ?? {};
|
||||
db.prepare(`UPDATE appointments SET status = 'completed', updated_at = datetime('now') WHERE id = ?`).run(id);
|
||||
const hasTicket = db.prepare(`SELECT id FROM tickets WHERE appointment_id = ?`).get(id);
|
||||
@@ -274,9 +289,12 @@ appointmentsRouter.post("/:id/complete", (req: AuthedRequest, res) => {
|
||||
appointmentsRouter.delete("/:id", (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db
|
||||
.prepare(`SELECT id FROM appointments WHERE id = ? AND business_id = ?`)
|
||||
.get(id, req.user!.business_id);
|
||||
.prepare(`SELECT id, employee_id FROM appointments WHERE id = ? AND business_id = ?`)
|
||||
.get(id, req.user!.business_id) as any;
|
||||
if (!existing) return err(res, 404, "Cita no encontrada");
|
||||
if (req.user!.role === "employee" && existing.employee_id !== req.user!.employee_id) {
|
||||
return err(res, 403, "No puedes modificar una cita que no es tuya");
|
||||
}
|
||||
db.prepare(`DELETE FROM appointments WHERE id = ?`).run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
+117
-45
@@ -1,5 +1,10 @@
|
||||
import { Router } from "express";
|
||||
import { db } from "../db.ts";
|
||||
import {
|
||||
parseWorkingHours, getWorkingHoursForDate, getCandidates, getExistingBusy,
|
||||
pickBestSlotEmployee, autoAssign, isAvailable, runInTransaction,
|
||||
type WorkingHoursMap,
|
||||
} from "../lib/scheduling.ts";
|
||||
|
||||
export const bookingRouter = Router();
|
||||
|
||||
@@ -7,7 +12,8 @@ function publicBusiness(slug: string) {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT id, name, industry, currency, currency_symbol, phone, address, timezone,
|
||||
booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct
|
||||
booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct,
|
||||
auto_assign_specialist, working_hours
|
||||
FROM businesses WHERE slug = ? AND status = 'active'`
|
||||
)
|
||||
.get(slug) as any;
|
||||
@@ -47,55 +53,75 @@ bookingRouter.get("/:slug/slots", (req, res) => {
|
||||
} else {
|
||||
candidates = db.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ?`).all(serviceId).map((r: any) => r.id);
|
||||
if (candidates.length === 0) {
|
||||
const any = db.prepare(`SELECT id FROM employees WHERE business_id = ? AND active = 1 LIMIT 1`).get(biz.id) as any;
|
||||
candidates = any ? [any.id] : [];
|
||||
const anyEmp = db.prepare(`SELECT id FROM employees WHERE business_id = ? AND active = 1 LIMIT 1`).get(biz.id) as any;
|
||||
candidates = anyEmp ? [anyEmp.id] : [];
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return res.json({ slots: [] });
|
||||
|
||||
// business hours 09:00–20:00, 30-min grid; reject past times
|
||||
const dayStart = new Date(`${date}T09:00:00`);
|
||||
const dayEnd = new Date(`${date}T20:00:00`);
|
||||
const bizWh = parseWorkingHours(biz.working_hours) as WorkingHoursMap | null;
|
||||
|
||||
// Si el cliente no eligió especialista, usar el ranking para mostrar el mejor disponible por slot.
|
||||
const useRanking = !employeeId;
|
||||
const candInfos = useRanking ? getCandidates(db, biz.id, serviceId) : [];
|
||||
const candBusyMap = useRanking ? getExistingBusy(db, candInfos.map((c) => c.id), date) : new Map();
|
||||
|
||||
// Para cuando sí se eligió especialista: ventana = horario de ese empleado (o negocio).
|
||||
const dateObj = new Date(`${date}T12:00:00`);
|
||||
const singleEmpRow = employeeId
|
||||
? (db.prepare(`SELECT working_hours wh FROM employees WHERE id = ?`).get(employeeId) as any)
|
||||
: null;
|
||||
const singleEmpWh = singleEmpRow ? parseWorkingHours(singleEmpRow.wh) : null;
|
||||
const window = employeeId
|
||||
? (getWorkingHoursForDate(singleEmpWh as WorkingHoursMap | null, bizWh, dateObj) ?? getWorkingHoursForDate(null, bizWh, dateObj))
|
||||
: getWorkingHoursForDate(null, bizWh, dateObj);
|
||||
|
||||
const now = new Date();
|
||||
const slots: { time: string; iso: string; employee_id: number; employee_name: string }[] = [];
|
||||
const dur = service.duration_min;
|
||||
|
||||
// gather existing appointments that day for these employees
|
||||
const startOfDayIso = `${date}T00:00:00`;
|
||||
const endOfDayIso = `${date}T23:59:59`;
|
||||
const existing = db
|
||||
.prepare(
|
||||
`SELECT employee_id, start_at, end_at FROM appointments
|
||||
WHERE business_id = ? AND employee_id IN (${candidates.map(() => "?").join(",")})
|
||||
AND status IN ('scheduled','completed') AND start_at >= ? AND start_at <= ?`
|
||||
)
|
||||
.all(biz.id, ...candidates, startOfDayIso, endOfDayIso) as any[];
|
||||
if (!window) {
|
||||
return res.json({ slots, service: { id: service.id, name: service.name, price: service.price, duration_min: service.duration_min } });
|
||||
}
|
||||
const dayStart = window.startMs;
|
||||
const dayEnd = window.endMs;
|
||||
|
||||
for (let t = dayStart.getTime(); t + dur * 60000 <= dayEnd.getTime(); t += 30 * 60000) {
|
||||
if (t < now.getTime() + 30 * 60000) continue; // skip past / too-soon
|
||||
// citas existentes del día para el caso de especialista fijo
|
||||
const existing = employeeId
|
||||
? (getExistingBusy(db, [employeeId], date).get(employeeId) ?? [])
|
||||
: [];
|
||||
|
||||
for (let t = dayStart; t + dur * 60000 <= dayEnd; t += 30 * 60000) {
|
||||
if (t < now.getTime() + 30 * 60000) continue; // pasado / demasiado pronto
|
||||
const slotStart = t;
|
||||
const slotEnd = t + dur * 60000;
|
||||
// find an employee free in this window
|
||||
for (const empId of candidates) {
|
||||
const busy = existing.some(
|
||||
(b) => b.employee_id === empId && slotStart < new Date(b.end_at).getTime() && slotEnd > new Date(b.start_at).getTime()
|
||||
);
|
||||
let chosen: { id: number; name: string } | null = null;
|
||||
if (useRanking) {
|
||||
chosen = pickBestSlotEmployee(candInfos, candBusyMap, bizWh, { name: service.name, category: service.category }, slotStart, slotEnd);
|
||||
} else {
|
||||
const busy = existing.some((b) => overlapsRange(slotStart, slotEnd, b.startMs, b.endMs));
|
||||
if (!busy) {
|
||||
const emp = db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any;
|
||||
const emp = db.prepare(`SELECT name FROM employees WHERE id = ?`).get(employeeId) as any;
|
||||
chosen = { id: employeeId, name: emp?.name ?? "" };
|
||||
}
|
||||
}
|
||||
if (chosen) {
|
||||
slots.push({
|
||||
time: new Date(t).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", hour12: true }),
|
||||
iso: new Date(t).toISOString(),
|
||||
employee_id: empId,
|
||||
employee_name: emp?.name,
|
||||
employee_id: chosen.id,
|
||||
employee_name: chosen.name,
|
||||
});
|
||||
break; // one free employee per slot is enough
|
||||
}
|
||||
}
|
||||
if (slots.length >= 40) break;
|
||||
}
|
||||
res.json({ slots, service: { id: service.id, name: service.name, price: service.price, duration_min: service.duration_min } });
|
||||
});
|
||||
|
||||
function overlapsRange(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
|
||||
return aStart < bEnd && aEnd > bStart;
|
||||
}
|
||||
|
||||
// Create a booking from the public site (no auth): creates/finds client + appointment
|
||||
bookingRouter.post("/:slug/book", (req, res) => {
|
||||
const biz = publicBusiness(req.params.slug);
|
||||
@@ -107,15 +133,37 @@ bookingRouter.post("/:slug/book", (req, res) => {
|
||||
const start = new Date(start_at);
|
||||
if (isNaN(start.getTime()) || start.getTime() < Date.now()) return res.status(400).json({ error: "Fecha inválida" });
|
||||
|
||||
// resolve employee
|
||||
let empId = employee_id ? Number(employee_id) : null;
|
||||
if (!empId) {
|
||||
const cand = db.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ? LIMIT 1`).get(Number(service_id)) as any;
|
||||
empId = cand?.id ?? null;
|
||||
}
|
||||
if (!empId) return res.status(400).json({ error: "Sin especialista disponible" });
|
||||
const startMs = start.getTime();
|
||||
const endMs = startMs + service.duration_min * 60000;
|
||||
|
||||
// resolve client (by phone/email or create)
|
||||
// ¿El cliente forzó especialista? Si auto_assign_specialist=1, se ignora al cliente y se auto-asigna.
|
||||
const autoAssignOn = !!biz.auto_assign_specialist;
|
||||
const clientChose = !autoAssignOn && employee_id ? Number(employee_id) : null;
|
||||
|
||||
try {
|
||||
const result = runInTransaction(db, () => {
|
||||
// 1) Resolver especialista
|
||||
let empId: number | null = clientChose;
|
||||
let reasons: string[] = [];
|
||||
if (!empId) {
|
||||
const bizWh = parseWorkingHours(biz.working_hours) as any;
|
||||
const aa = autoAssign(db, {
|
||||
businessId: biz.id, serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
|
||||
startMs, endMs, bizWh,
|
||||
});
|
||||
if (!aa) throw { status: 409, error: "ESE_HORARIO_OCUPADO" };
|
||||
empId = aa.employeeId;
|
||||
reasons = aa.reasons;
|
||||
} else {
|
||||
// validar que ofrece el servicio
|
||||
const ok = db.prepare(`SELECT 1 FROM employee_services WHERE service_id = ? AND employee_id = ?`).get(service.id, empId);
|
||||
if (!ok) throw { status: 400, error: "El especialista no ofrece este servicio" };
|
||||
// guard anti doble reserva
|
||||
if (!isAvailable(db, empId, startMs, endMs)) throw { status: 409, error: "ESE_HORARIO_OCUPADO" };
|
||||
reasons = ["Tu especialista elegido"];
|
||||
}
|
||||
|
||||
// 2) Resolver cliente (por phone/email o crear)
|
||||
let clientId: number | null = null;
|
||||
const match = client.phone
|
||||
? (db.prepare(`SELECT id FROM clients WHERE business_id = ? AND phone = ?`).get(biz.id, client.phone) as any)
|
||||
@@ -130,25 +178,49 @@ bookingRouter.post("/:slug/book", (req, res) => {
|
||||
clientId = created.id;
|
||||
}
|
||||
|
||||
const endIso = new Date(start.getTime() + service.duration_min * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const startIso = start.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
// F18: soft no-show enforcement (nunca bloquea la reserva)
|
||||
const nsCount = (db.prepare("SELECT COUNT(*) c FROM appointments WHERE client_id = ? AND status = 'no_show'").get(clientId) as { c: number })?.c ?? 0;
|
||||
const ns = typeof nsCount === "number" ? nsCount : Number(nsCount) || 0;
|
||||
const riskFlag = ns >= 2;
|
||||
let notesBase = client.notes || "Reserva online";
|
||||
if (ns >= 3) {
|
||||
notesBase = `[Riesgo de no-show] ${notesBase}`;
|
||||
db.prepare(`UPDATE clients SET tags = 'Riesgo' WHERE id = ? AND (tags IS NULL OR tags NOT LIKE '%Riesgo%')`).run(clientId);
|
||||
}
|
||||
|
||||
const endIso = new Date(endMs).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const startIso = new Date(startMs).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const appt = db
|
||||
.prepare(
|
||||
`INSERT INTO appointments (business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'scheduled', ?, ?) RETURNING *`
|
||||
)
|
||||
.get(biz.id, Number(service_id), empId, clientId, startIso, endIso, service.price, client.notes || "Reserva online") as any;
|
||||
.get(biz.id, Number(service_id), empId, clientId, startIso, endIso, service.price, notesBase) as any;
|
||||
|
||||
const empName = (db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any)?.name;
|
||||
return { appt, empId: empId as number, empName, reasons, clientCreated: !match, noShowCount: ns, riskFlag };
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
appointment: {
|
||||
id: appt.id,
|
||||
start_at: appt.start_at,
|
||||
end_at: appt.end_at,
|
||||
price: appt.price,
|
||||
id: result.appt.id,
|
||||
start_at: result.appt.start_at,
|
||||
end_at: result.appt.end_at,
|
||||
price: result.appt.price,
|
||||
service_name: service.name,
|
||||
employee_name: (db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any)?.name,
|
||||
employee_id: result.empId,
|
||||
employee_name: result.empName,
|
||||
reasons: result.reasons,
|
||||
},
|
||||
business: { name: biz.name, currency_symbol: biz.currency_symbol },
|
||||
client_created: !match,
|
||||
client_created: result.clientCreated,
|
||||
no_show_count: result.noShowCount,
|
||||
risk_flag: result.riskFlag,
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e && typeof e === "object" && e.status) {
|
||||
return res.status(e.status).json({ error: e.error === "ESE_HORARIO_OCUPADO" ? "Esa hora acaba de ocuparse, elige otra." : e.error });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from "express";
|
||||
import { db } from "../db.ts";
|
||||
import type { AuthedRequest } from "../lib/auth.ts";
|
||||
import { err } from "../lib/auth.ts";
|
||||
import { err, ownerOnly } from "../lib/auth.ts";
|
||||
import type { ClientStats } from "../../shared/types.ts";
|
||||
|
||||
export const clientsRouter = Router();
|
||||
@@ -78,7 +78,7 @@ clientsRouter.post("/", (req: AuthedRequest, res) => {
|
||||
res.status(201).json({ client: r });
|
||||
});
|
||||
|
||||
clientsRouter.patch("/:id", (req: AuthedRequest, res) => {
|
||||
clientsRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db
|
||||
.prepare(`SELECT * FROM clients WHERE id = ? AND business_id = ?`)
|
||||
@@ -100,7 +100,7 @@ clientsRouter.patch("/:id", (req: AuthedRequest, res) => {
|
||||
res.json({ client: updated });
|
||||
});
|
||||
|
||||
clientsRouter.delete("/:id", (req: AuthedRequest, res) => {
|
||||
clientsRouter.delete("/:id", ownerOnly, (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db
|
||||
.prepare(`SELECT id FROM clients WHERE id = ? AND business_id = ?`)
|
||||
|
||||
+141
-72
@@ -2,73 +2,114 @@ import { Router } from "express";
|
||||
import { db } from "../db.ts";
|
||||
import type { AuthedRequest } from "../lib/auth.ts";
|
||||
import { ownerOnly } from "../lib/auth.ts";
|
||||
import {
|
||||
bizDateISO,
|
||||
bizDayBoundsSqlite,
|
||||
bizDayBoundsIso,
|
||||
toIsoUtc,
|
||||
} from "../lib/time.ts";
|
||||
import { cancelRate, noShowRate, completionRate } from "../lib/metrics.ts";
|
||||
|
||||
export const dashboardRouter = Router();
|
||||
|
||||
dashboardRouter.use(ownerOnly);
|
||||
|
||||
const DEFAULT_TZ = "America/Mexico_City";
|
||||
|
||||
/** Fetch the business timezone once per handler. */
|
||||
function bizTz(bid: number | null): string {
|
||||
const row = db.prepare(`SELECT timezone t FROM businesses WHERE id = ?`).get(bid) as { t?: string } | undefined;
|
||||
return row?.t || DEFAULT_TZ;
|
||||
}
|
||||
|
||||
/** Clamp the range query param into [7, 120], default 30. */
|
||||
function clampRange(raw: unknown): number {
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n <= 0) return 30;
|
||||
return Math.min(120, Math.max(7, Math.round(n)));
|
||||
}
|
||||
|
||||
dashboardRouter.get("/overview", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const scalar = (sql: string, ...args: any[]) => (db.prepare(sql).get(...args) as any)?.v ?? 0;
|
||||
|
||||
// Business-local windows, expressed as canonical SQLite UTC strings.
|
||||
// created_at is stored in a MIXED format (ISO-Z from seed, space from app default),
|
||||
// so we wrap it with datetime() for correct chronological comparison. start_at is
|
||||
// consistently ISO-Z, so it compares directly against ISO-Z bounds.
|
||||
const todaySql = bizDayBoundsSqlite(tz, 0);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start; // range days ago, biz-local midnight
|
||||
const prevStart = bizDayBoundsSqlite(tz, -range * 2).start; // preceding equal-length window
|
||||
const todayIso = bizDayBoundsIso(tz, 0);
|
||||
const weekStartIso = bizDayBoundsIso(tz, -7).start;
|
||||
const rangeStartIso = bizDayBoundsIso(tz, -range).start;
|
||||
const nowIso = toIsoUtc(new Date());
|
||||
|
||||
const revenue_today = scalar(
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND date(created_at) = date('now')`,
|
||||
bid
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets
|
||||
WHERE business_id = ? AND datetime(created_at) >= ? AND datetime(created_at) <= ?`,
|
||||
bid, todaySql.start, todaySql.end
|
||||
);
|
||||
const revenue_week = scalar(
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-7 days')`,
|
||||
bid
|
||||
);
|
||||
const revenue_month = scalar(
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
|
||||
bid
|
||||
const revenue_range = scalar(
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND datetime(created_at) >= ?`,
|
||||
bid, rangeStart
|
||||
);
|
||||
const revenue_prev = scalar(
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-60 days') AND created_at < datetime('now','-30 days')`,
|
||||
bid
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets
|
||||
WHERE business_id = ? AND datetime(created_at) >= ? AND datetime(created_at) < ?`,
|
||||
bid, prevStart, rangeStart
|
||||
);
|
||||
const revenue_change_pct =
|
||||
revenue_prev > 0 ? Math.round(((revenue_month - revenue_prev) / revenue_prev) * 1000) / 10 : 0;
|
||||
revenue_prev > 0 ? Math.round(((revenue_range - revenue_prev) / revenue_prev) * 1000) / 10 : 0;
|
||||
|
||||
const appts_today = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND date(start_at) = date('now')`,
|
||||
bid
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= ? AND start_at <= ?`,
|
||||
bid, todayIso.start, todayIso.end
|
||||
);
|
||||
const appts_week = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= datetime('now','-7 days')`,
|
||||
bid
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= ?`,
|
||||
bid, weekStartIso
|
||||
);
|
||||
const appts_upcoming = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'scheduled' AND start_at >= datetime('now')`,
|
||||
bid
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'scheduled' AND start_at >= ?`,
|
||||
bid, nowIso
|
||||
);
|
||||
const active_clients = scalar(
|
||||
`SELECT COUNT(DISTINCT client_id) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
|
||||
bid
|
||||
`SELECT COUNT(DISTINCT client_id) v FROM tickets WHERE business_id = ? AND datetime(created_at) >= ?`,
|
||||
bid, bizDayBoundsSqlite(tz, -30).start
|
||||
);
|
||||
const avg_ticket = scalar(
|
||||
`SELECT COALESCE(AVG(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
|
||||
bid
|
||||
`SELECT COALESCE(AVG(amount+tip),0) v FROM tickets WHERE business_id = ? AND datetime(created_at) >= ?`,
|
||||
bid, rangeStart
|
||||
);
|
||||
const total_range = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
|
||||
bid
|
||||
|
||||
// Rates over appointments scheduled (start_at) in the window.
|
||||
const apptWindowTotal = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= ?`,
|
||||
bid, rangeStartIso
|
||||
);
|
||||
const cancels = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status IN ('cancelled','no_show') AND created_at >= datetime('now','-30 days')`,
|
||||
bid
|
||||
const cancelledCount = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'cancelled' AND start_at >= ?`,
|
||||
bid, rangeStartIso
|
||||
);
|
||||
const cancel_rate = total_range > 0 ? Math.round((cancels / total_range) * 1000) / 10 : 0;
|
||||
const completed = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'completed' AND created_at >= datetime('now','-30 days')`,
|
||||
bid
|
||||
const noShowCount = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'no_show' AND start_at >= ?`,
|
||||
bid, rangeStartIso
|
||||
);
|
||||
const occupancy_pct = total_range > 0 ? Math.round((completed / total_range) * 100) : 0;
|
||||
const completedCount = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'completed' AND start_at >= ?`,
|
||||
bid, rangeStartIso
|
||||
);
|
||||
const cancel_rate = cancelRate(cancelledCount, apptWindowTotal);
|
||||
const no_show_rate = noShowRate(noShowCount, apptWindowTotal);
|
||||
const completion_pct = completionRate(completedCount, apptWindowTotal);
|
||||
|
||||
res.json({
|
||||
revenue_today: Math.round(revenue_today * 100) / 100,
|
||||
revenue_week: Math.round(revenue_week * 100) / 100,
|
||||
revenue_month: Math.round(revenue_month * 100) / 100,
|
||||
revenue_range: Math.round(revenue_range * 100) / 100,
|
||||
revenue_prev: Math.round(revenue_prev * 100) / 100,
|
||||
revenue_change_pct,
|
||||
appts_today,
|
||||
appts_week,
|
||||
@@ -76,52 +117,59 @@ dashboardRouter.get("/overview", (req: AuthedRequest, res) => {
|
||||
active_clients,
|
||||
avg_ticket: Math.round(avg_ticket * 100) / 100,
|
||||
cancel_rate,
|
||||
occupancy_pct,
|
||||
no_show_rate,
|
||||
completion_pct,
|
||||
range,
|
||||
});
|
||||
});
|
||||
|
||||
dashboardRouter.get("/best-employees", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const range = (req.query.range as string) || "30";
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const ratingSince = bizDayBoundsSqlite(tz, -90).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT e.id, e.name, e.color, e.role,
|
||||
COUNT(t.id) appts,
|
||||
COALESCE(SUM(t.amount+t.tip),0) revenue,
|
||||
COALESCE((SELECT AVG(rating) FROM reviews WHERE employee_id = e.id), e.rating) avg_rating
|
||||
COALESCE((SELECT AVG(rating) FROM reviews WHERE employee_id = e.id AND datetime(created_at) >= ?), e.rating) avg_rating
|
||||
FROM employees e
|
||||
LEFT JOIN tickets t ON t.employee_id = e.id AND t.created_at >= datetime('now','-${Number(range)} days')
|
||||
LEFT JOIN tickets t ON t.employee_id = e.id AND datetime(t.created_at) >= ?
|
||||
WHERE e.business_id = ?
|
||||
GROUP BY e.id
|
||||
ORDER BY revenue DESC`
|
||||
)
|
||||
.all(bid) as any[];
|
||||
.all(ratingSince, rangeStart, bid) as any[];
|
||||
const totalAppts = rows.reduce((a, r) => a + r.appts, 0);
|
||||
const out = rows.map((r) => ({
|
||||
employee: { id: r.id, name: r.name, color: r.color, role: r.role },
|
||||
appointments: r.appts,
|
||||
revenue: Math.round(r.revenue * 100) / 100,
|
||||
avg_rating: Math.round(r.avg_rating * 10) / 10,
|
||||
utilization_pct: totalAppts > 0 ? Math.round((r.appts / totalAppts) * 100) : 0,
|
||||
share_pct: totalAppts > 0 ? Math.round((r.appts / totalAppts) * 100) : 0,
|
||||
}));
|
||||
res.json({ employees: out });
|
||||
});
|
||||
|
||||
dashboardRouter.get("/best-services", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const range = (req.query.range as string) || "30";
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT s.id, s.name, s.category, s.color,
|
||||
COUNT(t.id) count,
|
||||
COALESCE(SUM(t.amount+t.tip),0) revenue
|
||||
FROM services s
|
||||
LEFT JOIN tickets t ON t.service_id = s.id AND t.created_at >= datetime('now','-${Number(range)} days')
|
||||
LEFT JOIN tickets t ON t.service_id = s.id AND datetime(t.created_at) >= ?
|
||||
WHERE s.business_id = ?
|
||||
GROUP BY s.id
|
||||
ORDER BY revenue DESC`
|
||||
)
|
||||
.all(bid) as any[];
|
||||
.all(rangeStart, bid) as any[];
|
||||
const out = rows.map((r) => ({
|
||||
service: { id: r.id, name: r.name, category: r.category, color: r.color },
|
||||
count: r.count,
|
||||
@@ -133,7 +181,9 @@ dashboardRouter.get("/best-services", (req: AuthedRequest, res) => {
|
||||
dashboardRouter.get("/top-tickets", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const limit = Math.min(50, Number(req.query.limit) || 10);
|
||||
const range = (req.query.range as string) || "30";
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT t.*, s.name service_name, s.category service_category, s.color service_color,
|
||||
@@ -143,11 +193,11 @@ dashboardRouter.get("/top-tickets", (req: AuthedRequest, res) => {
|
||||
JOIN services s ON s.id = t.service_id
|
||||
JOIN employees e ON e.id = t.employee_id
|
||||
JOIN clients c ON c.id = t.client_id
|
||||
WHERE t.business_id = ? AND t.created_at >= datetime('now','-${Number(range)} days')
|
||||
WHERE t.business_id = ? AND datetime(t.created_at) >= ?
|
||||
ORDER BY (t.amount + t.tip) DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(bid, limit) as any[];
|
||||
.all(bid, rangeStart, limit) as any[];
|
||||
const out = rows.map((t) => ({
|
||||
ticket: {
|
||||
id: t.id,
|
||||
@@ -171,7 +221,9 @@ dashboardRouter.get("/top-tickets", (req: AuthedRequest, res) => {
|
||||
dashboardRouter.get("/top-clients", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const limit = Math.min(50, Number(req.query.limit) || 10);
|
||||
const range = (req.query.range as string) || "30";
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT c.id, c.name, c.tags, c.phone,
|
||||
@@ -179,14 +231,14 @@ dashboardRouter.get("/top-clients", (req: AuthedRequest, res) => {
|
||||
COALESCE(SUM(t.amount+t.tip),0) total,
|
||||
MAX(t.created_at) last_visit
|
||||
FROM clients c
|
||||
LEFT JOIN tickets t ON t.client_id = c.id AND t.created_at >= datetime('now','-${Number(range)} days')
|
||||
LEFT JOIN tickets t ON t.client_id = c.id AND datetime(t.created_at) >= ?
|
||||
WHERE c.business_id = ?
|
||||
GROUP BY c.id
|
||||
HAVING visits > 0
|
||||
ORDER BY total DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(bid, limit) as any[];
|
||||
.all(rangeStart, bid, limit) as any[];
|
||||
const out = rows.map((r) => ({
|
||||
client: { id: r.id, name: r.name, tags: r.tags, phone: r.phone },
|
||||
visits: r.visits,
|
||||
@@ -199,7 +251,9 @@ dashboardRouter.get("/top-clients", (req: AuthedRequest, res) => {
|
||||
dashboardRouter.get("/frequent-clients", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const limit = Math.min(50, Number(req.query.limit) || 10);
|
||||
const range = (req.query.range as string) || "30";
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT c.id, c.name, c.tags, c.phone,
|
||||
@@ -207,13 +261,13 @@ dashboardRouter.get("/frequent-clients", (req: AuthedRequest, res) => {
|
||||
COALESCE(SUM(t.amount+t.tip),0) total,
|
||||
MAX(t.created_at) last_visit
|
||||
FROM clients c
|
||||
JOIN tickets t ON t.client_id = c.id AND t.created_at >= datetime('now','-${Number(range)} days')
|
||||
JOIN tickets t ON t.client_id = c.id AND datetime(t.created_at) >= ?
|
||||
WHERE c.business_id = ?
|
||||
GROUP BY c.id
|
||||
ORDER BY visits DESC, total DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(bid, limit) as any[];
|
||||
.all(rangeStart, bid, limit) as any[];
|
||||
const out = rows.map((r) => ({
|
||||
client: { id: r.id, name: r.name, tags: r.tags, phone: r.phone },
|
||||
visits: r.visits,
|
||||
@@ -225,24 +279,35 @@ dashboardRouter.get("/frequent-clients", (req: AuthedRequest, res) => {
|
||||
|
||||
dashboardRouter.get("/revenue-trend", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const days = Math.min(120, Number(req.query.days) || 30);
|
||||
const days = Math.min(120, Math.max(1, Number(req.query.days) || 30));
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -days).start;
|
||||
// Pull raw tickets and bucket by BUSINESS-LOCAL date in JS (SQLite date() would use UTC,
|
||||
// misattributing the evening rush to the next day).
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT date(created_at) date, COALESCE(SUM(amount+tip),0) revenue, COUNT(*) appts
|
||||
FROM tickets
|
||||
WHERE business_id = ? AND created_at >= datetime('now','-${days} days')
|
||||
GROUP BY date(created_at)
|
||||
ORDER BY date ASC`
|
||||
`SELECT created_at, amount, tip FROM tickets
|
||||
WHERE business_id = ? AND datetime(created_at) >= ?`
|
||||
)
|
||||
.all(bid) as any[];
|
||||
// Fill missing days with 0
|
||||
const map = new Map(rows.map((r) => [r.date, r]));
|
||||
.all(bid, rangeStart) as { created_at: string; amount: number; tip: number }[];
|
||||
const map = new Map<string, { revenue: number; appts: number }>();
|
||||
for (const r of rows) {
|
||||
// Stored created_at may be "YYYY-MM-DD HH:MM:SS" (space) or "YYYY-MM-DDTHH:MM:SSZ";
|
||||
// normalize to an ISO UTC instant before converting to the biz date.
|
||||
const iso = typeof r.created_at === "string" && r.created_at.includes("T")
|
||||
? r.created_at
|
||||
: String(r.created_at).replace(" ", "T") + "Z";
|
||||
const key = bizDateISO(new Date(iso), tz);
|
||||
const cur = map.get(key) ?? { revenue: 0, appts: 0 };
|
||||
cur.revenue += Number(r.amount) + Number(r.tip);
|
||||
cur.appts += 1;
|
||||
map.set(key, cur);
|
||||
}
|
||||
const out: { date: string; revenue: number; appts: number }[] = [];
|
||||
const today = new Date();
|
||||
const now = new Date();
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
const d = new Date(now.getTime() - i * 86400000);
|
||||
const key = bizDateISO(d, tz);
|
||||
const row = map.get(key);
|
||||
out.push({
|
||||
date: key,
|
||||
@@ -255,17 +320,19 @@ dashboardRouter.get("/revenue-trend", (req: AuthedRequest, res) => {
|
||||
|
||||
dashboardRouter.get("/revenue-by-category", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const range = (req.query.range as string) || "30";
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT s.category, COUNT(t.id) count, COALESCE(SUM(t.amount+t.tip),0) revenue
|
||||
FROM tickets t
|
||||
JOIN services s ON s.id = t.service_id
|
||||
WHERE t.business_id = ? AND t.created_at >= datetime('now','-${Number(range)} days')
|
||||
WHERE t.business_id = ? AND datetime(t.created_at) >= ?
|
||||
GROUP BY s.category
|
||||
ORDER BY revenue DESC`
|
||||
)
|
||||
.all(bid) as any[];
|
||||
.all(bid, rangeStart) as any[];
|
||||
const out = rows.map((r) => ({
|
||||
category: r.category,
|
||||
count: r.count,
|
||||
@@ -312,7 +379,9 @@ dashboardRouter.get("/tickets", (req: AuthedRequest, res) => {
|
||||
|
||||
dashboardRouter.get("/commissions", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const range = (req.query.range as string) || "30";
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT e.id, e.name, e.color, e.role,
|
||||
@@ -320,12 +389,12 @@ dashboardRouter.get("/commissions", (req: AuthedRequest, res) => {
|
||||
COALESCE(SUM(t.amount + t.tip), 0) revenue,
|
||||
COALESCE(SUM(t.commission), 0) commission
|
||||
FROM employees e
|
||||
LEFT JOIN tickets t ON t.employee_id = e.id AND t.created_at >= datetime('now','-${Number(range)} days')
|
||||
LEFT JOIN tickets t ON t.employee_id = e.id AND datetime(t.created_at) >= ?
|
||||
WHERE e.business_id = ?
|
||||
GROUP BY e.id
|
||||
ORDER BY commission DESC`
|
||||
)
|
||||
.all(bid) as any[];
|
||||
.all(rangeStart, bid) as any[];
|
||||
const out = rows.map((r) => ({
|
||||
employee: { id: r.id, name: r.name, color: r.color, role: r.role },
|
||||
sales: r.sales,
|
||||
|
||||
+28
-12
@@ -6,6 +6,13 @@ import type { EmployeeStats } from "../../shared/types.ts";
|
||||
|
||||
export const employeesRouter = Router();
|
||||
|
||||
// commission_pct is sensitive (drives payouts) — only owner/admin may read it.
|
||||
const EMP_PUBLIC_COLS =
|
||||
"id, business_id, name, email, phone, color, role, active, rating, hire_date, created_at, specialties, working_hours, efficiency_score";
|
||||
function empCols(req: AuthedRequest): string {
|
||||
return req.user!.role === "owner" || req.user!.role === "admin" ? `${EMP_PUBLIC_COLS}, commission_pct` : EMP_PUBLIC_COLS;
|
||||
}
|
||||
|
||||
function attachServices(emp: any) {
|
||||
const ids = db
|
||||
.prepare(`SELECT service_id FROM employee_services WHERE employee_id = ?`)
|
||||
@@ -45,13 +52,13 @@ function computeStats(employeeId: number, businessId: number): EmployeeStats {
|
||||
appointments_completed: completed.c,
|
||||
revenue_total: completed.s,
|
||||
avg_rating: Math.round((rating.r ?? 5) * 10) / 10,
|
||||
utilization_pct: utilization,
|
||||
share_pct: utilization,
|
||||
};
|
||||
}
|
||||
|
||||
employeesRouter.get("/", (req: AuthedRequest, res) => {
|
||||
const rows = db
|
||||
.prepare(`SELECT * FROM employees WHERE business_id = ? ORDER BY active DESC, name`)
|
||||
.prepare(`SELECT ${empCols(req)} FROM employees WHERE business_id = ? ORDER BY active DESC, name`)
|
||||
.all(req.user!.business_id) as any[];
|
||||
const out = rows.map((r) => {
|
||||
attachServices(r);
|
||||
@@ -62,7 +69,7 @@ employeesRouter.get("/", (req: AuthedRequest, res) => {
|
||||
});
|
||||
|
||||
employeesRouter.get("/:id", (req: AuthedRequest, res) => {
|
||||
const emp = db.prepare(`SELECT * FROM employees WHERE id = ? AND business_id = ?`).get(
|
||||
const emp = db.prepare(`SELECT ${empCols(req)} FROM employees WHERE id = ? AND business_id = ?`).get(
|
||||
Number(req.params.id),
|
||||
req.user!.business_id
|
||||
) as any;
|
||||
@@ -73,11 +80,12 @@ employeesRouter.get("/:id", (req: AuthedRequest, res) => {
|
||||
});
|
||||
|
||||
employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
|
||||
const { name, role, email, phone, color, service_ids } = req.body ?? {};
|
||||
const { name, role, email, phone, color, service_ids, specialties, working_hours, efficiency_score } = req.body ?? {};
|
||||
if (!name) return err(res, 400, "El nombre es obligatorio");
|
||||
const r = db
|
||||
.prepare(
|
||||
`INSERT INTO employees (business_id, name, role, email, phone, color) VALUES (?, ?, ?, ?, ?, ?) RETURNING *`
|
||||
`INSERT INTO employees (business_id, name, role, email, phone, color, specialties, working_hours, efficiency_score)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
|
||||
)
|
||||
.get(
|
||||
req.user!.business_id,
|
||||
@@ -85,7 +93,10 @@ employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
|
||||
role || "Especialista",
|
||||
email || null,
|
||||
phone || null,
|
||||
color || "#3b66ff"
|
||||
color || "#3b66ff",
|
||||
JSON.stringify(Array.isArray(specialties) ? specialties : []),
|
||||
working_hours ? (typeof working_hours === "string" ? working_hours : JSON.stringify(working_hours)) : null,
|
||||
typeof efficiency_score === "number" ? efficiency_score : 50
|
||||
) as any;
|
||||
if (Array.isArray(service_ids)) {
|
||||
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
|
||||
@@ -98,11 +109,9 @@ employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
|
||||
|
||||
employeesRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db
|
||||
.prepare(`SELECT * FROM employees WHERE id = ? AND business_id = ?`)
|
||||
.get(id, req.user!.business_id as number) as any;
|
||||
const existing = db.prepare(`SELECT * FROM employees WHERE id = ? AND business_id = ?`).get(id, req.user!.business_id as number) as any;
|
||||
if (!existing) return err(res, 404, "Empleado no encontrado");
|
||||
const { name, role, email, phone, color, active, service_ids } = req.body ?? {};
|
||||
const { name, role, email, phone, color, active, service_ids, specialties, working_hours, efficiency_score } = req.body ?? {};
|
||||
const merged = {
|
||||
name: name ?? existing.name,
|
||||
role: role ?? existing.role,
|
||||
@@ -110,10 +119,17 @@ employeesRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
|
||||
phone: phone ?? existing.phone,
|
||||
color: color ?? existing.color,
|
||||
active: active === undefined ? existing.active : active ? 1 : 0,
|
||||
specialties: specialties !== undefined
|
||||
? (typeof specialties === "string" ? specialties : JSON.stringify(Array.isArray(specialties) ? specialties : []))
|
||||
: existing.specialties,
|
||||
working_hours: working_hours !== undefined
|
||||
? (working_hours === null ? null : typeof working_hours === "string" ? working_hours : JSON.stringify(working_hours))
|
||||
: existing.working_hours,
|
||||
efficiency_score: efficiency_score !== undefined ? Number(efficiency_score) : (existing.efficiency_score ?? 50),
|
||||
};
|
||||
db.prepare(
|
||||
`UPDATE employees SET name = ?, role = ?, email = ?, phone = ?, color = ?, active = ? WHERE id = ?`
|
||||
).run(merged.name, merged.role, merged.email, merged.phone, merged.color, merged.active, id);
|
||||
`UPDATE employees SET name = ?, role = ?, email = ?, phone = ?, color = ?, active = ?, specialties = ?, working_hours = ?, efficiency_score = ? WHERE id = ?`
|
||||
).run(merged.name, merged.role, merged.email, merged.phone, merged.color, merged.active, merged.specialties, merged.working_hours, merged.efficiency_score, id);
|
||||
if (Array.isArray(service_ids)) {
|
||||
db.prepare(`DELETE FROM employee_services WHERE employee_id = ?`).run(id);
|
||||
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Router } from "express";
|
||||
import { db } from "../db.ts";
|
||||
import type { AuthedRequest } from "../lib/auth.ts";
|
||||
import { authRequired } from "../lib/auth.ts";
|
||||
import { bizDayBoundsSqlite, bizDayBoundsIso, toIsoUtc } from "../lib/time.ts";
|
||||
|
||||
export const meRouter = Router();
|
||||
meRouter.use(authRequired);
|
||||
|
||||
const DEFAULT_TZ = "America/Mexico_City";
|
||||
|
||||
/** Fetch the business timezone once per handler. */
|
||||
function bizTz(bid: number | null): string {
|
||||
const row = db.prepare(`SELECT timezone t FROM businesses WHERE id = ?`).get(bid) as { t?: string } | undefined;
|
||||
return row?.t || DEFAULT_TZ;
|
||||
}
|
||||
|
||||
const EMPTY_PERF = {
|
||||
appointments_today: 0,
|
||||
upcoming: 0,
|
||||
revenue_30d: 0,
|
||||
avg_rating: 0,
|
||||
share_pct: 0,
|
||||
next_appointment: null,
|
||||
};
|
||||
|
||||
/** Employee self-service analytics, tz-correct via the business timezone. */
|
||||
meRouter.get("/performance", (req: AuthedRequest, res) => {
|
||||
const eid = req.user!.employee_id;
|
||||
const bid = req.user!.business_id;
|
||||
if (!eid) return res.json(EMPTY_PERF);
|
||||
|
||||
const tz = bizTz(bid);
|
||||
// start_at is stored ISO-Z → pair with ISO bounds.
|
||||
const todayIso = bizDayBoundsIso(tz, 0);
|
||||
const rangeStartIso = bizDayBoundsIso(tz, -30).start;
|
||||
const nowIso = toIsoUtc(new Date());
|
||||
// created_at is stored in a mixed format → wrap with datetime() and use SQLite bounds.
|
||||
const rangeStartSql = bizDayBoundsSqlite(tz, -30).start;
|
||||
const ratingStartSql = bizDayBoundsSqlite(tz, -90).start;
|
||||
|
||||
const scalar = (sql: string, ...args: any[]) => (db.prepare(sql).get(...args) as any)?.v ?? 0;
|
||||
|
||||
const appointments_today = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments
|
||||
WHERE employee_id = ? AND start_at >= ? AND start_at <= ? AND status != 'cancelled'`,
|
||||
eid, todayIso.start, todayIso.end
|
||||
);
|
||||
const upcoming = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments
|
||||
WHERE employee_id = ? AND status = 'scheduled' AND start_at >= ?`,
|
||||
eid, nowIso
|
||||
);
|
||||
const revenue_30d = scalar(
|
||||
`SELECT COALESCE(SUM(amount + tip), 0) v FROM tickets
|
||||
WHERE employee_id = ? AND datetime(created_at) >= ?`,
|
||||
eid, rangeStartSql
|
||||
);
|
||||
// Avg of last-90d reviews, falling back to the employee's stored rating.
|
||||
const avg_rating = scalar(
|
||||
`SELECT COALESCE(
|
||||
(SELECT AVG(rating) FROM reviews WHERE employee_id = ? AND datetime(created_at) >= ?),
|
||||
(SELECT rating FROM employees WHERE id = ?)
|
||||
) v`,
|
||||
eid, ratingStartSql, eid
|
||||
);
|
||||
const empAppts = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE employee_id = ? AND start_at >= ?`,
|
||||
eid, rangeStartIso
|
||||
);
|
||||
const bizAppts = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= ?`,
|
||||
bid, rangeStartIso
|
||||
);
|
||||
const share_pct = bizAppts > 0 ? Math.round((empAppts / bizAppts) * 100) : 0;
|
||||
|
||||
const next = db
|
||||
.prepare(
|
||||
`SELECT a.start_at, s.name service_name, c.name client_name
|
||||
FROM appointments a
|
||||
JOIN services s ON s.id = a.service_id
|
||||
JOIN clients c ON c.id = a.client_id
|
||||
WHERE a.employee_id = ? AND a.status = 'scheduled' AND a.start_at >= ?
|
||||
ORDER BY a.start_at ASC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(eid, nowIso) as { start_at: string; service_name: string; client_name: string } | undefined;
|
||||
|
||||
res.json({
|
||||
appointments_today,
|
||||
upcoming,
|
||||
revenue_30d: Math.round(revenue_30d * 100) / 100,
|
||||
avg_rating: Math.round((avg_rating ?? 0) * 10) / 10,
|
||||
share_pct,
|
||||
next_appointment: next
|
||||
? { start_at: next.start_at, service_name: next.service_name, client_name: next.client_name }
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
||||
/** Per-ticket commissions for the signed-in employee, last 30 business-local days. */
|
||||
meRouter.get("/commissions", (req: AuthedRequest, res) => {
|
||||
const eid = req.user!.employee_id;
|
||||
if (!eid) return res.json({ rows: [], total: 0 });
|
||||
|
||||
const bid = req.user!.business_id;
|
||||
const tz = bizTz(bid);
|
||||
const rangeStartSql = bizDayBoundsSqlite(tz, -30).start;
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT t.appointment_id, s.name service_name, c.name client_name,
|
||||
t.amount, t.commission, t.created_at
|
||||
FROM tickets t
|
||||
JOIN services s ON s.id = t.service_id
|
||||
JOIN clients c ON c.id = t.client_id
|
||||
WHERE t.employee_id = ? AND datetime(t.created_at) >= ?
|
||||
ORDER BY datetime(t.created_at) DESC
|
||||
LIMIT 100`
|
||||
)
|
||||
.all(eid, rangeStartSql) as any[];
|
||||
|
||||
const out = rows.map((r) => ({
|
||||
appointment_id: r.appointment_id,
|
||||
service_name: r.service_name,
|
||||
client_name: r.client_name,
|
||||
amount: Math.round(Number(r.amount) * 100) / 100,
|
||||
commission: Math.round(Number(r.commission) * 100) / 100,
|
||||
created_at: r.created_at,
|
||||
}));
|
||||
const total = Math.round(out.reduce((a, r) => a + r.commission, 0) * 100) / 100;
|
||||
|
||||
res.json({ rows: out, total });
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { Router } from "express";
|
||||
import { db } from "../db.ts";
|
||||
import type { AuthedRequest } from "../lib/auth.ts";
|
||||
import { err } from "../lib/auth.ts";
|
||||
import { bizDayBoundsSqlite } from "../lib/time.ts";
|
||||
|
||||
export const notificationsRouter = Router();
|
||||
|
||||
@@ -30,7 +31,10 @@ function buildMessage(biz: any, appt: any, kind: string): string {
|
||||
export function scheduleReminders(businessId: number) {
|
||||
const biz = db.prepare(`SELECT * FROM businesses WHERE id = ?`).get(businessId) as any;
|
||||
if (!biz) return;
|
||||
// upcoming scheduled appointments in next 7 days
|
||||
// upcoming scheduled appointments in next 7 days (UTC instants — the window is
|
||||
// instant-relative, so we bind clean UTC values instead of SQLite's date('now')).
|
||||
const nowUtc = new Date().toISOString().slice(0, 19) + "Z";
|
||||
const plus7Utc = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19) + "Z";
|
||||
const appts = db
|
||||
.prepare(
|
||||
`SELECT a.id, a.start_at, a.status, a.client_id, c.name client_name, c.phone, c.email,
|
||||
@@ -40,10 +44,10 @@ export function scheduleReminders(businessId: number) {
|
||||
JOIN services s ON s.id = a.service_id
|
||||
LEFT JOIN employees e ON e.id = a.employee_id
|
||||
WHERE a.business_id = ? AND a.status = 'scheduled'
|
||||
AND a.start_at >= datetime('now') AND a.start_at <= datetime('now','+7 days')
|
||||
AND a.start_at >= ? AND a.start_at <= ?
|
||||
ORDER BY a.start_at ASC`
|
||||
)
|
||||
.all(businessId) as any[];
|
||||
.all(businessId, nowUtc, plus7Utc) as any[];
|
||||
// delete pending reminders that no longer have a matching upcoming scheduled appt
|
||||
db.prepare(
|
||||
`DELETE FROM notifications WHERE business_id = ? AND status = 'pending' AND kind IN ('reminder','confirmation')`
|
||||
@@ -133,7 +137,12 @@ notificationsRouter.post("/:id/cancel", (req: AuthedRequest, res) => {
|
||||
|
||||
notificationsRouter.get("/stats", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id as number;
|
||||
const scalar = (sql: string) => (db.prepare(sql).get(bid) as any)?.v ?? 0;
|
||||
const tz =
|
||||
(db.prepare(`SELECT timezone t FROM businesses WHERE id = ?`).get(bid) as { t?: string } | undefined)?.t ||
|
||||
"America/Mexico_City";
|
||||
// tz-correct 30-day window (business-local). created_at is mixed-format, so wrap with datetime().
|
||||
const since30 = bizDayBoundsSqlite(tz, -30).start;
|
||||
const scalar = (sql: string, ...args: any[]) => (db.prepare(sql).get(bid, ...args) as any)?.v ?? 0;
|
||||
res.json({
|
||||
pending: scalar(`SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='pending'`),
|
||||
sent: scalar(`SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='sent'`),
|
||||
@@ -141,7 +150,9 @@ notificationsRouter.get("/stats", (req: AuthedRequest, res) => {
|
||||
`SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='pending' AND kind='reminder' AND send_at >= datetime('now')`
|
||||
),
|
||||
no_show_rate: scalar(
|
||||
`SELECT COALESCE(ROUND(100.0 * SUM(CASE WHEN status='no_show' THEN 1 ELSE 0 END) / NULLIF(COUNT(*),0),1),0) v FROM appointments WHERE business_id = ? AND created_at >= datetime('now','-30 days')`
|
||||
`SELECT COALESCE(ROUND(100.0 * SUM(CASE WHEN status='no_show' THEN 1 ELSE 0 END) / NULLIF(COUNT(*),0),1),0) v
|
||||
FROM appointments WHERE business_id = ? AND datetime(created_at) >= ?`,
|
||||
since30
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,13 +17,16 @@ const FIELDS = [
|
||||
"require_deposit",
|
||||
"deposit_pct",
|
||||
"timezone",
|
||||
"auto_assign_specialist",
|
||||
"working_hours",
|
||||
] as const;
|
||||
|
||||
settingsRouter.get("/", (req: AuthedRequest, res) => {
|
||||
const b = db
|
||||
.prepare(
|
||||
`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, booking_enabled,
|
||||
cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone
|
||||
cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone,
|
||||
auto_assign_specialist, working_hours
|
||||
FROM businesses WHERE id = ?`
|
||||
)
|
||||
.get(req.user!.business_id) as any;
|
||||
@@ -39,6 +42,12 @@ settingsRouter.patch("/", ownerOnly, (req: AuthedRequest, res) => {
|
||||
for (const f of FIELDS) {
|
||||
if (body[f] !== undefined) merged[f] = body[f];
|
||||
}
|
||||
if (merged.working_hours && typeof merged.working_hours !== "string") {
|
||||
merged.working_hours = JSON.stringify(merged.working_hours);
|
||||
}
|
||||
if (merged.auto_assign_specialist !== undefined) {
|
||||
merged.auto_assign_specialist = merged.auto_assign_specialist ? 1 : 0;
|
||||
}
|
||||
// slug uniqueness check
|
||||
if (merged.slug !== undefined) {
|
||||
merged.slug = String(merged.slug).trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
||||
@@ -51,7 +60,7 @@ settingsRouter.patch("/", ownerOnly, (req: AuthedRequest, res) => {
|
||||
merged.id = req.user!.business_id;
|
||||
db.prepare(`UPDATE businesses SET ${sets.join(", ")} WHERE id = @id`).run(merged);
|
||||
const updated = db
|
||||
.prepare(`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone FROM businesses WHERE id = ?`)
|
||||
.prepare(`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone, auto_assign_specialist, working_hours FROM businesses WHERE id = ?`)
|
||||
.get(req.user!.business_id);
|
||||
res.json({ settings: updated });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// server/scripts/booking-e2e.mjs — requiere el server dev corriendo (npm run dev)
|
||||
const BASE = "http://localhost:5173/api";
|
||||
let pass = 0, fail = 0;
|
||||
async function req(method, path, body, token) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (token) headers.authorization = `Bearer ${token}`;
|
||||
const res = await fetch(`${BASE}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const text = await res.text();
|
||||
let json; try { json = JSON.parse(text); } catch { json = text; }
|
||||
return { status: res.status, json };
|
||||
}
|
||||
function check(name, cond, extra = "") {
|
||||
if (cond) { pass++; console.log(` \u2713 ${name}`); }
|
||||
else { fail++; console.log(` \u2717 ${name} ${extra}`); }
|
||||
}
|
||||
|
||||
const { json: login } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
|
||||
const t = login.token;
|
||||
check("login owner", !!t);
|
||||
|
||||
// 1) Slug del negocio
|
||||
const { json: settings } = await req("GET", "/settings", null, t);
|
||||
const slug = settings.settings?.slug;
|
||||
check("tiene slug", !!slug, String(slug));
|
||||
|
||||
// 2) Datos públicos + servicios
|
||||
const { json: pub } = await req("GET", `/public/${slug}`);
|
||||
check("public business", !!pub.business && Array.isArray(pub.services) && pub.services.length > 0);
|
||||
const svc = pub.services[0];
|
||||
const tomorrow = new Date(Date.now() + 86400000);
|
||||
const date = tomorrow.toISOString().slice(0, 10);
|
||||
|
||||
// 3) Slots disponibles
|
||||
const { json: slotsRes } = await req("GET", `/public/${slug}/slots?service_id=${svc.id}&date=${date}`);
|
||||
check("slots devuelve lista", Array.isArray(slotsRes.slots));
|
||||
check("hay slots disponibles hoy/mañana", slotsRes.slots.length > 0, `got ${slotsRes.slots.length}`);
|
||||
|
||||
// 4) Reservar (auto-asignación, sin employee_id)
|
||||
const slot = slotsRes.slots[0];
|
||||
const { status: bookStatus, json: booked } = await req("POST", `/public/${slug}/book`, {
|
||||
service_id: svc.id,
|
||||
start_at: slot.iso,
|
||||
client: { name: "Cliente Prueba Auto", phone: "+525500000001" },
|
||||
});
|
||||
check("reserva creada (auto-assign)", bookStatus === 201 && !!booked.appointment?.employee_id, `status=${bookStatus}`);
|
||||
check("response trae reasons", Array.isArray(booked.appointment?.reasons) && booked.appointment.reasons.length > 0);
|
||||
check("response trae no_show_count", typeof booked.no_show_count === "number", `got ${typeof booked.no_show_count}`);
|
||||
check("response trae risk_flag", typeof booked.risk_flag === "boolean", `got ${typeof booked.risk_flag}`);
|
||||
check("cliente nuevo → sin riesgo", booked.no_show_count === 0 && booked.risk_flag === false, `ns=${booked.no_show_count} risk=${booked.risk_flag}`);
|
||||
|
||||
// 5) Mismo slot otra vez → 409 (doble reserva del mismo especialista)
|
||||
const { status: conflict } = await req("POST", `/public/${slug}/book`, {
|
||||
service_id: svc.id,
|
||||
employee_id: booked.appointment.employee_id,
|
||||
start_at: slot.iso,
|
||||
client: { name: "Cliente Conflictivo", phone: "+525500000002" },
|
||||
});
|
||||
check("doble reserva → 409", conflict === 409, `status=${conflict}`);
|
||||
|
||||
// 6) Activar auto_assign_specialist y verificar que la respuesta pública lo expone
|
||||
await req("PATCH", "/settings", { auto_assign_specialist: 1 }, t);
|
||||
const { json: pub2 } = await req("GET", `/public/${slug}`);
|
||||
check("auto_assign_specialist expuesto en público", pub2.business?.auto_assign_specialist === 1);
|
||||
await req("PATCH", "/settings", { auto_assign_specialist: 0 }, t); // cleanup
|
||||
|
||||
console.log(`\n${pass}/${pass + fail} passed${fail ? `, ${fail} FAILED` : ""}`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
+21
-2
@@ -204,14 +204,14 @@ export function seedBusiness(opts: SeedBusinessOptions) {
|
||||
let pastCount = 0;
|
||||
let upCount = 0;
|
||||
|
||||
const makeAppt = (dayOffset: number, svcIdx: number, status: string, createdBy: number | null) => {
|
||||
const makeAppt = (dayOffset: number, svcIdx: number, status: string, createdBy: number | null, fixedHour?: number) => {
|
||||
const svc = template.services[svcIdx];
|
||||
if (!svc) return null;
|
||||
const empLocalIdx = pick(svc.employees);
|
||||
const empId = empIds[empLocalIdx];
|
||||
const svcId = svcIds[svcIdx];
|
||||
const clientId = pick(clientIds);
|
||||
const hour = pick([9, 10, 11, 12, 13, 14, 15, 16, 17, 18]);
|
||||
const hour = fixedHour ?? pick([9, 10, 11, 12, 13, 14, 15, 16, 17, 18]);
|
||||
const minute = pick([0, 30]);
|
||||
const start = isoOffsetDays(dayOffset, hour, minute);
|
||||
const end = addMinutes(start, svc.duration_min);
|
||||
@@ -273,6 +273,25 @@ export function seedBusiness(opts: SeedBusinessOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
// Guarantee visible appointments across the current week (Mon–Sat) regardless of today's weekday
|
||||
{
|
||||
const now = new Date();
|
||||
const dow = now.getDay(); // 0=Sun ... 6=Sat
|
||||
const mondayOffset = dow === 0 ? -6 : 1 - dow;
|
||||
for (let d = 0; d < 6; d++) {
|
||||
const dayOffset = mondayOffset + d;
|
||||
if (dayOffset < -60) continue;
|
||||
const past = dayOffset < 0;
|
||||
const isToday_ = dayOffset === 0;
|
||||
const hours = pickN([10, 12, 14, 16, 18], rint(2, 4));
|
||||
for (const hour of hours) {
|
||||
const svcIdx = rint(0, template.services.length - 1);
|
||||
const status = past ? "completed" : (isToday_ && hour <= now.getHours()) ? "completed" : "scheduled";
|
||||
if (makeAppt(dayOffset, svcIdx, status, ownerRow?.id ?? null, hour)) upCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recompute ratings
|
||||
db.exec(`
|
||||
UPDATE employees SET rating = COALESCE(
|
||||
|
||||
+38
-5
@@ -14,9 +14,20 @@ export interface Business {
|
||||
status?: string;
|
||||
template?: string | null;
|
||||
trial_ends_at?: string | null;
|
||||
booking_enabled?: number | boolean;
|
||||
cancel_window_hours?: number;
|
||||
cancel_penalty_pct?: number;
|
||||
require_deposit?: number | boolean;
|
||||
deposit_pct?: number;
|
||||
timezone?: string;
|
||||
auto_assign_specialist?: number | boolean;
|
||||
working_hours?: WorkingHoursMap | string | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export type WorkingDay = { start: string; end: string };
|
||||
export type WorkingHoursMap = Record<number, WorkingDay | null>; // 1=Lun … 7=Dom
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
business_id: number | null;
|
||||
@@ -39,6 +50,9 @@ export interface Employee {
|
||||
rating: number;
|
||||
hire_date: string | null;
|
||||
service_ids?: number[];
|
||||
specialties?: string[];
|
||||
working_hours?: WorkingHoursMap | string | null;
|
||||
efficiency_score?: number;
|
||||
stats?: EmployeeStats;
|
||||
}
|
||||
|
||||
@@ -47,7 +61,7 @@ export interface EmployeeStats {
|
||||
appointments_completed: number;
|
||||
revenue_total: number;
|
||||
avg_rating: number;
|
||||
utilization_pct: number;
|
||||
share_pct: number;
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
@@ -121,8 +135,8 @@ export interface Ticket {
|
||||
|
||||
export interface DashboardOverview {
|
||||
revenue_today: number;
|
||||
revenue_week: number;
|
||||
revenue_month: number;
|
||||
revenue_range: number;
|
||||
revenue_prev: number;
|
||||
revenue_change_pct: number;
|
||||
appts_today: number;
|
||||
appts_week: number;
|
||||
@@ -130,7 +144,9 @@ export interface DashboardOverview {
|
||||
active_clients: number;
|
||||
avg_ticket: number;
|
||||
cancel_rate: number;
|
||||
occupancy_pct: number;
|
||||
no_show_rate: number;
|
||||
completion_pct: number;
|
||||
range: number;
|
||||
}
|
||||
|
||||
export interface RankedEmployee {
|
||||
@@ -138,7 +154,7 @@ export interface RankedEmployee {
|
||||
appointments: number;
|
||||
revenue: number;
|
||||
avg_rating: number;
|
||||
utilization_pct: number;
|
||||
share_pct: number;
|
||||
}
|
||||
|
||||
export interface RankedService {
|
||||
@@ -169,3 +185,20 @@ export interface CategorySlice {
|
||||
revenue: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface BookResponse {
|
||||
appointment: {
|
||||
id: number;
|
||||
start_at: string;
|
||||
end_at?: string;
|
||||
price: number;
|
||||
service_name: string;
|
||||
employee_id: number;
|
||||
employee_name: string;
|
||||
reasons: string[];
|
||||
};
|
||||
business: { name: string; currency_symbol: string };
|
||||
client_created: boolean;
|
||||
no_show_count?: number;
|
||||
risk_flag?: boolean;
|
||||
}
|
||||
|
||||
+3
-1
@@ -13,6 +13,7 @@ import { ClientDetailPage } from "./pages/ClientDetailPage";
|
||||
import { CashPage } from "./pages/CashPage";
|
||||
import { NotificationsPage } from "./pages/NotificationsPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { MyPerformancePage } from "./pages/MyPerformancePage";
|
||||
import { AdminOverviewPage } from "./pages/admin/AdminOverviewPage";
|
||||
import { AdminBusinessesPage } from "./pages/admin/AdminBusinessesPage";
|
||||
import { AdminBusinessDetailPage } from "./pages/admin/AdminBusinessDetailPage";
|
||||
@@ -25,7 +26,7 @@ function Protected() {
|
||||
<div className="flex h-screen items-center justify-center bg-[#f6f7fb]">
|
||||
<div className="flex flex-col items-center gap-3 text-slate-500">
|
||||
<div className="h-9 w-9 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
||||
<span className="text-sm font-medium">Cargando AgendaPro…</span>
|
||||
<span className="text-sm font-medium">Cargando AgendaMax…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -59,6 +60,7 @@ function Protected() {
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/clients" element={<ClientsPage />} />
|
||||
<Route path="/clients/:id" element={<ClientDetailPage />} />
|
||||
<Route path="/me" element={<MyPerformancePage />} />
|
||||
{user.role === "owner" && <Route path="/employees" element={<EmployeesPage />} />}
|
||||
{user.role === "owner" && <Route path="/services" element={<ServicesPage />} />}
|
||||
{user.role === "owner" && <Route path="/tickets" element={<TicketsPage />} />}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function AdminShell() {
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaPro</div>
|
||||
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaMax</div>
|
||||
<div className="text-[11px] font-medium text-slate-500">Consola Admin</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Wallet,
|
||||
Bell,
|
||||
Settings,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { initials, cn } from "../lib/format";
|
||||
@@ -24,12 +25,16 @@ interface NavItem {
|
||||
label: string;
|
||||
icon: typeof CalendarDays;
|
||||
ownerOnly?: boolean;
|
||||
employeeOnly?: boolean;
|
||||
}
|
||||
|
||||
const DEMO = import.meta.env.DEV || import.meta.env.VITE_DEMO_UI === "1";
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ to: "/dashboard", label: "Tablero", icon: LayoutDashboard, ownerOnly: true },
|
||||
{ to: "/calendar", label: "Calendario", icon: CalendarDays },
|
||||
{ to: "/clients", label: "Clientes", icon: Users },
|
||||
{ to: "/me", label: "Mi desempeño", icon: BarChart3, employeeOnly: true },
|
||||
{ to: "/employees", label: "Empleados", icon: UserCircle, ownerOnly: true },
|
||||
{ to: "/services", label: "Servicios", icon: Scissors, ownerOnly: true },
|
||||
{ to: "/cash", label: "Caja", icon: Wallet, ownerOnly: true },
|
||||
@@ -45,7 +50,11 @@ export function AppShell() {
|
||||
|
||||
if (!user) return null;
|
||||
const isOwner = user.role === "owner";
|
||||
const items = NAV.filter((n) => !n.ownerOnly || isOwner);
|
||||
const items = NAV.filter((n) => {
|
||||
if (n.ownerOnly) return isOwner;
|
||||
if (n.employeeOnly) return user.role === "employee" || (isOwner && !!user.employee_id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const SidebarContent = (
|
||||
<>
|
||||
@@ -54,7 +63,7 @@ export function AppShell() {
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaPro</div>
|
||||
<div className="text-sm font-extrabold tracking-tight text-slate-900">AgendaMax</div>
|
||||
<div className="text-[11px] font-medium text-slate-500">{business?.name ?? "Tu negocio"}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,9 +96,11 @@ export function AppShell() {
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto space-y-2 px-2 pb-3">
|
||||
{DEMO && (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-2.5">
|
||||
<DemoSwitcher />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 rounded-xl p-2">
|
||||
<div
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
|
||||
@@ -151,7 +162,7 @@ export function AppShell() {
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-brand-500 to-brand-700 text-white">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm font-extrabold">AgendaPro</span>
|
||||
<span className="text-sm font-extrabold">AgendaMax</span>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
Clock,
|
||||
DollarSign,
|
||||
Stethoscope,
|
||||
Phone,
|
||||
Mail,
|
||||
Tag,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
@@ -32,6 +36,13 @@ const TIME_SUGGESTIONS = [
|
||||
"16:00","16:30","17:00","17:30","18:00","18:30","19:00","19:30","20:00",
|
||||
];
|
||||
|
||||
// joinAppointment attaches notes + no_show_count directly on the client object,
|
||||
// but shared/types Appointment.client Pick omits them — augment locally.
|
||||
type ApptClientMeta = NonNullable<Appointment["client"]> & {
|
||||
notes?: string | null;
|
||||
no_show_count?: number;
|
||||
};
|
||||
|
||||
function toLocalInput(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return { date: "", time: "10:00" };
|
||||
@@ -51,9 +62,18 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
const [serviceId, setServiceId] = useState<number | null>(null);
|
||||
const [employeeId, setEmployeeId] = useState<number | null>(user?.employee_id ?? null);
|
||||
const [clientId, setClientId] = useState<number | null>(null);
|
||||
const [pickedClientName, setPickedClientName] = useState<string>("");
|
||||
const [pickedClientPhone, setPickedClientPhone] = useState<string>("");
|
||||
const [pickedClientEmail, setPickedClientEmail] = useState<string>("");
|
||||
const [pickedClientTags, setPickedClientTags] = useState<string>("");
|
||||
const [pickedClientNotes, setPickedClientNotes] = useState<string>("");
|
||||
const [pickedClientNoShows, setPickedClientNoShows] = useState<number>(0);
|
||||
const [clientMode, setClientMode] = useState<"existing" | "new">("existing");
|
||||
const [newClient, setNewClient] = useState({ name: "", phone: "", email: "", notes: "" });
|
||||
const [search, setSearch] = useState("");
|
||||
// When editing, hide the picker behind an explicit "Cambiar cliente" action so the
|
||||
// current client is always visible — never a stray "?" or empty search field.
|
||||
const [changingClient, setChangingClient] = useState(false);
|
||||
const [dateInput, setDateInput] = useState("");
|
||||
const [timeInput, setTimeInput] = useState("10:00");
|
||||
const [duration, setDuration] = useState(60);
|
||||
@@ -88,7 +108,15 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
setServiceId(appointment.service_id);
|
||||
setEmployeeId(appointment.employee_id);
|
||||
setClientId(appointment.client_id);
|
||||
setPickedClientName(appointment.client?.name ?? "");
|
||||
setPickedClientPhone(appointment.client?.phone ?? "");
|
||||
setPickedClientEmail(appointment.client?.email ?? "");
|
||||
setPickedClientTags(appointment.client?.tags ?? "");
|
||||
const ac = appointment.client as ApptClientMeta | undefined;
|
||||
setPickedClientNotes(ac?.notes ?? "");
|
||||
setPickedClientNoShows(Number(ac?.no_show_count ?? 0));
|
||||
setClientMode("existing");
|
||||
setChangingClient(false);
|
||||
const d = toLocalInput(appointment.start_at);
|
||||
setDateInput(d.date);
|
||||
setTimeInput(d.time);
|
||||
@@ -107,7 +135,14 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
setServiceId(null);
|
||||
setEmployeeId(user?.employee_id ?? null);
|
||||
setClientId(null);
|
||||
setPickedClientName("");
|
||||
setPickedClientPhone("");
|
||||
setPickedClientEmail("");
|
||||
setPickedClientTags("");
|
||||
setPickedClientNotes("");
|
||||
setPickedClientNoShows(0);
|
||||
setClientMode("existing");
|
||||
setChangingClient(true);
|
||||
setNewClient({ name: "", phone: "", email: "", notes: "" });
|
||||
setSearch("");
|
||||
const d = toLocalInput(draftSlot.start);
|
||||
@@ -297,6 +332,7 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="label mb-0">Cliente</label>
|
||||
{!isEditing && clientMode === "existing" && !clientId && (
|
||||
<div className="flex rounded-lg border border-slate-200 p-0.5">
|
||||
<button
|
||||
onClick={() => setClientMode("existing")}
|
||||
@@ -309,16 +345,126 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setClientMode("new")}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-xs font-semibold transition-colors",
|
||||
clientMode === "new" ? "bg-brand-500 text-white" : "text-slate-600 hover:bg-slate-100"
|
||||
)}
|
||||
className="rounded-md px-2.5 py-1 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-100"
|
||||
>
|
||||
<UserPlus className="mr-1 inline h-3 w-3" /> Nuevo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isEditing && clientId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setClientId(null);
|
||||
setPickedClientName("");
|
||||
setPickedClientPhone("");
|
||||
setPickedClientEmail("");
|
||||
setPickedClientTags("");
|
||||
setPickedClientNotes("");
|
||||
setPickedClientNoShows(0);
|
||||
setSearch("");
|
||||
}}
|
||||
className="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
Cambiar cliente
|
||||
</button>
|
||||
)}
|
||||
{isEditing && !changingClient && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChangingClient(true)}
|
||||
className="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
Cambiar cliente
|
||||
</button>
|
||||
)}
|
||||
{isEditing && changingClient && clientId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChangingClient(false);
|
||||
setSearch("");
|
||||
}}
|
||||
className="rounded-lg border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
Mantener actual
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current client card (edit mode, or after picking existing client) */}
|
||||
{!changingClient && clientId && pickedClientName && (
|
||||
<div className="rounded-xl border border-brand-200 bg-brand-50/70 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar name={pickedClientName} size={32} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="truncate text-sm font-bold text-slate-800">{pickedClientName}</div>
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-brand-600" />
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-slate-600">
|
||||
{pickedClientPhone && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Phone className="h-3 w-3 text-slate-400" />
|
||||
{pickedClientPhone}
|
||||
</span>
|
||||
)}
|
||||
{pickedClientEmail && (
|
||||
<span className="inline-flex items-center gap-1 truncate">
|
||||
<Mail className="h-3 w-3 shrink-0 text-slate-400" />
|
||||
<span className="truncate">{pickedClientEmail}</span>
|
||||
</span>
|
||||
)}
|
||||
{!pickedClientPhone && !pickedClientEmail && (
|
||||
<span className="text-slate-400">Sin datos de contacto</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{pickedClientTags && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1 border-t border-brand-200/60 pt-2">
|
||||
<Tag className="h-3 w-3 text-slate-400" />
|
||||
{pickedClientTags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.map((t) => (
|
||||
<span key={t} className="chip bg-white text-slate-600">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pickedClientNoShows >= 1 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1 border-t border-brand-200/60 pt-2">
|
||||
<span
|
||||
title="Ausencias previas sin avisar"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold",
|
||||
pickedClientNoShows >= 2
|
||||
? "bg-amber-100 text-amber-800 ring-1 ring-amber-300"
|
||||
: "bg-amber-50 text-amber-700"
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Ausencias previas: {pickedClientNoShows}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{pickedClientNotes && (
|
||||
<div className="mt-2 rounded-lg border border-brand-200/60 bg-white/60 px-2.5 py-1.5 text-[11px] text-slate-700">
|
||||
<div className="font-semibold text-slate-600">Notas del cliente</div>
|
||||
<div className="mt-0.5 whitespace-pre-wrap break-words">{pickedClientNotes}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Picker (existing/new): visible when explicitly changing (edit) OR
|
||||
when no existing client has been picked yet (create). Once an existing
|
||||
client is picked in create mode, the card above takes over. */}
|
||||
{(changingClient || (!isEditing && !clientId)) && (
|
||||
<>
|
||||
{clientMode === "existing" ? (
|
||||
<div>
|
||||
<div className="relative">
|
||||
@@ -330,21 +476,16 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setClientId(null);
|
||||
setPickedClientName("");
|
||||
setPickedClientPhone("");
|
||||
setPickedClientEmail("");
|
||||
setPickedClientTags("");
|
||||
setPickedClientNotes("");
|
||||
setPickedClientNoShows(0);
|
||||
}}
|
||||
/>
|
||||
{searching && <Spinner className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" />}
|
||||
</div>
|
||||
{clientId && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-xl bg-brand-50 px-3 py-2">
|
||||
<Avatar name={searchResults?.clients.find((c) => c.id === clientId)?.name ?? "?"} size={28} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-bold text-slate-800">
|
||||
{searchResults?.clients.find((c) => c.id === clientId)?.name}
|
||||
</div>
|
||||
</div>
|
||||
<Check className="h-4 w-4 text-brand-600" />
|
||||
</div>
|
||||
)}
|
||||
{!clientId && search && searchResults && (
|
||||
<div className="mt-2 max-h-48 overflow-y-auto rounded-xl border border-slate-100">
|
||||
{searchResults.clients.length === 0 ? (
|
||||
@@ -360,7 +501,14 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
key={c.id}
|
||||
onClick={() => {
|
||||
setClientId(c.id);
|
||||
setPickedClientName(c.name);
|
||||
setPickedClientPhone(c.phone ?? "");
|
||||
setPickedClientEmail(c.email ?? "");
|
||||
setPickedClientTags(c.tags ?? "");
|
||||
setPickedClientNotes(c.notes ?? "");
|
||||
setPickedClientNoShows(Number(c.stats?.no_show_count ?? 0));
|
||||
setSearch(c.name);
|
||||
if (isEditing) setChangingClient(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 border-b border-slate-50 px-3 py-2 text-left transition-colors last:border-0 hover:bg-slate-50"
|
||||
>
|
||||
@@ -414,6 +562,8 @@ export function AppointmentModal({ open, onClose, appointment, draftSlot }: Prop
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date / Time / Duration / Price */}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<string>;
|
||||
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 (
|
||||
<div className="select-none">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<button type="button" onClick={() => move(-1)} disabled={!canPrev}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
aria-label="Mes anterior">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="text-sm font-extrabold text-slate-800">{MONTHS[cursor.getMonth()]} {cursor.getFullYear()}</div>
|
||||
<button type="button" onClick={() => move(1)} disabled={!canNext}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
aria-label="Mes siguiente">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-1 grid grid-cols-7 gap-1">
|
||||
{WD.map((w) => (
|
||||
<div key={w} className="text-center text-[11px] font-bold uppercase tracking-wide text-slate-400">{w}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{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 (
|
||||
<button key={i} type="button" disabled={!on} onClick={() => onSelect(dIso)}
|
||||
className={cn(
|
||||
"relative flex aspect-square items-center justify-center rounded-xl text-sm font-bold transition-all",
|
||||
!inM && "text-slate-300",
|
||||
inM && !sel && on && "text-slate-700 hover:bg-brand-50 hover:text-brand-700",
|
||||
inM && !on && "cursor-not-allowed text-slate-300",
|
||||
sel && "bg-brand-500 text-white shadow-soft ring-2 ring-brand-500/30",
|
||||
weekend && inM && !sel && on && "text-slate-500"
|
||||
)}>
|
||||
{d.getDate()}
|
||||
{hasAvail && !sel && (
|
||||
<span className="absolute bottom-1 h-1.5 w-1.5 rounded-full bg-accent-500" aria-hidden />
|
||||
)}
|
||||
{isToday && !sel && (
|
||||
<span className="absolute top-1 h-1.5 w-1.5 rounded-full bg-brand-400" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+34
-8
@@ -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) */
|
||||
@@ -258,9 +282,10 @@ body {
|
||||
background: #fecaca;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select,
|
||||
.textarea {
|
||||
@layer components {
|
||||
.input,
|
||||
.select,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
@@ -270,12 +295,13 @@ body {
|
||||
color: #0f172a;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
outline: none;
|
||||
}
|
||||
.input:focus,
|
||||
.select:focus,
|
||||
.textarea:focus {
|
||||
}
|
||||
.input:focus,
|
||||
.select:focus,
|
||||
.textarea:focus {
|
||||
border-color: #3b66ff;
|
||||
box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
|
||||
}
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
|
||||
+25
-1
@@ -120,7 +120,8 @@ export const api = {
|
||||
remove: (id: number) => request<{ ok: boolean }>(`/appointments/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
dashboard: {
|
||||
overview: () => request<DashboardOverview>("/dashboard/overview"),
|
||||
overview: (range?: number) =>
|
||||
request<DashboardOverview>(`/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) }),
|
||||
|
||||
+5
-13
@@ -1,3 +1,6 @@
|
||||
import type { BookResponse } from "../../shared/types";
|
||||
export type { BookResponse };
|
||||
|
||||
const BASE = "/api/public";
|
||||
|
||||
export interface PublicBusiness {
|
||||
@@ -12,6 +15,7 @@ export interface PublicBusiness {
|
||||
cancel_penalty_pct: number;
|
||||
require_deposit: boolean;
|
||||
deposit_pct: number;
|
||||
auto_assign_specialist: number | boolean;
|
||||
}
|
||||
|
||||
export interface PublicService {
|
||||
@@ -58,23 +62,11 @@ export interface BookClient {
|
||||
|
||||
export interface BookPayload {
|
||||
service_id: number;
|
||||
employee_id: number;
|
||||
employee_id?: number;
|
||||
start_at: string;
|
||||
client: BookClient;
|
||||
}
|
||||
|
||||
export interface BookResponse {
|
||||
appointment: {
|
||||
id: number;
|
||||
start_at: string;
|
||||
price: number;
|
||||
service_name: string;
|
||||
employee_name: string;
|
||||
};
|
||||
business: { name: string; currency_symbol: string };
|
||||
client_created: boolean;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export const DEFAULT_WH: Record<number, { start: string; end: string } | null> = {
|
||||
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<number, { start: string; end: string } | null> {
|
||||
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<number, any> = {};
|
||||
for (let d = 1; d <= 7; d++) out[d] = obj[String(d)] ?? null;
|
||||
return out;
|
||||
}
|
||||
+62
-11
@@ -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<FullCalendar>(null);
|
||||
const pendingRevert = useRef<(() => void) | null>(null);
|
||||
const [moveError, setMoveError] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Appointment | null>(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 && (
|
||||
<div className="mx-5 mt-2 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 sm:mx-7">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
||||
<span className="flex-1">{moveError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMoveError(null)}
|
||||
className="shrink-0 text-amber-500 hover:text-amber-700"
|
||||
aria-label="Cerrar aviso"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 px-5 pb-3 pt-4 sm:px-7">
|
||||
{isOwner && (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:flex-none">
|
||||
@@ -198,7 +227,7 @@ export function CalendarPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 pb-6 sm:px-7">
|
||||
<div className="card overflow-x-auto p-3 sm:p-4">
|
||||
<div className="card flex h-full min-h-0 flex-col overflow-auto p-3 sm:p-4">
|
||||
{isLoading && !data && (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Spinner className="h-6 w-6 text-brand-500" />
|
||||
@@ -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 <div>{arg.timeText} {arg.event.title}</div>;
|
||||
const c = statusColor(a.status);
|
||||
const client = a.client as (NonNullable<Appointment["client"]> & {
|
||||
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 (
|
||||
<div className="flex h-full flex-col gap-0.5 overflow-hidden">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex h-full min-h-0 flex-col gap-0.5 overflow-hidden">
|
||||
<div className="flex items-center gap-1 leading-none">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: c.dot }} />
|
||||
<span className="text-[10px] font-semibold opacity-70">{arg.timeText}</span>
|
||||
<span className="truncate text-[10px] font-semibold opacity-70">{arg.timeText}</span>
|
||||
</div>
|
||||
<div className="truncate font-bold leading-tight">{a.client?.name}</div>
|
||||
<div className="truncate text-[10px] opacity-80">{a.service?.name}</div>
|
||||
{isTime && a.employee && (
|
||||
<div className="mt-auto truncate text-[10px] opacity-70">con {a.employee.name}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate font-bold leading-tight">{a.client?.name ?? "Cliente"}</span>
|
||||
{risky && (
|
||||
<span title="Ausencias previas sin avisar" aria-label="Ausencias previas sin avisar">
|
||||
<AlertTriangle className="h-3 w-3 shrink-0 text-amber-500" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-[10px] leading-tight opacity-80">
|
||||
{a.service?.name}{dur ? ` (${dur} min)` : ""}
|
||||
</div>
|
||||
{isTime && a.employee && !isNarrow && (
|
||||
<div className="mt-auto truncate text-[10px] leading-tight opacity-70">con {a.employee.name}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<div className="flex-1 space-y-5 px-5 py-5 sm:px-7">
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<KpiCard
|
||||
title="Ingresos de hoy"
|
||||
value={ovLoading ? "…" : formatCurrency(overview?.revenue_today ?? 0, symbol)}
|
||||
sub="día de hoy"
|
||||
icon={Wallet}
|
||||
color="#10b981"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Ingresos del período"
|
||||
value={ovLoading ? "…" : formatCurrency(overview?.revenue_month ?? 0, symbol)}
|
||||
sub="últimos 30 días"
|
||||
value={ovLoading ? "…" : formatCurrency(overview?.revenue_range ?? 0, symbol)}
|
||||
sub={`últimos ${range} días`}
|
||||
delta={overview?.revenue_change_pct}
|
||||
deltaSuffix="% vs mes anterior"
|
||||
deltaSuffix="% vs período anterior"
|
||||
icon={DollarSign}
|
||||
color="#3b66ff"
|
||||
/>
|
||||
@@ -133,7 +141,7 @@ export function DashboardPage() {
|
||||
value={ovLoading ? "…" : formatNumber(overview?.appts_upcoming ?? 0)}
|
||||
sub="próximas"
|
||||
icon={CalendarCheck}
|
||||
color="#10b981"
|
||||
color="#6366f1"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Ticket promedio"
|
||||
@@ -143,8 +151,8 @@ export function DashboardPage() {
|
||||
color="#a855f7"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Ocupación"
|
||||
value={ovLoading ? "…" : `${overview?.occupancy_pct ?? 0}%`}
|
||||
title="Finalización"
|
||||
value={ovLoading ? "…" : `${overview?.completion_pct ?? 0}%`}
|
||||
sub={`${overview?.cancel_rate ?? 0}% cancelación`}
|
||||
icon={Flame}
|
||||
color="#f17616"
|
||||
|
||||
+132
-3
@@ -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"];
|
||||
|
||||
@@ -129,9 +130,9 @@ export function EmployeesPage() {
|
||||
)}
|
||||
<div className="mt-3 flex items-center gap-2 text-[11px] text-slate-400">
|
||||
<Activity className="h-3 w-3" />
|
||||
Utilización {e.stats?.utilization_pct ?? 0}%
|
||||
% 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?.utilization_pct ?? 0}%` }} />
|
||||
<div className="h-full rounded-full bg-brand-500" style={{ width: `${e.stats?.share_pct ?? 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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)
|
||||
|
||||
+21
-8
@@ -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("[email protected]");
|
||||
const [password, setPassword] = useState("demo1234");
|
||||
const [email, setEmail] = useState(DEMO ? "[email protected]" : "");
|
||||
const [password, setPassword] = useState(DEMO ? "demo1234" : "");
|
||||
const [error, setError] = useState<string | null>(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,7 +39,10 @@ export function LoginPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const quickLogin = async (mail: string) => {
|
||||
// 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);
|
||||
@@ -49,7 +55,8 @@ export function LoginPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen lg:grid-cols-2">
|
||||
@@ -61,7 +68,7 @@ export function LoginPage() {
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/15 backdrop-blur">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-lg font-extrabold tracking-tight">AgendaPro</span>
|
||||
<span className="text-lg font-extrabold tracking-tight">AgendaMax</span>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-md">
|
||||
@@ -87,7 +94,7 @@ export function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 text-xs text-brand-200">
|
||||
© {new Date().getFullYear()} AgendaPro · Demo
|
||||
© {new Date().getFullYear()} AgendaMax · Demo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,7 +105,7 @@ export function LoginPage() {
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500 to-brand-700 text-white">
|
||||
<Sparkles className="h-6 w-6" />
|
||||
</div>
|
||||
<h1 className="text-xl font-extrabold">AgendaPro</h1>
|
||||
<h1 className="text-xl font-extrabold">AgendaMax</h1>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 shadow-card">
|
||||
@@ -149,6 +156,8 @@ export function LoginPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{DEMO && (
|
||||
<>
|
||||
<div className="mt-5 flex items-center gap-2 text-xs font-medium text-slate-400">
|
||||
<span className="h-px flex-1 bg-slate-200" />
|
||||
cuentas demo
|
||||
@@ -159,7 +168,7 @@ export function LoginPage() {
|
||||
{demoUsers.slice(0, 4).map((u) => (
|
||||
<button
|
||||
key={u.email}
|
||||
onClick={() => quickLogin(u.email)}
|
||||
onClick={() => quickLogin?.(u.email)}
|
||||
disabled={loading}
|
||||
className="group flex w-full items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2 text-left transition-colors hover:border-brand-300 hover:bg-brand-50"
|
||||
>
|
||||
@@ -179,11 +188,15 @@ export function LoginPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{DEMO && (
|
||||
<p className="mt-4 text-center text-xs text-slate-400">
|
||||
Demo · cualquier cuenta usa la contraseña <code className="font-mono font-semibold text-slate-600">demo1234</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title="Mi desempeño"
|
||||
subtitle={user?.name ? `Hola, ${user.name.split(" ")[0]}` : "Tu actividad y comisiones"}
|
||||
/>
|
||||
|
||||
<div className="flex-1 space-y-5 px-5 py-5 sm:px-7">
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
title="Citas hoy"
|
||||
value={isLoading ? "…" : String(perf?.appointments_today ?? 0)}
|
||||
sub="día de hoy"
|
||||
icon={CalendarCheck}
|
||||
color="#3b66ff"
|
||||
/>
|
||||
<StatCard
|
||||
title="Próximas"
|
||||
value={isLoading ? "…" : String(perf?.upcoming ?? 0)}
|
||||
sub="programadas"
|
||||
icon={Clock}
|
||||
color="#6366f1"
|
||||
/>
|
||||
<StatCard
|
||||
title="Ingresos (30 días)"
|
||||
value={isLoading ? "…" : formatCurrency(perf?.revenue_30d ?? 0, symbol)}
|
||||
sub="tus ventas"
|
||||
icon={Wallet}
|
||||
color="#10b981"
|
||||
/>
|
||||
<StatCard
|
||||
title="Valoración"
|
||||
value={isLoading ? "…" : `★ ${perf?.avg_rating ?? 0}`}
|
||||
sub={`${perf?.share_pct ?? 0}% de las citas del negocio`}
|
||||
icon={Star}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Próxima cita + Comisiones total */}
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||
{/* Próxima cita */}
|
||||
<div className="card p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<CalendarClock className="h-4 w-4 text-brand-600" />
|
||||
<h3 className="text-sm font-bold text-slate-900">Próxima cita</h3>
|
||||
</div>
|
||||
{!next ? (
|
||||
<EmptyState title="Sin citas próximas" description="No tienes citas programadas por ahora." />
|
||||
) : (
|
||||
<div className="rounded-xl bg-brand-50/60 p-4">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-extrabold tracking-tight text-brand-700">
|
||||
{formatTime(next.start_at)}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-brand-500">
|
||||
{formatDate(next.start_at, { weekday: "long", day: "numeric", month: "short" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1 text-sm">
|
||||
<div className="font-bold text-slate-900">{next.service_name}</div>
|
||||
<div className="text-slate-500">{next.client_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comisiones list */}
|
||||
<div className="card overflow-hidden lg:col-span-2">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className="h-4 w-4 text-emerald-600" />
|
||||
<h3 className="text-sm font-bold text-slate-900">Mis comisiones (30 días)</h3>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-emerald-700">
|
||||
Total de comisiones
|
||||
</div>
|
||||
<div className="text-lg font-extrabold text-emerald-700">
|
||||
{formatCurrency(commissions?.total ?? 0, symbol)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!commissions ? (
|
||||
<div className="flex h-24 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : commissionRows.length === 0 ? (
|
||||
<EmptyState title="Sin comisiones" description="Aún no tienes ventas en los últimos 30 días." />
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile cards */}
|
||||
<div className="divide-y divide-slate-50 sm:hidden">
|
||||
{commissionRows.slice(0, 20).map((r, i) => (
|
||||
<div key={`${r.appointment_id ?? "t"}-${i}`} className="flex items-center gap-2 px-4 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-bold text-slate-800">{r.service_name}</div>
|
||||
<div className="truncate text-[11px] text-slate-500">
|
||||
{r.client_name} · {formatDate(r.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-sm font-extrabold text-emerald-600">
|
||||
{formatCurrency(r.commission, symbol)}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400">{formatCurrency(r.amount, symbol)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden overflow-x-auto sm:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-[11px] font-bold uppercase tracking-wider text-slate-400">
|
||||
<th className="px-4 py-2">Servicio</th>
|
||||
<th className="px-4 py-2">Cliente</th>
|
||||
<th className="px-4 py-2">Fecha</th>
|
||||
<th className="px-4 py-2 text-right">Venta</th>
|
||||
<th className="px-4 py-2 text-right">Comisión</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{commissionRows.slice(0, 50).map((r, i) => (
|
||||
<tr key={`${r.appointment_id ?? "t"}-${i}`} className="border-t border-slate-50">
|
||||
<td className="px-4 py-2 font-semibold text-slate-800">{r.service_name}</td>
|
||||
<td className="px-4 py-2 text-slate-600">{r.client_name}</td>
|
||||
<td className="px-4 py-2 text-xs text-slate-500">{formatDate(r.created_at)}</td>
|
||||
<td className="px-4 py-2 text-right text-slate-600">{formatCurrency(r.amount, symbol)}</td>
|
||||
<td className="px-4 py-2 text-right font-bold text-emerald-600">
|
||||
{formatCurrency(r.commission, symbol)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
sub,
|
||||
icon: Icon,
|
||||
color,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="card relative overflow-hidden p-5">
|
||||
<div className="absolute -right-6 -top-6 h-24 w-24 rounded-full opacity-10" style={{ background: color }} />
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-slate-500">{title}</div>
|
||||
<div className="mt-1 text-2xl font-extrabold tracking-tight text-slate-900">{value}</div>
|
||||
{sub && <div className="mt-0.5 truncate text-xs text-slate-500">{sub}</div>}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-white shadow-soft"
|
||||
style={{ background: color }}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
Copy,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Wand2,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { PageHeader, Spinner } from "../components/ui";
|
||||
import { DEFAULT_WH, DAYS, parseWh } from "../lib/workingHours";
|
||||
|
||||
export function SettingsPage() {
|
||||
const qc = useQueryClient();
|
||||
@@ -20,9 +23,10 @@ export function SettingsPage() {
|
||||
const [f, setF] = useState<any>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [wh, setWh] = useState<Record<number, { start: string; end: string } | null>>({ ...DEFAULT_WH });
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.settings) setF(data.settings);
|
||||
if (data?.settings) { setF(data.settings); setWh(parseWh(data.settings.working_hours)); }
|
||||
}, [data]);
|
||||
|
||||
const mut = useMutation({
|
||||
@@ -39,6 +43,8 @@ export function SettingsPage() {
|
||||
require_deposit: f.require_deposit ? 1 : 0,
|
||||
deposit_pct: Number(f.deposit_pct),
|
||||
timezone: f.timezone,
|
||||
auto_assign_specialist: f.auto_assign_specialist ? 1 : 0,
|
||||
working_hours: wh,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["settings"] });
|
||||
@@ -97,6 +103,19 @@ export function SettingsPage() {
|
||||
<input className="input" value={f.slug} onChange={(e) => set("slug", e.target.value)} placeholder="mi-negocio" />
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-3">
|
||||
<input type="checkbox" className="mt-0.5 h-4 w-4 accent-brand-500" checked={!!f.auto_assign_specialist} onChange={(e) => set("auto_assign_specialist", e.target.checked)} />
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-sm font-bold text-slate-800"><Wand2 className="h-4 w-4 text-brand-500" /> Asignar especialista automáticamente</div>
|
||||
<p className="text-xs text-slate-500">El cliente no elige especialista: el sistema lo asigna según especialidad, eficiencia y disponibilidad. Evita dobles reservas.</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className="label flex items-center gap-1.5"><Clock className="h-3.5 w-3.5 text-slate-400" /> Horario del negocio</label>
|
||||
<WorkingHoursEditor value={wh} onChange={setWh} />
|
||||
<p className="mt-1 text-[11px] text-slate-500">Define qué días y horas se ofrecen citas. Los especialistas pueden tener su propio horario.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -175,3 +194,35 @@ function SectionTitle({ icon: Icon, color, title, subtitle }: { icon: any; color
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkingHoursEditor({ value, onChange }: { value: Record<number, { start: string; end: string } | null>; onChange: (v: Record<number, { start: string; end: string } | null>) => void }) {
|
||||
const toggle = (n: number) => onChange({ ...value, [n]: value[n] ? null : { start: "09:00", end: "18:00" } });
|
||||
const set = (n: number, field: "start" | "end", v: string) => {
|
||||
const cur = value[n] ?? { start: "09:00", end: "18:00" };
|
||||
onChange({ ...value, [n]: { ...cur, [field]: v } });
|
||||
};
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{DAYS.map(({ n, label }) => {
|
||||
const on = !!value[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={() => toggle(n)} />
|
||||
{label}
|
||||
</label>
|
||||
{on ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input type="time" className="input !w-auto !py-1 text-xs" value={value[n]!.start} onChange={(e) => set(n, "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={value[n]!.end} onChange={(e) => set(n, "end", e.target.value)} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs font-medium text-slate-400">Cerrado</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function AdminOverviewPage() {
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title="Resumen de la plataforma"
|
||||
subtitle="Visión general de todos los negocios en AgendaPro"
|
||||
subtitle="Visión general de todos los negocios en AgendaMax"
|
||||
actions={
|
||||
<Link to="/admin/businesses?new=1" className="btn btn-primary">
|
||||
<Plus className="h-4 w-4" /> Nuevo negocio
|
||||
|
||||
@@ -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,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
type BookResponse,
|
||||
} from "../../lib/publicApi";
|
||||
import { Spinner } from "../../components/ui";
|
||||
import { MonthCalendar } from "../../components/MonthCalendar";
|
||||
import { cn, formatCurrency, formatDate, formatTime } from "../../lib/format";
|
||||
|
||||
function todayStr() {
|
||||
@@ -33,12 +36,25 @@ function todayStr() {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
function maxBookableDate() {
|
||||
const d = new Date();
|
||||
d.setMonth(d.getMonth() + 3);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
const FULL_STEPS = [
|
||||
{ n: 1, label: "Servicio" },
|
||||
{ n: 2, label: "Especialista" },
|
||||
{ n: 3, label: "Horario" },
|
||||
{ n: 4, label: "Datos" },
|
||||
];
|
||||
] as const;
|
||||
|
||||
function stepsFor(autoAssign: boolean) {
|
||||
return autoAssign
|
||||
? FULL_STEPS.filter((s) => s.label !== "Especialista").map((s, i) => ({ n: i + 1, label: s.label, original: s.n }))
|
||||
: FULL_STEPS.map((s, i) => ({ n: i + 1, label: s.label, original: s.n }));
|
||||
}
|
||||
|
||||
export default function BookingPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
@@ -50,6 +66,9 @@ export default function BookingPage() {
|
||||
const [selectedSlot, setSelectedSlot] = useState<PublicSlot | null>(null);
|
||||
const [client, setClient] = useState({ name: "", phone: "", email: "", notes: "" });
|
||||
const [result, setResult] = useState<BookResponse | null>(null);
|
||||
const [bookError, setBookError] = useState<string | null>(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const businessQuery = useQuery({
|
||||
queryKey: ["public", "business", slug],
|
||||
@@ -63,6 +82,11 @@ export default function BookingPage() {
|
||||
const services = data?.services ?? [];
|
||||
const employees = data?.employees ?? [];
|
||||
|
||||
const autoAssign = !!business?.auto_assign_specialist;
|
||||
const steps = useMemo(() => stepsFor(autoAssign), [autoAssign]);
|
||||
const stepCount = steps.length;
|
||||
const currentOriginal = steps.find((s) => s.n === step)?.original;
|
||||
|
||||
const selectedService = useMemo<PublicService | undefined>(
|
||||
() => services.find((s) => s.id === serviceId),
|
||||
[services, serviceId]
|
||||
@@ -71,7 +95,7 @@ export default function BookingPage() {
|
||||
const slotsQuery = useQuery({
|
||||
queryKey: ["public", "slots", slug, serviceId, selectedDate, employeeId],
|
||||
queryFn: () => getSlots(slug!, serviceId!, selectedDate, employeeId ?? undefined),
|
||||
enabled: step >= 3 && !!serviceId && !!selectedDate && !result,
|
||||
enabled: currentOriginal === 3 && !!serviceId && !!selectedDate && !result,
|
||||
});
|
||||
|
||||
const slots = slotsQuery.data?.slots ?? [];
|
||||
@@ -79,9 +103,16 @@ export default function BookingPage() {
|
||||
const bookMut = useMutation({
|
||||
mutationFn: (payload: Parameters<typeof book>[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(() => {
|
||||
@@ -96,6 +127,7 @@ export default function BookingPage() {
|
||||
setSelectedSlot(null);
|
||||
setClient({ name: "", phone: "", email: "", notes: "" });
|
||||
setResult(null);
|
||||
setBookError(null);
|
||||
bookMut.reset();
|
||||
};
|
||||
|
||||
@@ -123,14 +155,34 @@ export default function BookingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasPolicy =
|
||||
(business.cancel_penalty_pct ?? 0) > 0 || !!business.require_deposit;
|
||||
const policyNote = (
|
||||
<PolicyNote
|
||||
cancelWindowHours={business.cancel_window_hours}
|
||||
cancelPenaltyPct={business.cancel_penalty_pct}
|
||||
requireDeposit={business.require_deposit}
|
||||
depositPct={business.deposit_pct}
|
||||
/>
|
||||
);
|
||||
|
||||
if (result) {
|
||||
return <Confirmation result={result} businessName={business.name} onReset={reset} />;
|
||||
return (
|
||||
<Confirmation
|
||||
result={result}
|
||||
businessName={business.name}
|
||||
clientName={client.name.trim()}
|
||||
hasPolicy={hasPolicy}
|
||||
policy={policyNote}
|
||||
onReset={reset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!business.booking_enabled) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-[#f6f7fb]">
|
||||
<TopBar name={business.name} industry={business.industry} step={0} />
|
||||
<TopBar name={business.name} industry={business.industry} step={0} stepCount={4} steps={[]} />
|
||||
<main className="flex flex-1 items-center justify-center px-5 py-10">
|
||||
<div className="card w-full max-w-md p-8 text-center shadow-card animate-slide-up">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-amber-100 text-amber-600">
|
||||
@@ -151,32 +203,35 @@ export default function BookingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const goNext = () => setStep((s) => Math.min(4, s + 1));
|
||||
const goNext = () => setStep((s) => Math.min(stepCount, s + 1));
|
||||
const goBack = () => setStep((s) => Math.max(1, s - 1));
|
||||
|
||||
const pickService = (id: number) => {
|
||||
if (serviceId !== id) {
|
||||
setServiceId(id);
|
||||
setSelectedSlot(null);
|
||||
setBookError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const pickEmployee = (id: number | null) => {
|
||||
setEmployeeId(id);
|
||||
setSelectedSlot(null);
|
||||
setBookError(null);
|
||||
};
|
||||
|
||||
const pickSlot = (slot: PublicSlot) => {
|
||||
setSelectedSlot(slot);
|
||||
setStep(4);
|
||||
setStep((s) => Math.min(stepCount, s + 1));
|
||||
};
|
||||
|
||||
const handleBook = () => {
|
||||
if (!selectedService || !selectedSlot) return;
|
||||
if (!client.name.trim()) return;
|
||||
setBookError(null);
|
||||
bookMut.mutate({
|
||||
service_id: selectedService.id,
|
||||
employee_id: selectedSlot.employee_id,
|
||||
employee_id: autoAssign ? undefined : selectedSlot.employee_id,
|
||||
start_at: selectedSlot.iso,
|
||||
client: {
|
||||
name: client.name.trim(),
|
||||
@@ -188,26 +243,27 @@ export default function BookingPage() {
|
||||
};
|
||||
|
||||
const canContinue =
|
||||
(step === 1 && !!serviceId) ||
|
||||
step === 2 ||
|
||||
(step === 3 && !!selectedSlot) ||
|
||||
(step === 4 && client.name.trim().length > 0);
|
||||
(currentOriginal === 1 && !!serviceId) ||
|
||||
currentOriginal === 2 ||
|
||||
(currentOriginal === 3 && !!selectedSlot) ||
|
||||
(currentOriginal === 4 && client.name.trim().length > 0);
|
||||
|
||||
const currency = business.currency_symbol || "$";
|
||||
const primaryLabel = step === 4 ? "Confirmar reserva" : "Continuar";
|
||||
const onPrimary = step === 4 ? handleBook : goNext;
|
||||
const primaryLabel = currentOriginal === 4 ? "Confirmar reserva" : "Continuar";
|
||||
const onPrimary = currentOriginal === 4 ? handleBook : goNext;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/50 via-[#f6f7fb] to-[#f6f7fb]">
|
||||
<TopBar name={business.name} industry={business.industry} step={step} />
|
||||
<TopBar name={business.name} industry={business.industry} step={step} stepCount={stepCount} steps={steps} />
|
||||
|
||||
<main className="mx-auto w-full max-w-2xl flex-1 px-4 pb-32 pt-5 sm:px-6 md:pb-28">
|
||||
{step === 1 && (
|
||||
<Hero business={business} />
|
||||
)}
|
||||
<main className={cn(
|
||||
"mx-auto w-full flex-1 px-4 pb-32 pt-5 sm:px-6 md:pb-28",
|
||||
currentOriginal === 3 ? "max-w-5xl" : "max-w-2xl"
|
||||
)}>
|
||||
{currentOriginal === 1 && <Hero business={business} />}
|
||||
|
||||
<div className="mt-4">
|
||||
{step === 1 && (
|
||||
{currentOriginal === 1 && (
|
||||
<StepShell
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
title="Elige un servicio"
|
||||
@@ -222,7 +278,7 @@ export default function BookingPage() {
|
||||
</StepShell>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
{currentOriginal === 2 && (
|
||||
<StepShell
|
||||
icon={<User className="h-4 w-4" />}
|
||||
title="Elige un especialista"
|
||||
@@ -236,7 +292,7 @@ export default function BookingPage() {
|
||||
</StepShell>
|
||||
)}
|
||||
|
||||
{step === 3 && selectedService && (
|
||||
{currentOriginal === 3 && selectedService && (
|
||||
<StepShell
|
||||
icon={<CalendarDays className="h-4 w-4" />}
|
||||
title="Elige fecha y hora"
|
||||
@@ -247,8 +303,10 @@ export default function BookingPage() {
|
||||
onDate={(d) => {
|
||||
setSelectedDate(d);
|
||||
setSelectedSlot(null);
|
||||
setBookError(null);
|
||||
}}
|
||||
min={todayStr()}
|
||||
max={maxBookableDate()}
|
||||
slots={slots}
|
||||
loading={slotsQuery.isLoading}
|
||||
isError={slotsQuery.isError}
|
||||
@@ -258,21 +316,42 @@ export default function BookingPage() {
|
||||
</StepShell>
|
||||
)}
|
||||
|
||||
{step === 4 && selectedService && selectedSlot && (
|
||||
{currentOriginal === 4 && selectedService && selectedSlot && (
|
||||
<StepShell
|
||||
icon={<CalendarCheck className="h-4 w-4" />}
|
||||
title="Tus datos"
|
||||
subtitle="Necesitamos algunos datos para confirmar tu cita."
|
||||
>
|
||||
{bookError && (
|
||||
<div className="mb-4 flex items-start gap-3 rounded-2xl bg-rose-600 px-4 py-3 text-white shadow-soft animate-slide-up">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold leading-snug">{bookError}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBookError(null);
|
||||
setSelectedSlot(null);
|
||||
setStep(steps.find((s) => s.original === 3)?.n ?? 3);
|
||||
queryClient.invalidateQueries({ queryKey: ["public", "slots", slug] });
|
||||
}}
|
||||
className="mt-2 inline-flex items-center gap-1.5 rounded-lg bg-white/20 px-2.5 py-1 text-xs font-bold backdrop-blur transition-colors hover:bg-white/30"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" /> Intentar de nuevo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DetailsForm
|
||||
client={client}
|
||||
setClient={setClient}
|
||||
policy={policyNote}
|
||||
summary={
|
||||
<>
|
||||
<SummaryRow label="Servicio" value={selectedService.name} />
|
||||
<SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long" })} />
|
||||
<SummaryRow label="Hora" value={formatTime(selectedSlot.iso)} />
|
||||
<SummaryRow label="Especialista" value={selectedSlot.employee_name} />
|
||||
{!autoAssign && <SummaryRow label="Especialista" value={selectedSlot.employee_name} />}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -284,6 +363,7 @@ export default function BookingPage() {
|
||||
<ActionBar
|
||||
hidden={!!result}
|
||||
step={step}
|
||||
stepCount={stepCount}
|
||||
goBack={goBack}
|
||||
onPrimary={onPrimary}
|
||||
primaryLabel={primaryLabel}
|
||||
@@ -301,11 +381,11 @@ export default function BookingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function TopBar({ name, industry, step }: { name: string; industry: string; step: number }) {
|
||||
const current = STEPS.find((s) => s.n === step);
|
||||
function TopBar({ name, industry, step, stepCount, steps }: { name: string; industry: string; step: number; stepCount: number; steps: { n: number; label: string }[]; }) {
|
||||
const current = steps.find((s) => s.n === step);
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-slate-200/70 bg-white/85 backdrop-blur-md">
|
||||
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
|
||||
<div className="mx-auto flex w-full max-w-5xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-soft">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
@@ -319,10 +399,10 @@ function TopBar({ name, industry, step }: { name: string; industry: string; step
|
||||
{step > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="hidden text-[11px] font-semibold uppercase tracking-wide text-slate-400 sm:inline">
|
||||
Paso {step} de 4
|
||||
Paso {step} de {stepCount}
|
||||
</span>
|
||||
<div className="hidden items-center gap-1 md:flex">
|
||||
{STEPS.map((s) => (
|
||||
{steps.map((s) => (
|
||||
<div
|
||||
key={s.n}
|
||||
className={cn(
|
||||
@@ -342,7 +422,7 @@ function TopBar({ name, industry, step }: { name: string; industry: string; step
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-brand-50 px-3 py-1 text-xs font-bold text-brand-700 md:hidden">
|
||||
<span>{step}</span>
|
||||
<span className="font-semibold text-brand-400">/</span>
|
||||
<span className="text-brand-400">4</span>
|
||||
<span className="text-brand-400">{stepCount}</span>
|
||||
<span className="text-brand-300">·</span>
|
||||
<span>{current?.label}</span>
|
||||
</div>
|
||||
@@ -556,6 +636,7 @@ function DateTimePicker({
|
||||
date,
|
||||
onDate,
|
||||
min,
|
||||
max,
|
||||
slots,
|
||||
loading,
|
||||
isError,
|
||||
@@ -565,28 +646,42 @@ function DateTimePicker({
|
||||
date: string;
|
||||
onDate: (d: string) => void;
|
||||
min: string;
|
||||
max?: string;
|
||||
slots: PublicSlot[];
|
||||
loading: boolean;
|
||||
isError: boolean;
|
||||
selectedSlot: PublicSlot | null;
|
||||
onPick: (s: PublicSlot) => void;
|
||||
}) {
|
||||
const { morning, afternoon } = useMemo(() => {
|
||||
const morning: PublicSlot[] = [];
|
||||
const afternoon: PublicSlot[] = [];
|
||||
for (const s of slots) {
|
||||
const m = /(\d{1,2}):(\d{2})/.exec(s.time);
|
||||
const h = m ? Number(m[1]) : 0;
|
||||
const pm = /PM/i.test(s.time);
|
||||
const hour24 = pm && h !== 12 ? h + 12 : !pm && h === 12 ? 0 : h;
|
||||
(hour24 < 12 ? morning : afternoon).push(s);
|
||||
}
|
||||
return { morning, afternoon };
|
||||
}, [slots]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:items-start md:gap-8">
|
||||
{/* Columna izquierda: calendario */}
|
||||
<div className="card p-4">
|
||||
<label className="label flex items-center gap-1.5">
|
||||
<CalendarDays className="h-3.5 w-3.5 text-slate-400" /> Fecha
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={date}
|
||||
min={min}
|
||||
onChange={(e) => e.target.value && onDate(e.target.value)}
|
||||
/>
|
||||
<MonthCalendar value={date} min={min} max={max} onSelect={(d) => onDate(d)} />
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-slate-500">
|
||||
<span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-accent-500" /> Con cupo</span>
|
||||
<span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-brand-400" /> Hoy</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* Columna derecha: horarios */}
|
||||
<div className="mt-4 md:mt-0">
|
||||
<label className="label flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 text-slate-400" /> Horario disponible
|
||||
</label>
|
||||
@@ -597,14 +692,12 @@ function DateTimePicker({
|
||||
<span className="text-sm font-medium">Buscando horarios…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && isError && (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<AlertCircle className="h-6 w-6 text-rose-400" />
|
||||
<p className="text-sm text-slate-500">No pudimos cargar los horarios. Intenta otra fecha.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !isError && slots.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<CalendarDays className="h-6 w-6 text-slate-300" />
|
||||
@@ -612,8 +705,36 @@ function DateTimePicker({
|
||||
<p className="text-xs text-slate-400">Prueba con otra fecha o especialista.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !isError && slots.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<SlotGroup title="Mañana" slots={morning} selectedSlot={selectedSlot} onPick={onPick} />
|
||||
<SlotGroup title="Tarde" slots={afternoon} selectedSlot={selectedSlot} onPick={onPick} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotGroup({
|
||||
title,
|
||||
slots,
|
||||
selectedSlot,
|
||||
onPick,
|
||||
}: {
|
||||
title: string;
|
||||
slots: PublicSlot[];
|
||||
selectedSlot: PublicSlot | null;
|
||||
onPick: (s: PublicSlot) => void;
|
||||
}) {
|
||||
if (slots.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="rounded-full bg-accent-50 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-accent-700">{title}</span>
|
||||
<span className="text-[11px] font-medium text-slate-400">{slots.length} opciones</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||
{slots.map((slot) => {
|
||||
const active = selectedSlot?.iso === slot.iso && selectedSlot?.employee_id === slot.employee_id;
|
||||
@@ -634,9 +755,6 @@ function DateTimePicker({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -645,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 (
|
||||
<div className="space-y-4">
|
||||
<div className="card divide-y divide-slate-100 p-2">{summary}</div>
|
||||
|
||||
{policy}
|
||||
<div className="card p-4">
|
||||
<div className="grid gap-3">
|
||||
<div>
|
||||
@@ -661,7 +781,7 @@ function DetailsForm({
|
||||
<div className="relative">
|
||||
<User className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
className="input pl-9"
|
||||
className="input pl-10"
|
||||
value={client.name}
|
||||
onChange={(e) => setClient({ ...client, name: e.target.value })}
|
||||
placeholder="Tu nombre"
|
||||
@@ -675,7 +795,7 @@ function DetailsForm({
|
||||
<div className="relative">
|
||||
<Phone className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
className="input pl-9"
|
||||
className="input pl-10"
|
||||
inputMode="tel"
|
||||
value={client.phone}
|
||||
onChange={(e) => setClient({ ...client, phone: e.target.value })}
|
||||
@@ -689,7 +809,7 @@ function DetailsForm({
|
||||
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="email"
|
||||
className="input pl-9"
|
||||
className="input pl-10"
|
||||
value={client.email}
|
||||
onChange={(e) => setClient({ ...client, email: e.target.value })}
|
||||
placeholder="[email protected]"
|
||||
@@ -713,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 (
|
||||
<div className="flex items-start gap-2 rounded-xl bg-amber-50 px-3 py-2.5 text-xs text-amber-800">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<div className="space-y-0.5">
|
||||
{lines.map((l) => (
|
||||
<p key={l}>{l}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||||
@@ -725,6 +876,7 @@ function SummaryRow({ label, value }: { label: string; value: string }) {
|
||||
function ActionBar({
|
||||
hidden,
|
||||
step,
|
||||
stepCount,
|
||||
goBack,
|
||||
onPrimary,
|
||||
primaryLabel,
|
||||
@@ -736,6 +888,7 @@ function ActionBar({
|
||||
}: {
|
||||
hidden: boolean;
|
||||
step: number;
|
||||
stepCount: number;
|
||||
goBack: () => void;
|
||||
onPrimary: () => void;
|
||||
primaryLabel: string;
|
||||
@@ -748,7 +901,7 @@ function ActionBar({
|
||||
if (hidden) return null;
|
||||
return (
|
||||
<div className="sticky bottom-0 z-30 border-t border-slate-200/70 bg-white/90 backdrop-blur-md">
|
||||
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-4 py-3 sm:px-6">
|
||||
<div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-4 py-3 sm:px-6">
|
||||
{step > 1 && (
|
||||
<button onClick={goBack} className="btn btn-ghost shrink-0" type="button">
|
||||
<ChevronLeft className="h-4 w-4" /> <span className="hidden sm:inline">Atrás</span>
|
||||
@@ -764,7 +917,7 @@ function ActionBar({
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm font-medium text-slate-400">
|
||||
{step === 1 ? "Selecciona un servicio" : "Continúa para reservar"}
|
||||
{step === 1 ? "Selecciona un servicio" : `Paso ${step} de ${stepCount} · Continúa para reservar`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -784,7 +937,7 @@ function ActionBar({
|
||||
function FooterNote({ name }: { name: string }) {
|
||||
return (
|
||||
<div className="border-t border-slate-200/60 bg-white/50 px-4 py-3 text-center text-[11px] text-slate-400">
|
||||
Reserva en línea · {name} · powered by AgendaPro
|
||||
Reserva en línea · {name} · powered by AgendaMax
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -792,17 +945,24 @@ 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 (
|
||||
<div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/60 to-[#f6f7fb]">
|
||||
<TopBar name={businessName} industry="Reserva confirmada" step={0} />
|
||||
<TopBar name={businessName} industry="Reserva confirmada" step={0} stepCount={4} steps={[]} />
|
||||
<main className="flex flex-1 items-center justify-center px-5 py-10">
|
||||
<div className="w-full max-w-md animate-slide-up">
|
||||
<div className="card overflow-hidden p-0 text-center shadow-card">
|
||||
@@ -817,6 +977,14 @@ function Confirmation({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-slate-100 bg-slate-50/60 px-5 py-3 text-center">
|
||||
<p className="text-sm font-bold text-slate-800">
|
||||
{result.client_created === false
|
||||
? `¡Gracias por volver, ${clientName}! 👋`
|
||||
: `¡Hola, ${clientName}! Tu cita quedó confirmada.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 p-5 text-left">
|
||||
<SummaryRow label="Servicio" value={appointment.service_name} />
|
||||
<div className="h-px bg-slate-100" />
|
||||
@@ -825,6 +993,17 @@ function Confirmation({
|
||||
<SummaryRow label="Hora" value={formatTime(appointment.start_at)} />
|
||||
<div className="h-px bg-slate-100" />
|
||||
<SummaryRow label="Especialista" value={appointment.employee_name} />
|
||||
{appointment.reasons && appointment.reasons.length > 0 && (
|
||||
<div className="px-3 pb-2 pt-1">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{appointment.reasons.map((r) => (
|
||||
<span key={r} className="inline-flex items-center gap-1 rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold text-brand-700">
|
||||
<Sparkles className="h-3 w-3" /> {r}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-px bg-slate-100" />
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">Total</span>
|
||||
@@ -835,6 +1014,16 @@ function Confirmation({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRisk && (
|
||||
<div className="mt-4 flex items-start gap-2.5 rounded-2xl bg-amber-50 px-4 py-3 text-amber-800 ring-1 ring-amber-200">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
||||
<p className="text-xs font-medium leading-relaxed">
|
||||
Notamos ausencias previas sin avisar. Te enviaremos un recordatorio; reserva y no asistir puede generar un cargo según la política del negocio.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{hasPolicy && policy ? <div className="mt-4">{policy}</div> : null}
|
||||
|
||||
<button onClick={onReset} className="btn btn-secondary mt-4 w-full" type="button">
|
||||
<ChevronLeft className="h-4 w-4" /> Reservar otra cita
|
||||
</button>
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user