Compare commits
3
Commits
0069d23744
...
4f566ddde7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f566ddde7 | ||
|
|
521c206ba7 | ||
|
|
f48a9ac3bf |
@@ -112,19 +112,32 @@ No lo endurezcas a medias: si se toca, hay que cambiar login, `authRequired`, `a
|
|||||||
[server/db.ts](server/db.ts) abre `data/agendapro.db` (WAL + `foreign_keys = ON`) y exporta:
|
[server/db.ts](server/db.ts) abre `data/agendapro.db` (WAL + `foreign_keys = ON`) y exporta:
|
||||||
|
|
||||||
- `SCHEMA` — el esquema de instalación nueva (`CREATE TABLE IF NOT EXISTS …`).
|
- `SCHEMA` — el esquema de instalación nueva (`CREATE TABLE IF NOT EXISTS …`).
|
||||||
- `runMigrations()` — llama `migrateV1ToV2()` → `migrateV2ToV3()` → `migrateV3ToV4()` **en ese orden**,
|
- `runMigrations()` — llama `migrateV1ToV2()` → `migrateV2ToV3()` → `migrateV3ToV4()` → `migrateV4ToV5()` **en ese orden**,
|
||||||
cada una idempotente y guardada por `getMeta("schema_version")`. Ojo: en el archivo `migrateV3ToV4`
|
cada una idempotente y guardada por `schemaVersion()`. Ojo: en el archivo `migrateV3ToV4`
|
||||||
está definida *antes* de `migrateV2ToV3`; el orden de ejecución es el de `runMigrations`, no el del
|
está definida *antes* de `migrateV2ToV3`; el orden de ejecución es el de `runMigrations`, no el del
|
||||||
archivo. Versión actual: **4**.
|
archivo. Versión actual: **5**. Las guardas comparan con `schemaVersion()` (número): la comparación
|
||||||
|
de texto que había hacía que `"10" >= "2"` fuera false y reejecutara `migrateV1ToV2`, que contiene
|
||||||
|
un `DROP TABLE users`.
|
||||||
|
|
||||||
Para cambiar el esquema: añade las columnas/tablas a `SCHEMA` **y** una `migrateV4ToV5()` nueva que
|
Para cambiar el esquema: añade las columnas/tablas a `SCHEMA` **y** una `migrateV5ToV6()` nueva que
|
||||||
haga el backfill, llámala desde `runMigrations()` y termina con `setMeta("schema_version", "5")`.
|
haga el backfill, llámala desde `runMigrations()` y termina con `setMeta("schema_version", "6")`.
|
||||||
Usa los helpers `tableExists()` / `columnExists()` para que la migración sea re-ejecutable.
|
Usa los helpers `tableExists()` / `columnExists()` para que la migración sea re-ejecutable.
|
||||||
|
|
||||||
### Siembra y plantillas
|
### Siembra y plantillas
|
||||||
|
|
||||||
`server/index.ts` llama `ensureSeed()` al arrancar: crea el admin de plataforma
|
`server/index.ts` llama `ensureSeed()` al arrancar: crea el admin de plataforma
|
||||||
(`ensurePlatformAdmin()`) y, si no hay ningún negocio, siembra el demo "Lumière".
|
(`ensurePlatformAdmin()`) y, si no hay ningún negocio, siembra el demo "Lumière".
|
||||||
|
|
||||||
|
**Todo negocio nuevo debe nacer con `slug`, `working_hours` y `timezone` en el propio `INSERT`.**
|
||||||
|
Los valores viven en [server/lib/businessDefaults.ts](server/lib/businessDefaults.ts)
|
||||||
|
(`DEFAULT_WORKING_HOURS`, `uniqueSlug`) y hay **tres** sitios que crean negocios —
|
||||||
|
`server/index.ts` (`ensureSeed`), `server/scripts/seed.ts` (`npm run seed`) y
|
||||||
|
`server/routes/admin.ts` (alta desde la consola): si añades otro, usa el mismo módulo. El backfill
|
||||||
|
de esos campos vivía solo dentro de `migrateV2ToV3`/`migrateV3ToV4`, y las migraciones corren al
|
||||||
|
importar `db.ts`, o sea **antes** de que exista la fila: un negocio sembrado después quedaba con
|
||||||
|
`working_hours = NULL` (cero franjas agendables en cualquier fecha) y `slug = NULL`
|
||||||
|
(`/b/:slug` → 404). `migrateV4ToV5()` repara los que ya quedaron rotos.
|
||||||
|
|
||||||
`seedBusiness({ businessId, template, ownerEmail, ownerName })` de
|
`seedBusiness({ businessId, template, ownerEmail, ownerName })` de
|
||||||
[server/scripts/seed.ts](server/scripts/seed.ts) es reutilizable: lo usa tanto el arranque como el
|
[server/scripts/seed.ts](server/scripts/seed.ts) es reutilizable: lo usa tanto el arranque como el
|
||||||
alta/reset de negocios desde `/api/admin`. Las plantillas (`estetica-spa`, `barberia`, `clinica`,
|
alta/reset de negocios desde `/api/admin`. Las plantillas (`estetica-spa`, `barberia`, `clinica`,
|
||||||
@@ -147,6 +160,25 @@ de SQLite (UTC) atribuye mal las citas de la tarde/noche. Regla:
|
|||||||
- La tz sale de `businesses.timezone`, con default `America/Mexico_City` (ver `bizTz()` en
|
- La tz sale de `businesses.timezone`, con default `America/Mexico_City` (ver `bizTz()` en
|
||||||
[server/routes/me.ts](server/routes/me.ts)). Todos los helpers son puros y aceptan el instante
|
[server/routes/me.ts](server/routes/me.ts)). Todos los helpers son puros y aceptan el instante
|
||||||
explícito, por eso son testeables (`server/lib/time.test.ts`).
|
explícito, por eso son testeables (`server/lib/time.test.ts`).
|
||||||
|
- **`new Date(y, m, d, hh, mm)` está prohibido en lógica de negocio**, igual que `date('now')`.
|
||||||
|
Ese constructor resuelve el reloj de pared en la tz del **proceso**, no en la del negocio. En dev
|
||||||
|
(Windows en `America/Mexico_City`) coincide y todo pasa; en el contenedor de producción
|
||||||
|
(`node:22-slim`, sin `TZ`) el proceso corre en UTC y la jornada entera se desplaza 6 h — un
|
||||||
|
negocio de 09:00–20:00 se publicaba como 03:00–14:00 hora de México, de modo que a partir de la
|
||||||
|
1 PM el endpoint de slots devolvía la lista vacía y no se podía reservar. Usa
|
||||||
|
`wallToUtcDate(tz, …)` y `bizDateISO(instant, tz)` de [server/lib/time.ts](server/lib/time.ts).
|
||||||
|
`isoDateStr`/`isoDayOfWeek` de `scheduling.ts` quedan marcados `@deprecated` por lo mismo.
|
||||||
|
- Por eso **toda la API de `scheduling.ts` lleva `tz` explícita**: `getWorkingHoursForDate(empWh,
|
||||||
|
bizWh, dateIso, tz)`, `getExistingBusy(db, ids, dateIso, tz)`, `isAvailable(db, emp, s, e, tz)`,
|
||||||
|
`pickBestSlotEmployee(…, tz)` y `AutoAssignCtx.tz`. No añadas sobrecargas sin `tz`.
|
||||||
|
- `getExistingBusy` acota el día con `bizDayBoundsIsoFor(tz, dateIso)`, no concatenando
|
||||||
|
`` `${dateIso}T00:00:00` ``: `start_at` se guarda en UTC, así que una cita de las 19:00 de México
|
||||||
|
vive en el día UTC siguiente y la versión anterior no la veía — el guard anti doble-reserva era
|
||||||
|
ciego a toda la tarde.
|
||||||
|
- `server/lib/scheduling.test.ts` fija `process.env.TZ = "UTC"` en su primera línea y usa negocios
|
||||||
|
en `America/Mexico_City`: la tz del proceso y la del negocio **nunca** coinciden, así que una
|
||||||
|
recaída falla en el test y no en producción. `Dockerfile` fija `ENV TZ=UTC` por lo mismo. No las
|
||||||
|
alinees "para que sea más simple".
|
||||||
|
|
||||||
### Agendado, auto-asignación y guard anti doble-reserva
|
### Agendado, auto-asignación y guard anti doble-reserva
|
||||||
|
|
||||||
|
|||||||
+17
-2
@@ -26,6 +26,11 @@ WORKDIR /app
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
# La zona del contenedor se fija explícitamente a UTC, que es como se almacenan los
|
||||||
|
# instantes en la base. La lógica de agenda ya no depende de ella (usa
|
||||||
|
# `businesses.timezone` vía server/lib/time.ts), y dejarla en UTC hace que cualquier
|
||||||
|
# recaída a la tz del proceso se note de inmediato en vez de esconderse en dev.
|
||||||
|
ENV TZ=UTC
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev && npm cache clean --force
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
@@ -38,9 +43,19 @@ COPY server ./server
|
|||||||
COPY shared ./shared
|
COPY shared ./shared
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
# Persistent SQLite data
|
# Persistent SQLite data.
|
||||||
|
#
|
||||||
|
# NO declares `VOLUME /app/data` aquí. Esa instrucción hace que Docker fabrique un
|
||||||
|
# volumen ANÓNIMO en cada arranque, y Coolify los purga al recrear el contenedor:
|
||||||
|
# cada despliegue estrenaba base vacía y `ensureSeed()` la resembraba, así que se
|
||||||
|
# perdían las citas, los clientes y todo lo configurado en Ajustes. Se verificó
|
||||||
|
# comparando el nombre del volumen entre despliegues — cambiaba cada vez.
|
||||||
|
#
|
||||||
|
# La persistencia la aporta el almacenamiento con nombre declarado en Coolify
|
||||||
|
# (`s30f7egdlkx4wyjp59o1iunc-agendamax-data` montado en /app/data), que sobrevive a
|
||||||
|
# la recreación del contenedor. Si algún día se despliega en otro sitio, hay que
|
||||||
|
# declarar allí un volumen equivalente: sin él la base es efímera.
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
VOLUME /app/data
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["npx", "tsx", "server/index.ts"]
|
CMD ["npx", "tsx", "server/index.ts"]
|
||||||
|
|||||||
+41
-9
@@ -1,4 +1,5 @@
|
|||||||
import { DatabaseSync } from "node:sqlite";
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import { DEFAULT_WORKING_HOURS, uniqueSlug } from "./lib/businessDefaults.ts";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
@@ -167,7 +168,7 @@ function columnExists(table: string, column: string): boolean {
|
|||||||
|
|
||||||
/** Migrate an existing v1 DB (single-tenant) to the multi-tenant v2 schema, preserving all data. */
|
/** Migrate an existing v1 DB (single-tenant) to the multi-tenant v2 schema, preserving all data. */
|
||||||
function migrateV1ToV2() {
|
function migrateV1ToV2() {
|
||||||
if (getMeta("schema_version") >= "2") return;
|
if (schemaVersion() >= 2) return;
|
||||||
if (!tableExists("users")) {
|
if (!tableExists("users")) {
|
||||||
setMeta("schema_version", "2");
|
setMeta("schema_version", "2");
|
||||||
return;
|
return;
|
||||||
@@ -224,7 +225,7 @@ function migrateV1ToV2() {
|
|||||||
|
|
||||||
/** v4: auto-assign specialist, business & employee working hours, employee specialties/efficiency. */
|
/** v4: auto-assign specialist, business & employee working hours, employee specialties/efficiency. */
|
||||||
function migrateV3ToV4() {
|
function migrateV3ToV4() {
|
||||||
if (getMeta("schema_version") >= "4") return;
|
if (schemaVersion() >= 4) return;
|
||||||
|
|
||||||
const bizCols: [string, string][] = [
|
const bizCols: [string, string][] = [
|
||||||
["auto_assign_specialist", "INTEGER NOT NULL DEFAULT 0"],
|
["auto_assign_specialist", "INTEGER NOT NULL DEFAULT 0"],
|
||||||
@@ -243,12 +244,9 @@ function migrateV3ToV4() {
|
|||||||
if (!columnExists("employees", col)) db.exec(`ALTER TABLE employees ADD COLUMN ${col} ${def};`);
|
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)
|
// Backfill business working_hours (misma definición que usan el seed y el alta:
|
||||||
const DEFAULT_WH = JSON.stringify({
|
// server/lib/businessDefaults.ts — no la dupliques aquí).
|
||||||
1: { start: "09:00", end: "20:00" }, 2: { start: "09:00", end: "20:00" },
|
const DEFAULT_WH = DEFAULT_WORKING_HOURS;
|
||||||
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 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 = ?`);
|
const upd = db.prepare(`UPDATE businesses SET working_hours = ? WHERE id = ?`);
|
||||||
for (const b of noWh) upd.run(DEFAULT_WH, b.id);
|
for (const b of noWh) upd.run(DEFAULT_WH, b.id);
|
||||||
@@ -262,11 +260,45 @@ export function runMigrations() {
|
|||||||
migrateV1ToV2();
|
migrateV1ToV2();
|
||||||
migrateV2ToV3();
|
migrateV2ToV3();
|
||||||
migrateV3ToV4();
|
migrateV3ToV4();
|
||||||
|
migrateV4ToV5();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Versión de esquema como NÚMERO. Compararla como texto hacía que `"10" >= "2"`
|
||||||
|
* fuera false y reejecutara las migraciones antiguas al llegar a dos dígitos. */
|
||||||
|
function schemaVersion(): number {
|
||||||
|
return Number(getMeta("schema_version") || "0") || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v5: repara los negocios que nacieron sin `slug` ni `working_hours`.
|
||||||
|
*
|
||||||
|
* Los backfills de v3 (slug) y v4 (working_hours) solo alcanzaban a las filas que ya
|
||||||
|
* existían cuando corrió la migración. Cualquier negocio creado después —el demo de
|
||||||
|
* una instalación nueva, o un alta desde /api/admin— quedaba con ambos campos en NULL:
|
||||||
|
* sin página pública de reservas (404 en /b/:slug) y sin ninguna franja agendable.
|
||||||
|
* El default ya se aplica en el INSERT (server/lib/businessDefaults.ts); esta
|
||||||
|
* migración arregla los que quedaron rotos. Idempotente: solo toca filas NULL/vacías.
|
||||||
|
*/
|
||||||
|
function migrateV4ToV5() {
|
||||||
|
if (schemaVersion() >= 5) return;
|
||||||
|
|
||||||
|
const noWh = db.prepare(`SELECT id FROM businesses WHERE working_hours IS NULL OR working_hours = ''`).all() as { id: number }[];
|
||||||
|
const updWh = db.prepare(`UPDATE businesses SET working_hours = ? WHERE id = ?`);
|
||||||
|
for (const b of noWh) updWh.run(DEFAULT_WORKING_HOURS, b.id);
|
||||||
|
|
||||||
|
const noSlug = db.prepare(`SELECT id, name FROM businesses WHERE slug IS NULL OR slug = ''`).all() as { id: number; name: string }[];
|
||||||
|
const updSlug = db.prepare(`UPDATE businesses SET slug = ? WHERE id = ?`);
|
||||||
|
for (const b of noSlug) updSlug.run(uniqueSlug(db, b.name, b.id), b.id);
|
||||||
|
|
||||||
|
if (noWh.length || noSlug.length) {
|
||||||
|
console.log(`[migrate v5] reparados ${noWh.length} horarios y ${noSlug.length} slugs de negocio.`);
|
||||||
|
}
|
||||||
|
setMeta("schema_version", "5");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** v3: cancellation policy, commissions, cash register, notifications/reminders, booking slug. */
|
/** v3: cancellation policy, commissions, cash register, notifications/reminders, booking slug. */
|
||||||
function migrateV2ToV3() {
|
function migrateV2ToV3() {
|
||||||
if (getMeta("schema_version") >= "3") return;
|
if (schemaVersion() >= 3) return;
|
||||||
|
|
||||||
// Business-level settings (cancellation policy + deposit + booking enabled)
|
// Business-level settings (cancellation policy + deposit + booking enabled)
|
||||||
const bizCols: [string, string][] = [
|
const bizCols: [string, string][] = [
|
||||||
|
|||||||
+18
-3
@@ -1,4 +1,6 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { DEFAULT_WORKING_HOURS } from "./lib/businessDefaults.ts";
|
||||||
|
import { DEFAULT_TZ } from "./lib/time.ts";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
@@ -29,12 +31,25 @@ function ensureSeed() {
|
|||||||
const row = db.prepare(`SELECT COUNT(*) c FROM businesses`).get() as { c: number };
|
const row = db.prepare(`SELECT COUNT(*) c FROM businesses`).get() as { c: number };
|
||||||
if (row.c === 0) {
|
if (row.c === 0) {
|
||||||
console.log("[seed] Base de datos vacía — creando negocio demo y admin…");
|
console.log("[seed] Base de datos vacía — creando negocio demo y admin…");
|
||||||
|
// slug, working_hours y timezone se fijan AQUÍ, en el INSERT. Los backfills de
|
||||||
|
// migración corren al importar db.ts, o sea antes de que exista esta fila: un
|
||||||
|
// negocio sembrado sin ellos queda sin página pública (/b/:slug → 404) y sin
|
||||||
|
// ninguna franja agendable. Ver server/lib/businessDefaults.ts.
|
||||||
|
const NAME = "Lumière Estética & Spa";
|
||||||
|
// Slug FIJO, no derivado del nombre: es la URL pública ya publicada
|
||||||
|
// (/b/mi-negocio-demo) y el volumen de producción se recrea en cada
|
||||||
|
// redespliegue, así que un slug derivado la rompería en cada deploy.
|
||||||
|
// Cámbialo desde Ajustes si el negocio deja de ser el demo.
|
||||||
|
const DEMO_SLUG = "mi-negocio-demo";
|
||||||
const biz = db
|
const biz = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status)
|
`INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status, slug, working_hours, timezone)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 'trial', 'active') RETURNING id`
|
VALUES (?, ?, ?, ?, ?, ?, 'trial', 'active', ?, ?, ?) RETURNING id`
|
||||||
)
|
)
|
||||||
.get("Lumière Estética & Spa", "Estética y Spa", "MXN", "$", "+52 55 1234 5678", "Av. Reforma 245, CDMX") as { id: number };
|
.get(
|
||||||
|
NAME, "Estética y Spa", "MXN", "$", "+52 55 1234 5678", "Av. Reforma 245, CDMX",
|
||||||
|
DEMO_SLUG, DEFAULT_WORKING_HOURS, DEFAULT_TZ
|
||||||
|
) as { id: number };
|
||||||
seedBusiness({
|
seedBusiness({
|
||||||
businessId: biz.id,
|
businessId: biz.id,
|
||||||
template: getTemplate(DEFAULT_TEMPLATE_KEY)!,
|
template: getTemplate(DEFAULT_TEMPLATE_KEY)!,
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// server/lib/businessDefaults.ts
|
||||||
|
//
|
||||||
|
// Valores por defecto de un negocio recién creado. Existen porque el backfill de
|
||||||
|
// `slug` y `working_hours` vivía SOLO dentro de las migraciones (migrateV2ToV3 y
|
||||||
|
// migrateV3ToV4), y las migraciones corren al importar db.ts, es decir **antes** de
|
||||||
|
// que `ensureSeed()` inserte ningún negocio. Resultado en una instalación nueva:
|
||||||
|
//
|
||||||
|
// • `working_hours = NULL` → `getWorkingHoursForDate` devuelve null para todos los
|
||||||
|
// días → el endpoint de slots responde `[]` en cualquier fecha y no se puede
|
||||||
|
// reservar nada.
|
||||||
|
// • `slug = NULL` → `publicBusiness()` nunca encuentra el negocio → /b/:slug da 404.
|
||||||
|
//
|
||||||
|
// Un backfill de migración no puede cubrir filas que aún no existen: el default tiene
|
||||||
|
// que aplicarse en el INSERT. Este módulo es la única definición, compartida por el
|
||||||
|
// seed, el alta desde /api/admin y las propias migraciones.
|
||||||
|
|
||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
|
/** Lun-Vie 09:00-20:00, fin de semana cerrado. */
|
||||||
|
export const DEFAULT_WORKING_HOURS = 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** "Lumière Estética & Spa" → "lumiere-estetica-spa". Puro. */
|
||||||
|
export function slugify(s: string): string {
|
||||||
|
return (s || "negocio")
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 60) || "negocio";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slug único para `name`, añadiendo sufijo numérico si ya está tomado. */
|
||||||
|
export function uniqueSlug(db: DatabaseSync, name: string, excludeId: number | null = null): string {
|
||||||
|
const base = slugify(name);
|
||||||
|
let slug = base;
|
||||||
|
let n = 2;
|
||||||
|
const taken = db.prepare(`SELECT id FROM businesses WHERE slug = ? AND id IS NOT ?`);
|
||||||
|
while (taken.get(slug, excludeId)) slug = `${base}-${n++}`;
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
// server/lib/scheduling.test.ts
|
// server/lib/scheduling.test.ts
|
||||||
|
//
|
||||||
|
// La suite corre con TZ=UTC A PROPÓSITO, y los casos usan negocios en
|
||||||
|
// America/Mexico_City: la tz del proceso y la del negocio nunca coinciden, así que
|
||||||
|
// cualquier cálculo que se ancle a la del proceso falla aquí y no en producción.
|
||||||
|
// (Se fija en runtime en vez de en el script de npm porque `TZ=x cmd` no es
|
||||||
|
// portable a cmd.exe en Windows.)
|
||||||
|
process.env.TZ = "UTC";
|
||||||
|
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +16,13 @@ import {
|
|||||||
} from "./scheduling.ts";
|
} from "./scheduling.ts";
|
||||||
import type { CandidateInfo, BusyWindow } from "./scheduling.ts";
|
import type { CandidateInfo, BusyWindow } from "./scheduling.ts";
|
||||||
|
|
||||||
|
const MX = "America/Mexico_City";
|
||||||
|
/** Instante UTC de un reloj de pared mexicano. Los tests no deben depender de la tz del proceso. */
|
||||||
|
function mx(dateIso: string, hh: number, mm = 0): number {
|
||||||
|
const [y, mo, d] = dateIso.split("-").map(Number);
|
||||||
|
return Date.UTC(y, mo - 1, d, hh + 6, mm); // México = UTC-6 todo el año desde 2022
|
||||||
|
}
|
||||||
|
|
||||||
test("parseWorkingHours: json válido → mapa 1..7", () => {
|
test("parseWorkingHours: json válido → mapa 1..7", () => {
|
||||||
const m = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" }, 6: null, 7: null }));
|
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?.[1]?.start, "09:00");
|
||||||
@@ -26,16 +41,9 @@ test("isoDayOfWeek: lunes=1, domingo=7", () => {
|
|||||||
assert.equal(isoDayOfWeek(new Date("2026-07-26T12:00:00")), 7); // domingo
|
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", () => {
|
test("getWorkingHoursForDate: día cerrado → null", () => {
|
||||||
const biz = parseWorkingHours(JSON.stringify({ 6: null, 7: null })); // fin de semana cerrado
|
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
|
assert.equal(getWorkingHoursForDate(null, biz, "2026-07-25", MX), null); // sábado
|
||||||
});
|
});
|
||||||
|
|
||||||
test("normalizeText/tokens: quita acentos y lowercase", () => {
|
test("normalizeText/tokens: quita acentos y lowercase", () => {
|
||||||
@@ -106,8 +114,8 @@ test("scoreCandidate: fairness — libre+ineficiente vence a ocupado+eficiente (
|
|||||||
|
|
||||||
test("pickBestSlotEmployee: empate en score → gana el de menor loadBalance (no por id)", () => {
|
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 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 startMs = mx("2026-07-27", 13); // lunes, dentro de horario
|
||||||
const endMs = new Date(2026, 6, 27, 14, 0).getTime();
|
const endMs = mx("2026-07-27", 14);
|
||||||
// A (id menor, senior): especialidad coincide (sm=1), ocupado media jornada (load=0.5)
|
// A (id menor, senior): especialidad coincide (sm=1), ocupado media jornada (load=0.5)
|
||||||
// → 40·1 + 20·0.5 + 40·0.5 = 70
|
// → 40·1 + 20·0.5 + 40·0.5 = 70
|
||||||
// B (id mayor, junior): sin especialidad (sm=0.5), libre (load=0)
|
// B (id mayor, junior): sin especialidad (sm=0.5), libre (load=0)
|
||||||
@@ -115,23 +123,23 @@ test("pickBestSlotEmployee: empate en score → gana el de menor loadBalance (no
|
|||||||
const A: CandidateInfo = { id: 1, name: "Senior", color: "#fff", role: "stylist", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
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 B: CandidateInfo = { id: 2, name: "Junior", color: "#fff", role: "stylist", specialties: [], efficiency_score: 50, empWh: null };
|
||||||
const busy = new Map<number, BusyWindow[]>([
|
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() }]],
|
[1, [{ startMs: mx("2026-07-27", 9), endMs: mx("2026-07-27", 13) }]],
|
||||||
[2, []],
|
[2, []],
|
||||||
]);
|
]);
|
||||||
const best = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
const best = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs, MX);
|
||||||
assert.equal(best?.id, 2, "el junior libre gana el empate por menor loadBalance, NO por id menor");
|
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", () => {
|
test("pickBestSlotEmployee: empate total (score+load) → decisión determinista y reproducible", () => {
|
||||||
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } }));
|
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } }));
|
||||||
const startMs = new Date(2026, 6, 27, 9, 0).getTime();
|
const startMs = mx("2026-07-27", 9);
|
||||||
const endMs = new Date(2026, 6, 27, 10, 0).getTime();
|
const endMs = mx("2026-07-27", 10);
|
||||||
// Dos candidatos idénticos salvo el id → mismo score y mismo loadBalance → hash decide.
|
// 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 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 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 busy = new Map<number, BusyWindow[]>([[10, []], [20, []]]);
|
||||||
const r1 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
const r1 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs, MX);
|
||||||
const r2 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
const r2 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs, MX);
|
||||||
assert.ok(r1 && (r1.id === 10 || r1.id === 20), "debe elegir uno de los dos candidatos");
|
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)");
|
assert.equal(r1?.id, r2?.id, "misma entrada → misma decisión (hash determinista, reproducible)");
|
||||||
});
|
});
|
||||||
@@ -143,3 +151,58 @@ test("BUSY_STATUSES: scheduled+completed sí cuentan; no_show NO (silla vacía)"
|
|||||||
assert.equal(s.includes("no_show"), false);
|
assert.equal(s.includes("no_show"), false);
|
||||||
assert.equal(s.length, 2);
|
assert.equal(s.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Regresión: la ventana laboral debe anclarse a la tz del NEGOCIO ────────────
|
||||||
|
// Estos tests fallaban en producción (contenedor node:22-slim = UTC) y pasaban en
|
||||||
|
// dev (Windows = America/Mexico_City), porque getWorkingHoursForDate construía la
|
||||||
|
// ventana con `new Date(y, m, d, hh, mm)`, es decir en la tz del *proceso*.
|
||||||
|
// Corren bajo `TZ=UTC` en test:unit precisamente para que el desfase no se esconda.
|
||||||
|
|
||||||
|
test("getWorkingHoursForDate: ancla la ventana a la tz del negocio, no a la del proceso", () => {
|
||||||
|
const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
|
||||||
|
const r = getWorkingHoursForDate(null, biz, "2026-08-31", "America/Mexico_City"); // lunes
|
||||||
|
assert.ok(r, "el lunes debe estar abierto");
|
||||||
|
// 09:00 CST = 15:00Z ; 20:00 CST = 02:00Z del día siguiente
|
||||||
|
assert.equal(new Date(r!.startMs).toISOString(), "2026-08-31T15:00:00.000Z");
|
||||||
|
assert.equal(new Date(r!.endMs).toISOString(), "2026-09-01T02:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getWorkingHoursForDate: la misma fecha en otra tz da otra ventana absoluta", () => {
|
||||||
|
const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
|
||||||
|
const mx = getWorkingHoursForDate(null, biz, "2026-08-31", "America/Mexico_City");
|
||||||
|
const utc = getWorkingHoursForDate(null, biz, "2026-08-31", "UTC");
|
||||||
|
assert.notEqual(mx!.startMs, utc!.startMs);
|
||||||
|
assert.equal(new Date(utc!.startMs).toISOString(), "2026-08-31T09:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getWorkingHoursForDate: el día de la semana sale de la fecha natural, no de un Date local", () => {
|
||||||
|
const biz = parseWorkingHours(JSON.stringify({ 6: null, 7: null, 1: { start: "09:00", end: "20:00" } }));
|
||||||
|
assert.equal(getWorkingHoursForDate(null, biz, "2026-08-29", "America/Mexico_City"), null); // sábado
|
||||||
|
assert.equal(getWorkingHoursForDate(null, biz, "2026-08-30", "America/Mexico_City"), null); // domingo
|
||||||
|
assert.ok(getWorkingHoursForDate(null, biz, "2026-08-31", "America/Mexico_City")); // lunes
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getWorkingHoursForDate: empleado tiene prioridad sobre negocio (tz-explícito)", () => {
|
||||||
|
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, "2026-08-31", "America/Mexico_City");
|
||||||
|
assert.equal(new Date(r!.startMs).toISOString(), "2026-08-31T16:00:00.000Z"); // 10:00 CST
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pickBestSlotEmployee: un slot de la tarde del negocio sigue dentro de la ventana", () => {
|
||||||
|
// 19:00 México = 01:00Z del día siguiente. Con la ventana mal anclada este slot
|
||||||
|
// quedaba fuera de rango y el endpoint devolvía la tarde vacía.
|
||||||
|
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
|
||||||
|
const cand: CandidateInfo = {
|
||||||
|
id: 1, name: "Ana", color: "#000", role: "Estilista",
|
||||||
|
specialties: ["Cabello"], efficiency_score: 80, empWh: null,
|
||||||
|
};
|
||||||
|
const startMs = Date.parse("2026-09-01T01:00:00Z"); // lunes 31/08 19:00 México
|
||||||
|
const endMs = startMs + 60 * 60000;
|
||||||
|
const busy = new Map<number, BusyWindow[]>();
|
||||||
|
const best = pickBestSlotEmployee(
|
||||||
|
[cand], busy, bizWh, { name: "Corte", category: "Cabello" },
|
||||||
|
startMs, endMs, "America/Mexico_City"
|
||||||
|
);
|
||||||
|
assert.equal(best?.id, 1);
|
||||||
|
});
|
||||||
|
|||||||
+59
-18
@@ -2,6 +2,8 @@
|
|||||||
// Disponibilidad + scoring de especialistas. Las funciones puras de aquí
|
// 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.
|
// se unit-testean en scheduling.test.ts; las que tocan la DB se añaden más abajo.
|
||||||
|
|
||||||
|
import { bizDateISO, bizDayBoundsIsoFor, isoDowFromDateStr, wallToUtcDate, DEFAULT_TZ } from "./time.ts";
|
||||||
|
|
||||||
export interface WorkingDay { start: string; end: string; } // "HH:mm"
|
export interface WorkingDay { start: string; end: string; } // "HH:mm"
|
||||||
export type WorkingHoursMap = Record<number, WorkingDay | null>; // 1=Lun … 7=Dom
|
export type WorkingHoursMap = Record<number, WorkingDay | null>; // 1=Lun … 7=Dom
|
||||||
|
|
||||||
@@ -39,24 +41,39 @@ export function isoDayOfWeek(date: Date): number {
|
|||||||
return j === 0 ? 7 : j;
|
return j === 0 ? 7 : j;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Fecha natural en la tz del PROCESO. Para lógica de negocio usa
|
||||||
|
* `bizDateISO(instant, tz)` de time.ts — esta desplaza el día en un contenedor UTC. */
|
||||||
export function isoDateStr(d: Date): string {
|
export function isoDateStr(d: Date): string {
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
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. */
|
/**
|
||||||
|
* Resuelve el horario (empleado tiene prioridad; si no, negocio) para una fecha
|
||||||
|
* natural del negocio, y lo devuelve como instantes absolutos (ms UTC).
|
||||||
|
*
|
||||||
|
* `dateIso` es "YYYY-MM-DD" **en la tz del negocio** y `tz` su zona IANA. Ambos
|
||||||
|
* son obligatorios a propósito: la versión anterior recibía un `Date` y
|
||||||
|
* construía la ventana con `new Date(y, m, d, hh, mm)`, o sea en la tz del
|
||||||
|
* *proceso*. En dev (Windows, America/Mexico_City) coincidía; en el contenedor
|
||||||
|
* de producción (node:22-slim = UTC) desplazaba toda la jornada 6 horas, de modo
|
||||||
|
* que un negocio de 09:00–20:00 se publicaba como 03:00–14:00 hora de México.
|
||||||
|
*/
|
||||||
export function getWorkingHoursForDate(
|
export function getWorkingHoursForDate(
|
||||||
empWh: WorkingHoursMap | null,
|
empWh: WorkingHoursMap | null,
|
||||||
bizWh: WorkingHoursMap | null,
|
bizWh: WorkingHoursMap | null,
|
||||||
date: Date
|
dateIso: string,
|
||||||
|
tz: string
|
||||||
): { startMs: number; endMs: number } | null {
|
): { startMs: number; endMs: number } | null {
|
||||||
const dow = isoDayOfWeek(date);
|
const zone = tz || DEFAULT_TZ;
|
||||||
|
const dow = isoDowFromDateStr(dateIso);
|
||||||
const day = empWh?.[dow] ?? bizWh?.[dow] ?? null;
|
const day = empWh?.[dow] ?? bizWh?.[dow] ?? null;
|
||||||
if (!day) return null;
|
if (!day) return null;
|
||||||
|
const [y, mo, d] = dateIso.split("-").map(Number);
|
||||||
const [sh, sm] = day.start.split(":").map(Number);
|
const [sh, sm] = day.start.split(":").map(Number);
|
||||||
const [eh, em] = day.end.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 startMs = wallToUtcDate(zone, y, mo, d, sh, sm, 0).getTime();
|
||||||
const endMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), eh, em).getTime();
|
const endMs = wallToUtcDate(zone, y, mo, d, eh, em, 0).getTime();
|
||||||
if (!isFinite(startMs) || !isFinite(endMs) || endMs <= startMs) return null;
|
if (!isFinite(startMs) || !isFinite(endMs) || endMs <= startMs) return null;
|
||||||
return { startMs, endMs };
|
return { startMs, endMs };
|
||||||
}
|
}
|
||||||
@@ -156,11 +173,24 @@ export function getCandidates(db: DatabaseSync, businessId: number, serviceId: n
|
|||||||
* del empleado (además ayuda a los sobre-agendados a recuperarse). */
|
* del empleado (además ayuda a los sobre-agendados a recuperarse). */
|
||||||
export const BUSY_STATUSES = ["scheduled", "completed"] as const;
|
export const BUSY_STATUSES = ["scheduled", "completed"] as const;
|
||||||
|
|
||||||
export function getExistingBusy(db: DatabaseSync, employeeIds: number[], dateIso: string): Map<number, BusyWindow[]> {
|
/**
|
||||||
|
* Citas que ocupan a cada empleado durante el día natural `dateIso` **del negocio**.
|
||||||
|
*
|
||||||
|
* Los límites se calculan con `bizDayBoundsIsoFor`, no concatenando
|
||||||
|
* `${dateIso}T00:00:00`: `start_at` se almacena en UTC, así que una cita de las
|
||||||
|
* 19:00 de México vive en el día UTC *siguiente* (01:00Z) y la versión anterior
|
||||||
|
* la dejaba fuera del rango — el guard anti doble-reserva no veía ninguna cita
|
||||||
|
* de la tarde.
|
||||||
|
*/
|
||||||
|
export function getExistingBusy(
|
||||||
|
db: DatabaseSync,
|
||||||
|
employeeIds: number[],
|
||||||
|
dateIso: string,
|
||||||
|
tz: string
|
||||||
|
): Map<number, BusyWindow[]> {
|
||||||
const map = new Map<number, BusyWindow[]>();
|
const map = new Map<number, BusyWindow[]>();
|
||||||
if (employeeIds.length === 0) return map;
|
if (employeeIds.length === 0) return map;
|
||||||
const startOfDay = `${dateIso}T00:00:00`;
|
const { start: startOfDay, end: endOfDay } = bizDayBoundsIsoFor(tz || DEFAULT_TZ, dateIso);
|
||||||
const endOfDay = `${dateIso}T23:59:59`;
|
|
||||||
const statusPh = BUSY_STATUSES.map(() => "?").join(",");
|
const statusPh = BUSY_STATUSES.map(() => "?").join(",");
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -177,9 +207,16 @@ export function getExistingBusy(db: DatabaseSync, employeeIds: number[], dateIso
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAvailable(db: DatabaseSync, employeeId: number, startMs: number, endMs: number): boolean {
|
export function isAvailable(
|
||||||
const dateIso = isoDateStr(new Date(startMs));
|
db: DatabaseSync,
|
||||||
const busy = getExistingBusy(db, [employeeId], dateIso).get(employeeId) ?? [];
|
employeeId: number,
|
||||||
|
startMs: number,
|
||||||
|
endMs: number,
|
||||||
|
tz: string
|
||||||
|
): boolean {
|
||||||
|
const zone = tz || DEFAULT_TZ;
|
||||||
|
const dateIso = bizDateISO(new Date(startMs), zone);
|
||||||
|
const busy = getExistingBusy(db, [employeeId], dateIso, zone).get(employeeId) ?? [];
|
||||||
return !hasConflict(busy, startMs, endMs);
|
return !hasConflict(busy, startMs, endMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,13 +282,14 @@ export function pickBestSlotEmployee(
|
|||||||
bizWh: WorkingHoursMap | null,
|
bizWh: WorkingHoursMap | null,
|
||||||
service: { name: string; category: string },
|
service: { name: string; category: string },
|
||||||
startMs: number,
|
startMs: number,
|
||||||
endMs: number
|
endMs: number,
|
||||||
|
tz: string
|
||||||
): { id: number; name: string } | null {
|
): { id: number; name: string } | null {
|
||||||
const date = new Date(startMs);
|
const zone = tz || DEFAULT_TZ;
|
||||||
const dateIso = isoDateStr(date);
|
const dateIso = bizDateISO(new Date(startMs), zone);
|
||||||
let best: RankedCandidate | null = null;
|
let best: RankedCandidate | null = null;
|
||||||
for (const c of candidates) {
|
for (const c of candidates) {
|
||||||
const wh = getWorkingHoursForDate(c.empWh, bizWh, date);
|
const wh = getWorkingHoursForDate(c.empWh, bizWh, dateIso, zone);
|
||||||
if (!wh) continue;
|
if (!wh) continue;
|
||||||
if (startMs < wh.startMs || endMs > wh.endMs) continue;
|
if (startMs < wh.startMs || endMs > wh.endMs) continue;
|
||||||
if (hasConflict(busyMap.get(c.id) ?? [], startMs, endMs)) continue;
|
if (hasConflict(busyMap.get(c.id) ?? [], startMs, endMs)) continue;
|
||||||
@@ -278,17 +316,20 @@ export interface AutoAssignCtx {
|
|||||||
startMs: number;
|
startMs: number;
|
||||||
endMs: number;
|
endMs: number;
|
||||||
bizWh: WorkingHoursMap | null;
|
bizWh: WorkingHoursMap | null;
|
||||||
|
/** Zona IANA del negocio. Sin ella la ventana laboral se ancla a la tz del proceso. */
|
||||||
|
tz: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Asigna el mejor especialista disponible (hard constraints + score). Llamar dentro de transacción. */
|
/** Asigna el mejor especialista disponible (hard constraints + score). Llamar dentro de transacción. */
|
||||||
export function autoAssign(db: DatabaseSync, ctx: AutoAssignCtx): AutoAssignResult | null {
|
export function autoAssign(db: DatabaseSync, ctx: AutoAssignCtx): AutoAssignResult | null {
|
||||||
const candidates = getCandidates(db, ctx.businessId, ctx.serviceId);
|
const candidates = getCandidates(db, ctx.businessId, ctx.serviceId);
|
||||||
if (candidates.length === 0) return null;
|
if (candidates.length === 0) return null;
|
||||||
const dateIso = isoDateStr(new Date(ctx.startMs));
|
const zone = ctx.tz || DEFAULT_TZ;
|
||||||
const busyMap = getExistingBusy(db, candidates.map((c) => c.id), dateIso);
|
const dateIso = bizDateISO(new Date(ctx.startMs), zone);
|
||||||
|
const busyMap = getExistingBusy(db, candidates.map((c) => c.id), dateIso, zone);
|
||||||
let best: RankedCandidate | null = null;
|
let best: RankedCandidate | null = null;
|
||||||
for (const c of candidates) {
|
for (const c of candidates) {
|
||||||
const wh = getWorkingHoursForDate(c.empWh, ctx.bizWh, new Date(ctx.startMs));
|
const wh = getWorkingHoursForDate(c.empWh, ctx.bizWh, dateIso, zone);
|
||||||
if (!wh) continue;
|
if (!wh) continue;
|
||||||
if (ctx.startMs < wh.startMs || ctx.endMs > wh.endMs) continue;
|
if (ctx.startMs < wh.startMs || ctx.endMs > wh.endMs) continue;
|
||||||
if (hasConflict(busyMap.get(c.id) ?? [], ctx.startMs, ctx.endMs)) continue;
|
if (hasConflict(busyMap.get(c.id) ?? [], ctx.startMs, ctx.endMs)) continue;
|
||||||
|
|||||||
@@ -115,3 +115,34 @@ export function wallToUtcISO(
|
|||||||
): string {
|
): string {
|
||||||
return wallToUtcDate(tz, y, mo, d, hh, mm, ss).toISOString();
|
return wallToUtcDate(tz, y, mo, d, hh, mm, ss).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Zona horaria por defecto de los negocios (México). Única definición. */
|
||||||
|
export const DEFAULT_TZ = "America/Mexico_City";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Día ISO de la semana (1=Lun … 7=Dom) de una fecha natural "YYYY-MM-DD".
|
||||||
|
* Deriva del string, nunca de un `Date` local, así que es independiente de
|
||||||
|
* la tz del proceso (en un contenedor UTC `new Date("…").getDay()` puede
|
||||||
|
* caer en el día anterior).
|
||||||
|
*/
|
||||||
|
export function isoDowFromDateStr(dateIso: string): number {
|
||||||
|
const [y, m, d] = dateIso.split("-").map(Number);
|
||||||
|
const j = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0=Dom..6=Sáb
|
||||||
|
return j === 0 ? 7 : j;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Límites UTC del día natural `dateIso` **en la tz del negocio**, en formato
|
||||||
|
* ISO-Z. Emparéjalo con `start_at >= ? AND start_at <= ?` (comparación
|
||||||
|
* lexicográfica sobre el formato canónico almacenado).
|
||||||
|
*
|
||||||
|
* A diferencia de `bizDayBoundsIso`, que trabaja con desplazamientos respecto
|
||||||
|
* de "hoy", este acepta la fecha explícita que pide el cliente.
|
||||||
|
*/
|
||||||
|
export function bizDayBoundsIsoFor(tz: string, dateIso: string): { start: string; end: string } {
|
||||||
|
const [y, m, d] = dateIso.split("-").map(Number);
|
||||||
|
return {
|
||||||
|
start: toIsoUtc(wallToUtcDate(tz || DEFAULT_TZ, y, m, d, 0, 0, 0)),
|
||||||
|
end: toIsoUtc(wallToUtcDate(tz || DEFAULT_TZ, y, m, d, 23, 59, 59)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+10
-3
@@ -1,4 +1,6 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
import { DEFAULT_WORKING_HOURS, uniqueSlug } from "../lib/businessDefaults.ts";
|
||||||
|
import { DEFAULT_TZ } from "../lib/time.ts";
|
||||||
import { db } from "../db.ts";
|
import { db } from "../db.ts";
|
||||||
import { adminOnly, err, type AuthedRequest } from "../lib/auth.ts";
|
import { adminOnly, err, type AuthedRequest } from "../lib/auth.ts";
|
||||||
import { TEMPLATES, getTemplate, type TemplateDef } from "../lib/templates.ts";
|
import { TEMPLATES, getTemplate, type TemplateDef } from "../lib/templates.ts";
|
||||||
@@ -103,8 +105,8 @@ adminRouter.post("/businesses", (req: AuthedRequest, res) => {
|
|||||||
|
|
||||||
const biz = db
|
const biz = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO businesses (name, industry, currency, currency_symbol, plan, status, template)
|
`INSERT INTO businesses (name, industry, currency, currency_symbol, plan, status, template, slug, working_hours, timezone)
|
||||||
VALUES (?, ?, ?, ?, ?, 'active', ?) RETURNING id`
|
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?) RETURNING id`
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
name,
|
name,
|
||||||
@@ -112,7 +114,12 @@ adminRouter.post("/businesses", (req: AuthedRequest, res) => {
|
|||||||
currency || tpl.currency || "MXN",
|
currency || tpl.currency || "MXN",
|
||||||
currency_symbol || tpl.currency_symbol || "$",
|
currency_symbol || tpl.currency_symbol || "$",
|
||||||
plan || "trial",
|
plan || "trial",
|
||||||
tpl.key
|
tpl.key,
|
||||||
|
// Sin estos tres, el tenant nace sin página pública (/b/:slug → 404) y sin
|
||||||
|
// ninguna franja agendable, porque el backfill solo corre en migraciones.
|
||||||
|
uniqueSlug(db, name),
|
||||||
|
DEFAULT_WORKING_HOURS,
|
||||||
|
DEFAULT_TZ
|
||||||
) as { id: number };
|
) as { id: number };
|
||||||
|
|
||||||
const result = seedBusiness({
|
const result = seedBusiness({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db } from "../db.ts";
|
|||||||
import type { AuthedRequest } from "../lib/auth.ts";
|
import type { AuthedRequest } from "../lib/auth.ts";
|
||||||
import { err } from "../lib/auth.ts";
|
import { err } from "../lib/auth.ts";
|
||||||
import { autoAssign, isAvailable, runInTransaction, parseWorkingHours } from "../lib/scheduling.ts";
|
import { autoAssign, isAvailable, runInTransaction, parseWorkingHours } from "../lib/scheduling.ts";
|
||||||
|
import { DEFAULT_TZ } from "../lib/time.ts";
|
||||||
|
|
||||||
export const appointmentsRouter = Router();
|
export const appointmentsRouter = Router();
|
||||||
|
|
||||||
@@ -138,18 +139,23 @@ appointmentsRouter.post("/", (req: AuthedRequest, res) => {
|
|||||||
if (!empId && req.user!.role === "employee" && req.user!.employee_id) {
|
if (!empId && req.user!.role === "employee" && req.user!.employee_id) {
|
||||||
empId = req.user!.employee_id;
|
empId = req.user!.employee_id;
|
||||||
}
|
}
|
||||||
|
// La tz del negocio manda: la ventana laboral y el rango del día se anclan a
|
||||||
|
// ella, no a la del proceso (el contenedor de producción corre en UTC).
|
||||||
|
const bizRow = db
|
||||||
|
.prepare(`SELECT working_hours wh, timezone tz FROM businesses WHERE id = ?`)
|
||||||
|
.get(req.user!.business_id) as { wh?: string; tz?: string } | undefined;
|
||||||
|
const tz = bizRow?.tz || DEFAULT_TZ;
|
||||||
if (!empId) {
|
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, {
|
const aa = autoAssign(db, {
|
||||||
businessId: Number(req.user!.business_id), serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
|
businessId: Number(req.user!.business_id), serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
|
||||||
startMs, endMs, bizWh: parseWorkingHours(bizRow?.wh) as any,
|
startMs, endMs, bizWh: parseWorkingHours(bizRow?.wh) as any, tz,
|
||||||
});
|
});
|
||||||
empId = aa?.employeeId ?? null;
|
empId = aa?.employeeId ?? null;
|
||||||
}
|
}
|
||||||
if (!empId) throw { status: 400, error: "No hay empleado asignado a este servicio" };
|
if (!empId) throw { status: 400, error: "No hay empleado asignado a este servicio" };
|
||||||
|
|
||||||
// Guard anti doble reserva
|
// Guard anti doble reserva
|
||||||
if (!isAvailable(db, empId, startMs, endMs)) throw { status: 409, error: "Ese horario ya está ocupado para el especialista" };
|
if (!isAvailable(db, empId, startMs, endMs, tz)) throw { status: 409, error: "Ese horario ya está ocupado para el especialista" };
|
||||||
|
|
||||||
const inserted = db
|
const inserted = db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
+38
-12
@@ -5,6 +5,7 @@ import {
|
|||||||
pickBestSlotEmployee, autoAssign, isAvailable, runInTransaction,
|
pickBestSlotEmployee, autoAssign, isAvailable, runInTransaction,
|
||||||
type WorkingHoursMap,
|
type WorkingHoursMap,
|
||||||
} from "../lib/scheduling.ts";
|
} from "../lib/scheduling.ts";
|
||||||
|
import { DEFAULT_TZ } from "../lib/time.ts";
|
||||||
|
|
||||||
export const bookingRouter = Router();
|
export const bookingRouter = Router();
|
||||||
|
|
||||||
@@ -27,9 +28,30 @@ bookingRouter.get("/:slug", (req, res) => {
|
|||||||
const services = db
|
const services = db
|
||||||
.prepare(`SELECT id, name, description, category, duration_min, price, color FROM services WHERE business_id = ? AND active = 1 ORDER BY category, name`)
|
.prepare(`SELECT id, name, description, category, duration_min, price, color FROM services WHERE business_id = ? AND active = 1 ORDER BY category, name`)
|
||||||
.all(biz.id);
|
.all(biz.id);
|
||||||
const employees = db
|
const employeeRows = db
|
||||||
.prepare(`SELECT id, name, role, color FROM employees WHERE business_id = ? AND active = 1 ORDER BY name`)
|
.prepare(`SELECT id, name, role, color FROM employees WHERE business_id = ? AND active = 1 ORDER BY name`)
|
||||||
.all(biz.id);
|
.all(biz.id) as { id: number; name: string; role: string; color: string }[];
|
||||||
|
// Qué servicios ofrece cada especialista. Sin esto el cliente no puede filtrar el
|
||||||
|
// paso "Elige un especialista" y deja escoger a alguien que no da ese servicio: el
|
||||||
|
// siguiente paso responde 400 y la UI lo muestra como "no pudimos cargar los
|
||||||
|
// horarios, intenta otra fecha", que nunca lo arregla.
|
||||||
|
// El JOIN contra employees filtra por negocio: employee_services no tiene
|
||||||
|
// business_id propio, así que es la única forma de no cruzar tenants aquí.
|
||||||
|
const links = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT es.employee_id, es.service_id
|
||||||
|
FROM employee_services es
|
||||||
|
JOIN employees e ON e.id = es.employee_id
|
||||||
|
WHERE e.business_id = ? AND e.active = 1`
|
||||||
|
)
|
||||||
|
.all(biz.id) as { employee_id: number; service_id: number }[];
|
||||||
|
const byEmployee = new Map<number, number[]>();
|
||||||
|
for (const l of links) {
|
||||||
|
const arr = byEmployee.get(l.employee_id) ?? [];
|
||||||
|
arr.push(l.service_id);
|
||||||
|
byEmployee.set(l.employee_id, arr);
|
||||||
|
}
|
||||||
|
const employees = employeeRows.map((e) => ({ ...e, service_ids: byEmployee.get(e.id) ?? [] }));
|
||||||
res.json({ business: { ...biz, currency_symbol: biz.currency_symbol || "$" }, services, employees });
|
res.json({ business: { ...biz, currency_symbol: biz.currency_symbol || "$" }, services, employees });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,8 +61,10 @@ bookingRouter.get("/:slug/slots", (req, res) => {
|
|||||||
if (!biz || !biz.booking_enabled) return res.status(404).json({ error: "No disponible" });
|
if (!biz || !biz.booking_enabled) return res.status(404).json({ error: "No disponible" });
|
||||||
const serviceId = Number(req.query.service_id);
|
const serviceId = Number(req.query.service_id);
|
||||||
const employeeId = req.query.employee_id ? Number(req.query.employee_id) : null;
|
const employeeId = req.query.employee_id ? Number(req.query.employee_id) : null;
|
||||||
const date = req.query.date as string; // YYYY-MM-DD
|
const date = req.query.date as string; // YYYY-MM-DD, en la tz del negocio
|
||||||
if (!serviceId || !date) return res.status(400).json({ error: "service_id y date son obligatorios" });
|
if (!serviceId || !date) return res.status(400).json({ error: "service_id y date son obligatorios" });
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return res.status(400).json({ error: "Fecha inválida (formato AAAA-MM-DD)" });
|
||||||
|
const tz = biz.timezone || DEFAULT_TZ;
|
||||||
const service = db.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ? AND active = 1`).get(serviceId, biz.id) as any;
|
const service = db.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ? AND active = 1`).get(serviceId, biz.id) as any;
|
||||||
if (!service) return res.status(400).json({ error: "Servicio no válido" });
|
if (!service) return res.status(400).json({ error: "Servicio no válido" });
|
||||||
|
|
||||||
@@ -64,17 +88,16 @@ bookingRouter.get("/:slug/slots", (req, res) => {
|
|||||||
// Si el cliente no eligió especialista, usar el ranking para mostrar el mejor disponible por slot.
|
// Si el cliente no eligió especialista, usar el ranking para mostrar el mejor disponible por slot.
|
||||||
const useRanking = !employeeId;
|
const useRanking = !employeeId;
|
||||||
const candInfos = useRanking ? getCandidates(db, biz.id, serviceId) : [];
|
const candInfos = useRanking ? getCandidates(db, biz.id, serviceId) : [];
|
||||||
const candBusyMap = useRanking ? getExistingBusy(db, candInfos.map((c) => c.id), date) : new Map();
|
const candBusyMap = useRanking ? getExistingBusy(db, candInfos.map((c) => c.id), date, tz) : new Map();
|
||||||
|
|
||||||
// Para cuando sí se eligió especialista: ventana = horario de ese empleado (o negocio).
|
// Para cuando sí se eligió especialista: ventana = horario de ese empleado (o negocio).
|
||||||
const dateObj = new Date(`${date}T12:00:00`);
|
|
||||||
const singleEmpRow = employeeId
|
const singleEmpRow = employeeId
|
||||||
? (db.prepare(`SELECT working_hours wh FROM employees WHERE id = ?`).get(employeeId) as any)
|
? (db.prepare(`SELECT working_hours wh FROM employees WHERE id = ?`).get(employeeId) as any)
|
||||||
: null;
|
: null;
|
||||||
const singleEmpWh = singleEmpRow ? parseWorkingHours(singleEmpRow.wh) : null;
|
const singleEmpWh = singleEmpRow ? parseWorkingHours(singleEmpRow.wh) : null;
|
||||||
const window = employeeId
|
const window = employeeId
|
||||||
? (getWorkingHoursForDate(singleEmpWh as WorkingHoursMap | null, bizWh, dateObj) ?? getWorkingHoursForDate(null, bizWh, dateObj))
|
? (getWorkingHoursForDate(singleEmpWh as WorkingHoursMap | null, bizWh, date, tz) ?? getWorkingHoursForDate(null, bizWh, date, tz))
|
||||||
: getWorkingHoursForDate(null, bizWh, dateObj);
|
: getWorkingHoursForDate(null, bizWh, date, tz);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const slots: { time: string; iso: string; employee_id: number; employee_name: string }[] = [];
|
const slots: { time: string; iso: string; employee_id: number; employee_name: string }[] = [];
|
||||||
@@ -88,7 +111,7 @@ bookingRouter.get("/:slug/slots", (req, res) => {
|
|||||||
|
|
||||||
// citas existentes del día para el caso de especialista fijo
|
// citas existentes del día para el caso de especialista fijo
|
||||||
const existing = employeeId
|
const existing = employeeId
|
||||||
? (getExistingBusy(db, [employeeId], date).get(employeeId) ?? [])
|
? (getExistingBusy(db, [employeeId], date, tz).get(employeeId) ?? [])
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
for (let t = dayStart; t + dur * 60000 <= dayEnd; t += 30 * 60000) {
|
for (let t = dayStart; t + dur * 60000 <= dayEnd; t += 30 * 60000) {
|
||||||
@@ -97,7 +120,7 @@ bookingRouter.get("/:slug/slots", (req, res) => {
|
|||||||
const slotEnd = t + dur * 60000;
|
const slotEnd = t + dur * 60000;
|
||||||
let chosen: { id: number; name: string } | null = null;
|
let chosen: { id: number; name: string } | null = null;
|
||||||
if (useRanking) {
|
if (useRanking) {
|
||||||
chosen = pickBestSlotEmployee(candInfos, candBusyMap, bizWh, { name: service.name, category: service.category }, slotStart, slotEnd);
|
chosen = pickBestSlotEmployee(candInfos, candBusyMap, bizWh, { name: service.name, category: service.category }, slotStart, slotEnd, tz);
|
||||||
} else {
|
} else {
|
||||||
const busy = existing.some((b) => overlapsRange(slotStart, slotEnd, b.startMs, b.endMs));
|
const busy = existing.some((b) => overlapsRange(slotStart, slotEnd, b.startMs, b.endMs));
|
||||||
if (!busy) {
|
if (!busy) {
|
||||||
@@ -107,7 +130,9 @@ bookingRouter.get("/:slug/slots", (req, res) => {
|
|||||||
}
|
}
|
||||||
if (chosen) {
|
if (chosen) {
|
||||||
slots.push({
|
slots.push({
|
||||||
time: new Date(t).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", hour12: true }),
|
// La etiqueta se rinde en la tz del NEGOCIO: sin `timeZone` salía en la del
|
||||||
|
// proceso (UTC en producción) y no coincidía con la hora real del `iso`.
|
||||||
|
time: new Date(t).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", hour12: true, timeZone: tz }),
|
||||||
iso: new Date(t).toISOString(),
|
iso: new Date(t).toISOString(),
|
||||||
employee_id: chosen.id,
|
employee_id: chosen.id,
|
||||||
employee_name: chosen.name,
|
employee_name: chosen.name,
|
||||||
@@ -126,6 +151,7 @@ function overlapsRange(aStart: number, aEnd: number, bStart: number, bEnd: numbe
|
|||||||
bookingRouter.post("/:slug/book", (req, res) => {
|
bookingRouter.post("/:slug/book", (req, res) => {
|
||||||
const biz = publicBusiness(req.params.slug);
|
const biz = publicBusiness(req.params.slug);
|
||||||
if (!biz || !biz.booking_enabled) return res.status(404).json({ error: "No disponible" });
|
if (!biz || !biz.booking_enabled) return res.status(404).json({ error: "No disponible" });
|
||||||
|
const tz = biz.timezone || DEFAULT_TZ;
|
||||||
const { service_id, employee_id, start_at, client } = req.body ?? {};
|
const { service_id, employee_id, start_at, client } = req.body ?? {};
|
||||||
if (!service_id || !start_at || !client?.name) return res.status(400).json({ error: "Faltan datos (servicio, hora o nombre)" });
|
if (!service_id || !start_at || !client?.name) return res.status(400).json({ error: "Faltan datos (servicio, hora o nombre)" });
|
||||||
const service = db.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ? AND active = 1`).get(Number(service_id), biz.id) as any;
|
const service = db.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ? AND active = 1`).get(Number(service_id), biz.id) as any;
|
||||||
@@ -149,7 +175,7 @@ bookingRouter.post("/:slug/book", (req, res) => {
|
|||||||
const bizWh = parseWorkingHours(biz.working_hours) as any;
|
const bizWh = parseWorkingHours(biz.working_hours) as any;
|
||||||
const aa = autoAssign(db, {
|
const aa = autoAssign(db, {
|
||||||
businessId: biz.id, serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
|
businessId: biz.id, serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
|
||||||
startMs, endMs, bizWh,
|
startMs, endMs, bizWh, tz,
|
||||||
});
|
});
|
||||||
if (!aa) throw { status: 409, error: "ESE_HORARIO_OCUPADO" };
|
if (!aa) throw { status: 409, error: "ESE_HORARIO_OCUPADO" };
|
||||||
empId = aa.employeeId;
|
empId = aa.employeeId;
|
||||||
@@ -159,7 +185,7 @@ bookingRouter.post("/:slug/book", (req, res) => {
|
|||||||
const ok = db.prepare(`SELECT 1 FROM employee_services WHERE service_id = ? AND employee_id = ?`).get(service.id, empId);
|
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" };
|
if (!ok) throw { status: 400, error: "El especialista no ofrece este servicio" };
|
||||||
// guard anti doble reserva
|
// guard anti doble reserva
|
||||||
if (!isAvailable(db, empId, startMs, endMs)) throw { status: 409, error: "ESE_HORARIO_OCUPADO" };
|
if (!isAvailable(db, empId, startMs, endMs, tz)) throw { status: 409, error: "ESE_HORARIO_OCUPADO" };
|
||||||
reasons = ["Tu especialista elegido"];
|
reasons = ["Tu especialista elegido"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,13 +27,42 @@ check("tiene slug", !!slug, String(slug));
|
|||||||
const { json: pub } = await req("GET", `/public/${slug}`);
|
const { json: pub } = await req("GET", `/public/${slug}`);
|
||||||
check("public business", !!pub.business && Array.isArray(pub.services) && pub.services.length > 0);
|
check("public business", !!pub.business && Array.isArray(pub.services) && pub.services.length > 0);
|
||||||
const svc = pub.services[0];
|
const svc = pub.services[0];
|
||||||
const tomorrow = new Date(Date.now() + 86400000);
|
|
||||||
const date = tomorrow.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
// 3) Slots disponibles
|
// El paso "Elige un especialista" solo puede ofrecer a quien realmente da el servicio:
|
||||||
const { json: slotsRes } = await req("GET", `/public/${slug}/slots?service_id=${svc.id}&date=${date}`);
|
// si ofrece a otro, /slots responde 400 y el cliente queda atascado. Estas dos
|
||||||
check("slots devuelve lista", Array.isArray(slotsRes.slots));
|
// comprobaciones fijan el contrato que hace posible ese filtro en el cliente.
|
||||||
check("hay slots disponibles hoy/mañana", slotsRes.slots.length > 0, `got ${slotsRes.slots.length}`);
|
check(
|
||||||
|
"cada especialista publica sus service_ids",
|
||||||
|
pub.employees.every((e) => Array.isArray(e.service_ids)),
|
||||||
|
JSON.stringify(pub.employees.map((e) => e.service_ids)).slice(0, 120)
|
||||||
|
);
|
||||||
|
{
|
||||||
|
const target = pub.services.find((x) => pub.employees.some((e) => e.service_ids.includes(x.id)));
|
||||||
|
const offers = pub.employees.filter((e) => e.service_ids.includes(target.id));
|
||||||
|
const denies = pub.employees.filter((e) => !e.service_ids.includes(target.id));
|
||||||
|
const probe = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
|
||||||
|
const okRes = await req("GET", `/public/${slug}/slots?service_id=${target.id}&employee_id=${offers[0].id}&date=${probe}`);
|
||||||
|
check("especialista que SÍ ofrece el servicio → 200", okRes.status === 200, `status=${okRes.status}`);
|
||||||
|
if (denies.length) {
|
||||||
|
const badRes = await req("GET", `/public/${slug}/slots?service_id=${target.id}&employee_id=${denies[0].id}&date=${probe}`);
|
||||||
|
check("especialista que NO lo ofrece → 400 (por eso se filtra en la UI)", badRes.status === 400, `status=${badRes.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Slots disponibles.
|
||||||
|
// Se busca el próximo día ABIERTO en vez de asumir "mañana": el negocio demo cierra
|
||||||
|
// sábado y domingo, así que fijar mañana hacía que la suite fallara cada viernes y
|
||||||
|
// sábado por calendario, no por un defecto del producto.
|
||||||
|
let date = null;
|
||||||
|
let slotsRes = null;
|
||||||
|
for (let i = 1; i <= 8; i++) {
|
||||||
|
const d = new Date(Date.now() + i * 86400000).toISOString().slice(0, 10);
|
||||||
|
const r = await req("GET", `/public/${slug}/slots?service_id=${svc.id}&date=${d}`);
|
||||||
|
if (i === 1) check("slots devuelve lista", Array.isArray(r.json.slots));
|
||||||
|
if (Array.isArray(r.json.slots) && r.json.slots.length > 0) { date = d; slotsRes = r.json; break; }
|
||||||
|
}
|
||||||
|
check("hay slots en algún día abierto de la próxima semana", !!slotsRes, date ? `fecha ${date}` : "ningún día con slots");
|
||||||
|
if (!slotsRes) { console.error("Sin slots en 8 días: revisa working_hours del negocio demo."); process.exit(1); }
|
||||||
|
|
||||||
// 4) Reservar (auto-asignación, sin employee_id)
|
// 4) Reservar (auto-asignación, sin employee_id)
|
||||||
const slot = slotsRes.slots[0];
|
const slot = slotsRes.slots[0];
|
||||||
|
|||||||
+10
-2
@@ -1,4 +1,6 @@
|
|||||||
import { db } from "../db.ts";
|
import { db } from "../db.ts";
|
||||||
|
import { DEFAULT_WORKING_HOURS, uniqueSlug } from "../lib/businessDefaults.ts";
|
||||||
|
import { DEFAULT_TZ } from "../lib/time.ts";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { TEMPLATES, getTemplate, DEFAULT_TEMPLATE_KEY, type TemplateDef } from "../lib/templates.ts";
|
import { TEMPLATES, getTemplate, DEFAULT_TEMPLATE_KEY, type TemplateDef } from "../lib/templates.ts";
|
||||||
|
|
||||||
@@ -328,8 +330,14 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|||||||
let biz = db.prepare(`SELECT id FROM businesses WHERE name = ?`).get("Lumière Estética & Spa") as { id: number } | undefined;
|
let biz = db.prepare(`SELECT id FROM businesses WHERE name = ?`).get("Lumière Estética & Spa") as { id: number } | undefined;
|
||||||
if (!biz) {
|
if (!biz) {
|
||||||
biz = db
|
biz = db
|
||||||
.prepare(`INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status) VALUES (?, ?, ?, ?, ?, ?, 'trial', 'active') RETURNING id`)
|
.prepare(
|
||||||
.get("Lumière Estética & Spa", "Estética y Spa", "MXN", "$", "+52 55 1234 5678", "Av. Reforma 245, CDMX") as { id: number };
|
`INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status, slug, working_hours, timezone)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'trial', 'active', ?, ?, ?) RETURNING id`
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"Lumière Estética & Spa", "Estética y Spa", "MXN", "$", "+52 55 1234 5678", "Av. Reforma 245, CDMX",
|
||||||
|
uniqueSlug(db, "Lumière Estética & Spa"), DEFAULT_WORKING_HOURS, DEFAULT_TZ
|
||||||
|
) as { id: number };
|
||||||
seedBusiness({ businessId: biz.id, template: getTemplate(DEFAULT_TEMPLATE_KEY)!, ownerEmail: "[email protected]", ownerName: "Daniela Reyes" });
|
seedBusiness({ businessId: biz.id, template: getTemplate(DEFAULT_TEMPLATE_KEY)!, ownerEmail: "[email protected]", ownerName: "Daniela Reyes" });
|
||||||
console.log(`[seed] Created default business #${biz.id} with estetica-spa template + admin user.`);
|
console.log(`[seed] Created default business #${biz.id} with estetica-spa template + admin user.`);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -59,8 +59,12 @@ export function MonthCalendar({
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const move = (delta: number) => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + delta, 1));
|
const move = (delta: number) => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + delta, 1));
|
||||||
const firstOfMonth = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
|
// `new Date(y, m, 0)` es el ÚLTIMO día del mes anterior. Se comparaba contra el
|
||||||
const canPrev = !minDate || new Date(cursor.getFullYear(), cursor.getMonth(), 0) >= firstOfMonth;
|
// día 1 del mes visible, que siempre es mayor, así que `canPrev` era false
|
||||||
|
// permanentemente: como BookingPage siempre pasa `min={todayStr()}`, la flecha de
|
||||||
|
// "mes anterior" nunca se podía pulsar y el cliente que avanzaba de mes quedaba
|
||||||
|
// atrapado. La comparación correcta es contra `minDate`.
|
||||||
|
const canPrev = !minDate || new Date(cursor.getFullYear(), cursor.getMonth(), 0) >= minDate;
|
||||||
const canNext = !maxDate || new Date(cursor.getFullYear(), cursor.getMonth() + 2, 0) <= new Date(maxDate.getFullYear(), maxDate.getMonth() + 1, 0);
|
const canNext = !maxDate || new Date(cursor.getFullYear(), cursor.getMonth() + 2, 0) <= new Date(maxDate.getFullYear(), maxDate.getMonth() + 1, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+15
-2
@@ -24,11 +24,24 @@ export function formatDate(iso: string | null | undefined, opts: Intl.DateTimeFo
|
|||||||
return d.toLocaleDateString("es-MX", { day: "numeric", month: "short", year: "numeric", ...opts });
|
return d.toLocaleDateString("es-MX", { day: "numeric", month: "short", year: "numeric", ...opts });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTime(iso: string | null) {
|
/**
|
||||||
|
* Hora de un instante. `tz` fija la zona: en el panel se omite (el usuario está en
|
||||||
|
* el negocio), pero en la reserva pública es obligatoria — un cliente que reserva
|
||||||
|
* desde otra zona horaria debe ver la hora **del negocio**, no la suya.
|
||||||
|
*/
|
||||||
|
export function formatTime(iso: string | null, tz?: string) {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (isNaN(d.getTime())) return "—";
|
if (isNaN(d.getTime())) return "—";
|
||||||
return d.toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit" });
|
return d.toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", ...(tz ? { timeZone: tz } : {}) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hora del día (0-23) de un instante en la zona `tz`. Para agrupar mañana/tarde
|
||||||
|
* sin parsear la cadena ya localizada (que trae "p.m." y rompe un /PM/i). */
|
||||||
|
export function hourInTz(iso: string, tz: string): number {
|
||||||
|
const h = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", hour12: false, timeZone: tz }).format(new Date(iso));
|
||||||
|
const n = Number(h);
|
||||||
|
return Number.isFinite(n) ? n % 24 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRelative(iso: string | null) {
|
export function formatRelative(iso: string | null) {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface PublicBusiness {
|
|||||||
currency_symbol: string;
|
currency_symbol: string;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
address: string | null;
|
address: string | null;
|
||||||
|
/** Zona IANA del negocio. Toda hora mostrada al cliente se rinde en ella. */
|
||||||
|
timezone: string;
|
||||||
booking_enabled: boolean;
|
booking_enabled: boolean;
|
||||||
cancel_window_hours: number;
|
cancel_window_hours: number;
|
||||||
cancel_penalty_pct: number;
|
cancel_penalty_pct: number;
|
||||||
@@ -33,6 +35,8 @@ export interface PublicEmployee {
|
|||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
/** Servicios que este especialista sí ofrece. El paso 2 filtra por aquí. */
|
||||||
|
service_ids: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicBusinessResponse {
|
export interface PublicBusinessResponse {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
} from "../../lib/publicApi";
|
} from "../../lib/publicApi";
|
||||||
import { Spinner } from "../../components/ui";
|
import { Spinner } from "../../components/ui";
|
||||||
import { MonthCalendar } from "../../components/MonthCalendar";
|
import { MonthCalendar } from "../../components/MonthCalendar";
|
||||||
import { cn, formatCurrency, formatDate, formatTime } from "../../lib/format";
|
import { cn, formatCurrency, formatDate, formatTime, hourInTz } from "../../lib/format";
|
||||||
|
|
||||||
function todayStr() {
|
function todayStr() {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
@@ -82,6 +82,9 @@ export default function BookingPage() {
|
|||||||
const services = data?.services ?? [];
|
const services = data?.services ?? [];
|
||||||
const employees = data?.employees ?? [];
|
const employees = data?.employees ?? [];
|
||||||
|
|
||||||
|
// Toda hora que ve el cliente se rinde en la zona del NEGOCIO. Si se cae a la del
|
||||||
|
// navegador, alguien que reserva desde otro huso ve una hora que no es la de su cita.
|
||||||
|
const tz = business?.timezone || "America/Mexico_City";
|
||||||
const autoAssign = !!business?.auto_assign_specialist;
|
const autoAssign = !!business?.auto_assign_specialist;
|
||||||
const steps = useMemo(() => stepsFor(autoAssign), [autoAssign]);
|
const steps = useMemo(() => stepsFor(autoAssign), [autoAssign]);
|
||||||
const stepCount = steps.length;
|
const stepCount = steps.length;
|
||||||
@@ -92,6 +95,14 @@ export default function BookingPage() {
|
|||||||
[services, serviceId]
|
[services, serviceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Un especialista que no ofrece el servicio hace que /slots responda 400. Antes se
|
||||||
|
// listaban todos, así que el cliente podía elegir a alguien imposible y solo se
|
||||||
|
// enteraba un paso después, con un mensaje que le pedía cambiar la fecha.
|
||||||
|
const eligibleEmployees = useMemo(
|
||||||
|
() => (serviceId == null ? [] : employees.filter((e) => e.service_ids.includes(serviceId))),
|
||||||
|
[employees, serviceId]
|
||||||
|
);
|
||||||
|
|
||||||
const slotsQuery = useQuery({
|
const slotsQuery = useQuery({
|
||||||
queryKey: ["public", "slots", slug, serviceId, selectedDate, employeeId],
|
queryKey: ["public", "slots", slug, serviceId, selectedDate, employeeId],
|
||||||
queryFn: () => getSlots(slug!, serviceId!, selectedDate, employeeId ?? undefined),
|
queryFn: () => getSlots(slug!, serviceId!, selectedDate, employeeId ?? undefined),
|
||||||
@@ -211,6 +222,10 @@ export default function BookingPage() {
|
|||||||
setServiceId(id);
|
setServiceId(id);
|
||||||
setSelectedSlot(null);
|
setSelectedSlot(null);
|
||||||
setBookError(null);
|
setBookError(null);
|
||||||
|
// El especialista elegido puede no ofrecer el servicio nuevo: soltarlo en vez
|
||||||
|
// de arrastrar una combinación que el servidor rechazará.
|
||||||
|
const stillValid = employees.some((e) => e.id === employeeId && e.service_ids.includes(id));
|
||||||
|
if (!stillValid) setEmployeeId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -285,9 +300,10 @@ export default function BookingPage() {
|
|||||||
subtitle="¿Tienes una preferencia? O déjalo en cualquiera."
|
subtitle="¿Tienes una preferencia? O déjalo en cualquiera."
|
||||||
>
|
>
|
||||||
<EmployeeList
|
<EmployeeList
|
||||||
employees={employees}
|
employees={eligibleEmployees}
|
||||||
selectedId={employeeId}
|
selectedId={employeeId}
|
||||||
onPick={pickEmployee}
|
onPick={pickEmployee}
|
||||||
|
serviceName={selectedService?.name}
|
||||||
/>
|
/>
|
||||||
</StepShell>
|
</StepShell>
|
||||||
)}
|
)}
|
||||||
@@ -310,8 +326,10 @@ export default function BookingPage() {
|
|||||||
slots={slots}
|
slots={slots}
|
||||||
loading={slotsQuery.isLoading}
|
loading={slotsQuery.isLoading}
|
||||||
isError={slotsQuery.isError}
|
isError={slotsQuery.isError}
|
||||||
|
errorMessage={slotsQuery.error instanceof Error ? slotsQuery.error.message : undefined}
|
||||||
selectedSlot={selectedSlot}
|
selectedSlot={selectedSlot}
|
||||||
onPick={pickSlot}
|
onPick={pickSlot}
|
||||||
|
tz={tz}
|
||||||
/>
|
/>
|
||||||
</StepShell>
|
</StepShell>
|
||||||
)}
|
)}
|
||||||
@@ -349,8 +367,8 @@ export default function BookingPage() {
|
|||||||
summary={
|
summary={
|
||||||
<>
|
<>
|
||||||
<SummaryRow label="Servicio" value={selectedService.name} />
|
<SummaryRow label="Servicio" value={selectedService.name} />
|
||||||
<SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long" })} />
|
<SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long", timeZone: tz })} />
|
||||||
<SummaryRow label="Hora" value={formatTime(selectedSlot.iso)} />
|
<SummaryRow label="Hora" value={formatTime(selectedSlot.iso, tz)} />
|
||||||
{!autoAssign && <SummaryRow label="Especialista" value={selectedSlot.employee_name} />}
|
{!autoAssign && <SummaryRow label="Especialista" value={selectedSlot.employee_name} />}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -373,7 +391,7 @@ export default function BookingPage() {
|
|||||||
selectedService ? formatCurrency(selectedService.price, currency) : undefined
|
selectedService ? formatCurrency(selectedService.price, currency) : undefined
|
||||||
}
|
}
|
||||||
serviceName={selectedService?.name}
|
serviceName={selectedService?.name}
|
||||||
slotLabel={selectedSlot ? `${formatDate(selectedSlot.iso, { weekday: "short", day: "numeric", month: "short" })} · ${selectedSlot.time}` : undefined}
|
slotLabel={selectedSlot ? `${formatDate(selectedSlot.iso, { weekday: "short", day: "numeric", month: "short", timeZone: tz })} · ${formatTime(selectedSlot.iso, tz)}` : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FooterNote name={business.name} />
|
<FooterNote name={business.name} />
|
||||||
@@ -564,10 +582,12 @@ function EmployeeList({
|
|||||||
employees,
|
employees,
|
||||||
selectedId,
|
selectedId,
|
||||||
onPick,
|
onPick,
|
||||||
|
serviceName,
|
||||||
}: {
|
}: {
|
||||||
employees: { id: number; name: string; role: string; color: string }[];
|
employees: { id: number; name: string; role: string; color: string }[];
|
||||||
selectedId: number | null;
|
selectedId: number | null;
|
||||||
onPick: (id: number | null) => void;
|
onPick: (id: number | null) => void;
|
||||||
|
serviceName?: string;
|
||||||
}) {
|
}) {
|
||||||
const anyActive = selectedId === null;
|
const anyActive = selectedId === null;
|
||||||
return (
|
return (
|
||||||
@@ -596,6 +616,14 @@ function EmployeeList({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{employees.length === 0 && (
|
||||||
|
<p className="rounded-2xl border border-slate-200 bg-slate-50 p-3.5 text-sm text-slate-600">
|
||||||
|
{serviceName
|
||||||
|
? `Para ${serviceName} asignamos al especialista disponible.`
|
||||||
|
: "Asignamos al especialista disponible."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{employees.map((e) => {
|
{employees.map((e) => {
|
||||||
const active = e.id === selectedId;
|
const active = e.id === selectedId;
|
||||||
return (
|
return (
|
||||||
@@ -640,8 +668,10 @@ function DateTimePicker({
|
|||||||
slots,
|
slots,
|
||||||
loading,
|
loading,
|
||||||
isError,
|
isError,
|
||||||
|
errorMessage,
|
||||||
selectedSlot,
|
selectedSlot,
|
||||||
onPick,
|
onPick,
|
||||||
|
tz,
|
||||||
}: {
|
}: {
|
||||||
date: string;
|
date: string;
|
||||||
onDate: (d: string) => void;
|
onDate: (d: string) => void;
|
||||||
@@ -650,21 +680,22 @@ function DateTimePicker({
|
|||||||
slots: PublicSlot[];
|
slots: PublicSlot[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
selectedSlot: PublicSlot | null;
|
selectedSlot: PublicSlot | null;
|
||||||
onPick: (s: PublicSlot) => void;
|
onPick: (s: PublicSlot) => void;
|
||||||
|
tz: string;
|
||||||
}) {
|
}) {
|
||||||
|
// La hora sale del instante `iso` resuelto en la tz del negocio. Antes se parseaba
|
||||||
|
// `s.time`, ya localizado: es-MX rinde "05:00 p.m." y `/PM/i` no casa con "p.m.",
|
||||||
|
// así que TODOS los slots de la tarde se agrupaban bajo "Mañana".
|
||||||
const { morning, afternoon } = useMemo(() => {
|
const { morning, afternoon } = useMemo(() => {
|
||||||
const morning: PublicSlot[] = [];
|
const morning: PublicSlot[] = [];
|
||||||
const afternoon: PublicSlot[] = [];
|
const afternoon: PublicSlot[] = [];
|
||||||
for (const s of slots) {
|
for (const s of slots) {
|
||||||
const m = /(\d{1,2}):(\d{2})/.exec(s.time);
|
(hourInTz(s.iso, tz) < 12 ? morning : afternoon).push(s);
|
||||||
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 };
|
return { morning, afternoon };
|
||||||
}, [slots]);
|
}, [slots, tz]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:items-start md:gap-8">
|
<div className="md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:items-start md:gap-8">
|
||||||
@@ -695,7 +726,13 @@ function DateTimePicker({
|
|||||||
{!loading && isError && (
|
{!loading && isError && (
|
||||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||||
<AlertCircle className="h-6 w-6 text-rose-400" />
|
<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>
|
{/* Se muestra el motivo real del servidor. El texto fijo anterior
|
||||||
|
("intenta otra fecha") mandaba al cliente a probar días al azar
|
||||||
|
cuando el problema era el especialista, y ninguna fecha lo resolvía. */}
|
||||||
|
<p className="text-sm font-medium text-slate-700">
|
||||||
|
{errorMessage || "No pudimos cargar los horarios."}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400">Prueba con otro especialista u otra fecha.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!loading && !isError && slots.length === 0 && (
|
{!loading && !isError && slots.length === 0 && (
|
||||||
@@ -707,8 +744,8 @@ function DateTimePicker({
|
|||||||
)}
|
)}
|
||||||
{!loading && !isError && slots.length > 0 && (
|
{!loading && !isError && slots.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SlotGroup title="Mañana" slots={morning} selectedSlot={selectedSlot} onPick={onPick} />
|
<SlotGroup title="Mañana" slots={morning} selectedSlot={selectedSlot} onPick={onPick} tz={tz} />
|
||||||
<SlotGroup title="Tarde" slots={afternoon} selectedSlot={selectedSlot} onPick={onPick} />
|
<SlotGroup title="Tarde" slots={afternoon} selectedSlot={selectedSlot} onPick={onPick} tz={tz} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -722,11 +759,13 @@ function SlotGroup({
|
|||||||
slots,
|
slots,
|
||||||
selectedSlot,
|
selectedSlot,
|
||||||
onPick,
|
onPick,
|
||||||
|
tz,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
slots: PublicSlot[];
|
slots: PublicSlot[];
|
||||||
selectedSlot: PublicSlot | null;
|
selectedSlot: PublicSlot | null;
|
||||||
onPick: (s: PublicSlot) => void;
|
onPick: (s: PublicSlot) => void;
|
||||||
|
tz: string;
|
||||||
}) {
|
}) {
|
||||||
if (slots.length === 0) return null;
|
if (slots.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
@@ -750,7 +789,7 @@ function SlotGroup({
|
|||||||
: "border-slate-200 bg-white text-slate-700 hover:border-brand-400 hover:bg-brand-50"
|
: "border-slate-200 bg-white text-slate-700 hover:border-brand-400 hover:bg-brand-50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{slot.time}
|
{formatTime(slot.iso, tz)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user