From e8d2435cc2b7ebc156923efe463677012c874376 Mon Sep 17 00:00:00 2001 From: AgendaPro Dev Date: Sat, 25 Jul 2026 13:45:35 -0600 Subject: [PATCH] AgendaPro v1.0 - multi-tenant SaaS, mobile calendar, public booking Multi-tenant scheduling SaaS (AgendaPro-equivalent): - Backend: Node + Express + node:sqlite, multi-tenant (admin/owner/employee), versioned migrations, 4 templates (estetica-spa, barberia, clinica, blank). - Frontend: React 18 + TS + Vite + Tailwind, FullCalendar drag-and-drop, Recharts dashboard, mobile-first (day/list views on mobile). - Features: calendar+services+employees+clients+tickets, dashboard with best employee/service, top tickets, top/frequent clients, commissions, cancellation policy + no-show tracking, public online booking (/b/:slug), cash register (cierre de caja), reminders/notifications center. - Verification: 65/65 e2e API, 58/58 visual (0 overflow, 0 console errors), typecheck clean, vite build OK. --- .gitignore | 37 + AUDIT.md | 42 + README.md | 164 + admin-test.mjs | 83 + e2e-full.mjs | 214 + e2e-test.mjs | 60 + index.html | 18 + package-lock.json | 6109 ++++++++++++++++++ package.json | 63 + postcss.config.js | 6 + public/favicon.svg | 5 + server/db.ts | 320 + server/index.ts | 94 + server/lib/auth.ts | 50 + server/lib/templates.ts | 168 + server/routes/admin.ts | 210 + server/routes/appointments.ts | 303 + server/routes/auth.ts | 32 + server/routes/booking.ts | 154 + server/routes/business.ts | 16 + server/routes/cash.ts | 119 + server/routes/clients.ts | 111 + server/routes/dashboard.ts | 337 + server/routes/employees.ts | 136 + server/routes/notifications.ts | 147 + server/routes/services.ts | 113 + server/routes/settings.ts | 57 + server/scripts/seed.ts | 324 + shared/types.ts | 171 + src/App.tsx | 84 + src/components/AdminShell.tsx | 141 + src/components/AppShell.tsx | 163 + src/components/AppointmentModal.tsx | 593 ++ src/components/DemoSwitcher.tsx | 74 + src/components/Modal.tsx | 63 + src/components/admin/CreateBusinessModal.tsx | 239 + src/components/ui.tsx | 96 + src/index.css | 296 + src/lib/api.ts | 159 + src/lib/auth.tsx | 100 + src/lib/format.ts | 98 + src/lib/publicApi.ts | 119 + src/main.tsx | 26 + src/pages/CalendarPage.tsx | 314 + src/pages/CashPage.tsx | 260 + src/pages/ClientDetailPage.tsx | 243 + src/pages/ClientsPage.tsx | 152 + src/pages/DashboardPage.tsx | 576 ++ src/pages/EmployeesPage.tsx | 313 + src/pages/LoginPage.tsx | 191 + src/pages/NotificationsPage.tsx | 144 + src/pages/ServicesPage.tsx | 298 + src/pages/SettingsPage.tsx | 177 + src/pages/TicketsPage.tsx | 184 + src/pages/admin/AdminBusinessDetailPage.tsx | 272 + src/pages/admin/AdminBusinessesPage.tsx | 152 + src/pages/admin/AdminOverviewPage.tsx | 143 + src/pages/public/BookingPage.tsx | 862 +++ tailwind.config.js | 66 + tsconfig.json | 26 + tsconfig.node.json | 13 + visual-audit.mjs | 196 + vite.config.ts | 43 + 63 files changed, 16539 insertions(+) create mode 100644 .gitignore create mode 100644 AUDIT.md create mode 100644 README.md create mode 100644 admin-test.mjs create mode 100644 e2e-full.mjs create mode 100644 e2e-test.mjs create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 public/favicon.svg create mode 100644 server/db.ts create mode 100644 server/index.ts create mode 100644 server/lib/auth.ts create mode 100644 server/lib/templates.ts create mode 100644 server/routes/admin.ts create mode 100644 server/routes/appointments.ts create mode 100644 server/routes/auth.ts create mode 100644 server/routes/booking.ts create mode 100644 server/routes/business.ts create mode 100644 server/routes/cash.ts create mode 100644 server/routes/clients.ts create mode 100644 server/routes/dashboard.ts create mode 100644 server/routes/employees.ts create mode 100644 server/routes/notifications.ts create mode 100644 server/routes/services.ts create mode 100644 server/routes/settings.ts create mode 100644 server/scripts/seed.ts create mode 100644 shared/types.ts create mode 100644 src/App.tsx create mode 100644 src/components/AdminShell.tsx create mode 100644 src/components/AppShell.tsx create mode 100644 src/components/AppointmentModal.tsx create mode 100644 src/components/DemoSwitcher.tsx create mode 100644 src/components/Modal.tsx create mode 100644 src/components/admin/CreateBusinessModal.tsx create mode 100644 src/components/ui.tsx create mode 100644 src/index.css create mode 100644 src/lib/api.ts create mode 100644 src/lib/auth.tsx create mode 100644 src/lib/format.ts create mode 100644 src/lib/publicApi.ts create mode 100644 src/main.tsx create mode 100644 src/pages/CalendarPage.tsx create mode 100644 src/pages/CashPage.tsx create mode 100644 src/pages/ClientDetailPage.tsx create mode 100644 src/pages/ClientsPage.tsx create mode 100644 src/pages/DashboardPage.tsx create mode 100644 src/pages/EmployeesPage.tsx create mode 100644 src/pages/LoginPage.tsx create mode 100644 src/pages/NotificationsPage.tsx create mode 100644 src/pages/ServicesPage.tsx create mode 100644 src/pages/SettingsPage.tsx create mode 100644 src/pages/TicketsPage.tsx create mode 100644 src/pages/admin/AdminBusinessDetailPage.tsx create mode 100644 src/pages/admin/AdminBusinessesPage.tsx create mode 100644 src/pages/admin/AdminOverviewPage.tsx create mode 100644 src/pages/public/BookingPage.tsx create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 visual-audit.mjs create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fffd27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +dist-ssr/ +*.tsbuildinfo + +# Database (demo data, regenerated by seed) +data/ + +# Visual audit output (Playwright screenshots) +screenshots/ + +# Logs +*.log +server-out.log +server-err.log +dev-out.log +dev-err.log + +# Environment +.env +.env.local +.env.*.local + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ +*.swp + +# OpenCode runtime state (machine-local: sessions, goals) +.opencode/ diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 0000000..ef9d21a --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,42 @@ +# AgendaPro — Auditoría de funcionalidades (basada en investigación real) + +Fuentes: agendapro.com/mx (site + blog), comparativa Wellbe vs AgendaPro, artículo de política de cancelaciones (CEO Julio Guzmán). G2/Capterra/Trustpilot bloquearon scraping (403), pero el blog oficial expone casos de uso y pain points reales. + +## Lo que YA tenemos (vs AgendaPro real) +- ✅ Calendario drag & drop (mes/semana/día/lista) con auto-asignación a empleados +- ✅ Servicios, empleados, clientes + historial/ficha +- ✅ Tickets, pagos, propinas +- ✅ Dashboard dueño (mejor empleado/servicio, top tickets, top/frecuentes clientes, ingresos) +- ✅ Multi-tenant SaaS + consola admin + plantillas +- ✅ Reseñas/ratings + +## Brechas CRÍTICAS vs AgendaPro real (oportunidades de mejora) +1. **Política de cancelación + no-shows** — AgendaPro destaca esto; en MX la inasistencia es 15–35%. Nosotros solo marcamos estado, sin penalización ni depósito. **#1 pain real.** +2. **Comisiones** — cálculo automático por venta/servicio. Nosotros no lo tenemos. Pain operativo grande. +3. **Cierre de caja / flujo de caja** — apertura/cierre diario, ingresos/egresos. Nosotros solo listamos tickets. +4. **Página de reservas online pública (24/7)** — el cliente se auto-agenda. Nuestro mayor gap de adquisición. Reduce ruido de WhatsApp. +5. **Recordatorios automáticos** (WhatsApp/SMS/email) — reducen inasistencias. Nosotros no tenemos centro de notificaciones. +6. **Inventario** — control de productos con alertas de stock bajo. +7. **Lista de espera / waitlist** — para rellenar cancelaciones. +8. **Fidelización / giftcards / membresías** — paquetes y crédito. +9. **Encuestas de satisfacción (NPS)** — aparte de reseñas puntuales. +10. **Multi-sucursal** dentro de un negocio (ya tenemos multi-tenant, no multi-branch). +11. **Sincronización Google Calendar.** + +## Pain points reales de los usuarios (del blog/casos) +- No-shows = pérdida directa de ingresos (un salón perdía 15 citas/mes). +- Cierre de caja con descuadre ("menos dinero del que debería haber"). +- Cálculo manual de comisiones = error y tiempo. +- Inventario desordenado en salones. +- Cobranza incómoda ("recordatorios de pago"). +- Pérdida de clientes por falta de fidelización. +- Saturación de recepción y WhatsApps repetitivos preguntando horario/precio. + +## Priorización (impacto × factibilidad) — lo que implementaremos +- **P0 Política de cancelación + no-show** (company) — resuelve el dolor #1. +- **P0 Comisiones** (company/empleado) — dolor operativo top. +- **P0 Página pública de reservas /b/:slug** (cliente) — mayor diferenciador, reduce WhatsApp. +- **P1 Cierre de caja** (company) — control financiero del día. +- **P1 Centro de notificaciones/recordatorios** (company) — reduce no-shows (simulado, sin WhatsApp real). + +Perspectivas cubiertas: **cliente** (booking público), **empresa** (caja, comisiones, política, dashboard), **empleado** (sus comisiones, su agenda). diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e9bdd9 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# AgendaPro + +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**, +y un **tablero** con ingresos, mejores empleados, servicios más rentables y clientes top. + +Tres roles: **Administrador de plataforma** (crea y gestiona todos los negocios desde la consola admin), +**Dueño** (ve su negocio y el tablero) y **Empleado** (agenda citas que se auto-asignan a él). + +## Stack + +- **Frontend**: React 18 + TypeScript + Vite + Tailwind CSS +- **Calendario**: FullCalendar (drag & drop, resize, day/time grid) +- **Gráficas**: Recharts +- **Backend**: Node.js + Express +- **Base de datos**: SQLite (vía `node:sqlite`, nativo de Node — sin compilación nativa) + +## Requisitos + +- Node.js **22.5+** (recomendado 24+). Este proyecto usa el módulo nativo `node:sqlite`. + +## Instalación + +```bash +npm install +``` + +La base de datos se **siembra automáticamente** con datos demo la primera vez que arranca el servidor. +Para regenerarla desde cero: + +```bash +# Borra la DB y vuelve a sembrar +npm run seed +# o, forzando reset desde el servidor: +$env:RESET_DB="1"; npm run dev:server # PowerShell +RESET_DB=1 npm run dev:server # bash +``` + +## Desarrollo + +```bash +npm run dev +``` + +Abre http://localhost:5173 (el frontend proxya `/api` al backend en el puerto 3000). + +## Producción + +```bash +npm run build # compila TS y empaqueta el frontend en dist/ +npm start # sirve la API y el frontend estático en http://localhost:3000 +``` + +## Tests y auditoría visual + +```bash +npm run test:e2e # 22 pruebas end-to-end de la API (login, CRUD, dashboard, auto-asignación, roles, multi-tenant) +npm run test:admin # 17 pruebas del admin (crear negocio + plantilla, aislamiento, reset-demo, delete cascade) +npm run audit:visual # Playwright: navega 10 páginas × 4 viewports (móvil/tablet/desktop/wide), + # mide overflow horizontal, captura errores de consola y guarda capturas en screenshots/ +``` + +La auditoría visual verifica responsividad en 375 / 768 / 1280 / 1536 px, detecta overflow, +errores de consola y confirma que el calendario, el modal de cita y el cambio de sesión funcionan en cada tamaño. + +## Cuentas demo + +Todas usan la contraseña **`demo1234`**. + +| Rol | Email | Nombre | +|----------|----------------------------------|------------------| +| **Admin**| `admin@agendapro.demo` | Administrador | +| Dueño | `owner@agendapro.demo` | Daniela Reyes | +| Empleado | `valentina.cruz@lumiere.mx` | Valentina Cruz | +| Empleado | `mateo.herrera@lumiere.mx` | Mateo Herrera | +| Empleado | `sofia.ramirez@lumiere.mx` | Sofía Ramírez | +| Empleado | `diego.castillo@lumiere.mx` | Diego Castillo | +| Empleado | `isabela.torres@lumiere.mx` | Isabela Torres | +| Empleado | `carolina.mendez@lumiere.mx` | Carolina Méndez | + +> En la pantalla de login aparecen botones de acceso rápido a las cuentas demo, +> y desde la barra lateral puedes cambiar de cuenta con **“Ver como…”**. + +## Multi-tenant y consola de administrador + +AgendaPro 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`: + +- **Resumen**: KPIs globales (negocios activos, usuarios, ingresos y citas de toda la plataforma). +- **Negocios**: lista buscable con stats por negocio (usuarios, citas, ingresos). +- **Crear negocio** (2 pasos): eliges una **plantilla** → se cargan empleados, servicios y clientes demo + con historial de citas/tickets listos para usar; defines los datos del negocio y la cuenta del dueño. +- **Detalle de negocio**: editar nombre/industria, cambiar **plan** (Prueba/Pro/Free) y **estado** + (Activo/Suspendido), **recargar datos demo** con otra plantilla (preserva las cuentas de usuario) + o **eliminar** el negocio por completo (con cascade de todos sus datos). + +**Plantillas disponibles**: `estetica-spa` (Lumière), `barberia` (Urban Cut), `clinica` (Dental Sonrisa) +y `blank` (vacío, para configurar desde cero). Defínelas en `server/lib/templates.ts`. + +El aislamiento entre tenants se valida: un dueño solo ve los datos de su negocio, y los empleados +no pueden acceder al tablero ni a la gestión (403). + +## Funcionalidades + +### Calendario (Dueño y Empleado) +- Vistas de mes / semana / día, con indicador de hora actual. +- **En móvil** se abre por defecto en **vista Día** y se ofrece una **vista Lista** (agenda) — + la vista Semana/Mes queda reservada para tablet/desktop para evitar columnas aplastadas. +- **Arrastra** para reagendar y **redimensiona** para cambiar duración. +- Clic en un hueco o en **Nueva cita** para abrir el formulario. +- Clic en una cita para **editarla**: cambiar servicio, especialista, cliente, hora, precio, notas. +- **Marcar como completada** (genera un ticket con propina y método de pago). +- **Cancelar** o **eliminar** citas. +- Filtra por **empleado** y por **estado** (programada / completada / cancelada). + +### Formulario de cita +- **Servicio** desplegable (menú del negocio), con precio y duración. +- **Especialista**: el empleado lo tiene auto-asignado por defecto (filtrado a quienes ofrecen el servicio). +- **Cliente**: buscar **existente** o crear uno **nuevo** al vuelo. +- Duración, precio y notas editables. + +### Tablero (Dueño) +- KPIs: ingresos del período, citas programadas, ticket promedio, ocupación y % de cancelación. +- Gráfica de **ingresos diarios** y **distribución por categoría**. +- **Mejores empleados** por ingreso, con rating y utilización. +- **Servicios más rentables**. +- **Tickets más altos** recientes. +- **Top clientes** (por gasto) y **clientes frecuentes** (por visitas). +- Rango de tiempo ajustable (7 / 30 / 60 días). + +### Gestión (Dueño) +- **Empleados**: crear, editar, asignar servicios, ver ingresos/citas/rating. +- **Servicios**: catálogo agrupado por categoría, con especialistas asignados. +- **Tickets**: historial de ventas con filtros por método de pago. + +### Clientes (Dueño y Empleado) +- Buscador por nombre / teléfono / correo. +- Ficha del cliente con historial de citas, gasto total y visitas. + +## Estructura + +``` +. +├── server/ # API Express + SQLite +│ ├── db.ts # esquema multi-tenant + sistema de migraciones versionado +│ ├── index.ts # app Express (auto-siembla admin + negocio demo en primer arranque) +│ ├── lib/auth.ts # middlewares: authRequired, ownerOnly, adminOnly +│ ├── lib/templates.ts # plantillas: estetica-spa, barberia, clinica, blank +│ ├── routes/ # auth, business, employees, services, clients, appointments, dashboard, admin +│ └── scripts/seed.ts# seedBusiness(businessId, template) reutilizable + script standalone +├── shared/types.ts # tipos TS compartidos cliente/servidor +├── src/ # frontend React +│ ├── components/ # AppShell, AdminShell, Modal, AppointmentModal, ui, DemoSwitcher, admin/CreateBusinessModal +│ ├── lib/ # api, auth, format +│ └── pages/ # Login, Dashboard, Calendar, Employees, Services, Clients, Tickets + admin/* +└── data/ # SQLite (generado, .gitignored) +``` + +## Notas de seguridad + +Es una **demo**. La autenticación usa un token trivial (el id del usuario) sin hashing de contraseña +ni sesiones reales — **no usar en producción** tal cual. Antes de un despliegue real habría que añadir + hashing (bcrypt), JWT firmados, validación estricta con zod, rate-limiting y HTTPS. diff --git a/admin-test.mjs b/admin-test.mjs new file mode 100644 index 0000000..5df684b --- /dev/null +++ b/admin-test.mjs @@ -0,0 +1,83 @@ +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(` ✓ ${name}`); } + else { fail++; console.log(` ✗ ${name} ${extra}`); } +} + +// Admin login +const { json: aLogin } = await req("POST", "/auth/login", { email: "admin@agendapro.demo", password: "demo1234" }); +check("admin login", aLogin.user?.role === "admin" && aLogin.token, JSON.stringify(aLogin).slice(0, 100)); +const A = aLogin.token; + +// Existing data preserved (resilient count — other test suites may add appointments) +const { json: list } = await req("GET", "/admin/businesses", null, A); +check("admin lists businesses", list.businesses?.length >= 1, `got ${list.businesses?.length}`); +check("biz1 data intact", list.businesses[0]?.stats?.appointments >= 100, `appts=${list.businesses[0]?.stats?.appointments}`); + +// Platform overview +const { json: ov } = await req("GET", "/admin/overview", null, A); +check("platform overview", ov.businesses >= 1 && ov.total_appointments >= 100, JSON.stringify(ov)); + +// Templates +const { json: tpls } = await req("GET", "/admin/templates", null, A); +check("templates list", tpls.templates?.length === 4, `got ${tpls.templates?.length}`); + +// Create a barberia business with template + owner +const { json: created, status: cStatus } = await req("POST", "/admin/businesses", { + name: "Barbería Test", + industry: "Barbería", + template: "barberia", + ownerName: "Test Owner", + ownerEmail: "owner.test@barberia.mx", + ownerPassword: "demo1234", +}, A); +check("create business + template seed", cStatus === 201 && !!created.business?.id, JSON.stringify(created).slice(0, 150)); +check("barberia seeded services", created.seeded?.services === 8, `got ${created.seeded?.services}`); +const newBizId = created.business?.id; + +// Owner of new business can log in and see only their data (tenant isolation) +const { json: oLogin } = await req("POST", "/auth/login", { email: "owner.test@barberia.mx", password: "demo1234" }); +check("new owner login", oLogin.user?.role === "owner" && oLogin.user?.business_id === newBizId); +const OT = oLogin.token; +const { json: oEmps } = await req("GET", "/employees", null, OT); +check("tenant isolation: new owner sees only their employees", oEmps.employees?.length === 4, `got ${oEmps.employees?.length}`); +const { json: oBiz } = await req("GET", "/business", null, OT); +check("new owner business name", oBiz.business?.name === "Barbería Test"); + +// Owner1 still sees only Lumière (isolation the other way) +const { json: owner1 } = await req("POST", "/auth/login", { email: "owner@agendapro.demo", password: "demo1234" }); +const { json: o1Emps } = await req("GET", "/employees", null, owner1.token); +check("owner1 isolation: still 6 employees", o1Emps.employees?.length === 6, `got ${o1Emps.employees?.length}`); + +// Admin cannot be blocked, but owner cannot access admin +const { status: forb } = await req("GET", "/admin/businesses", null, owner1.token); +check("owner blocked from admin (403)", forb === 403); + +// Update business (plan/status) +const { json: upd } = await req("PATCH", `/admin/businesses/${newBizId}`, { plan: "pro", status: "suspended" }, A); +check("update business plan/status", upd.business?.plan === "pro" && upd.business?.status === "suspended"); + +// Reset demo +const { json: reset } = await req("POST", `/admin/businesses/${newBizId}/reset-demo`, { template: "blank" }, A); +check("reset to blank template", reset.business?.template === "blank" && reset.stats?.services === 0, JSON.stringify(reset.stats)); + +// Delete business +const { status: del } = await req("DELETE", `/admin/businesses/${newBizId}`, null, A); +check("delete business", del === 200); +const { json: list2 } = await req("GET", "/admin/businesses", null, A); +check("business removed", list2.businesses?.length === 1, `got ${list2.businesses?.length}`); +// Cascade: owner.test user should be gone +const { status: gone } = await req("POST", "/auth/login", { email: "owner.test@barberia.mx", password: "demo1234" }); +check("deleted business users removed", gone === 401); + +console.log(`\n${pass}/${pass + fail} passed${fail ? `, ${fail} FAILED` : ""}`); +process.exit(fail ? 1 : 0); diff --git a/e2e-full.mjs b/e2e-full.mjs new file mode 100644 index 0000000..4ce3689 --- /dev/null +++ b/e2e-full.mjs @@ -0,0 +1,214 @@ +// Comprehensive e2e: ALL features (existing + new) must pass before declaring 100% functional. +const BASE = "http://localhost:5173"; +let pass = 0, fail = 0; +const failures = []; +async function req(path, init = {}) { + const url = path.startsWith("http") ? path : `${BASE}${path}`; + const res = await fetch(url, init); + const text = await res.text(); + let json; try { json = JSON.parse(text); } catch { json = text; } + return { status: res.status, json, headers: res.headers }; +} +function check(name, cond, extra = "") { + if (cond) { pass++; console.log(` ✓ ${name}`); } + else { fail++; failures.push(name + (extra ? " :: " + extra : "")); console.log(` ✗ ${name} ${extra}`); } +} +async function login(email, password = "demo1234") { + const r = await req("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }) }); + if (r.status !== 200) throw new Error(`login ${email} failed: ${r.status} ${JSON.stringify(r.json)}`); + return r.json; +} +const H = (token) => ({ authorization: `Bearer ${token}`, "Content-Type": "application/json" }); + +console.log("\n=== HEALTH & AUTH ==="); +const h = await req("/api/health"); check("health", h.json?.ok === true); +const me = await req("/api/auth/demo-users"); check("demo-users >= 8", me.json.users.length >= 8); + +const owner = await login("owner@agendapro.demo"); check("owner login", !!owner.token && owner.user.role === "owner"); +const empLogin = await login("valentina.cruz@lumiere.mx"); check("employee login", empLogin.user.role === "employee" && empLogin.user.employee_id); +const adminLogin = await login("admin@agendapro.demo"); check("admin login", adminLogin.user.role === "admin"); +const t = owner.token, eT = empLogin.token, aT = adminLogin.token; + +// /me fix +const meR = await req("/api/auth/me", { headers: H(t) }); check("/me returns owner", meR.json.user.email === "owner@agendapro.demo"); + +console.log("\n=== EXISTING: business / employees / services / clients / appointments / dashboard ==="); +const biz = await req("/api/business", { headers: H(t) }); check("business loaded", biz.json.business?.name === "Lumière Estética & Spa"); +const emps = await req("/api/employees", { headers: H(t) }); check("employees list", emps.json.employees.length === 6 && emps.json.employees[0].stats?.revenue_total >= 0); +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 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); +const tc = await req("/api/dashboard/top-clients", { headers: H(t) }); check("top-clients", tc.json.clients.length > 0); +const fc = await req("/api/dashboard/frequent-clients", { headers: H(t) }); check("frequent-clients", fc.json.clients.length > 0); +const rt = await req("/api/dashboard/revenue-trend?days=30", { headers: H(t) }); check("revenue-trend 30 points", rt.json.points.length === 30); +const rb = await req("/api/dashboard/revenue-by-category", { headers: H(t) }); check("revenue-by-category", rb.json.slices.length > 0); +const tk = await req("/api/dashboard/tickets?limit=20", { headers: H(t) }); check("tickets list", tk.json.tickets.length > 0); + +console.log("\n=== NEW: COMMISSIONS ==="); +const com = await req("/api/dashboard/commissions?range=60", { headers: H(t) }); +check("commissions has rows", com.json.rows?.length === 6); +check("commissions total > 0 (after backfill)", com.json.total > 0); +const topEmp = com.json.rows[0]; +check("top employee has commission > 0", topEmp.commission > 0 && topEmp.sales > 0); + +// Lifecycle creates commission on complete +const createR = await req("/api/appointments", { + method: "POST", headers: H(t), + body: JSON.stringify({ service_id: svcs.json.services[0].id, client_id: cls.json.clients[0].id, start_at: "2026-09-01T10:00:00Z" }), +}); +check("create appointment", !!createR.json.appointment?.id); +const completed = await req(`/api/appointments/${createR.json.appointment.id}/complete`, { + method: "POST", headers: H(t), body: JSON.stringify({ tip: 50, payment_method: "card" }), +}); +check("complete -> status completed", completed.json.appointment.status === "completed"); +// ticket should have commission > 0 +const tkAfter = await req(`/api/dashboard/tickets?limit=5`, { headers: H(t) }); +const ourTicket = tkAfter.json.tickets.find((t2) => t2.service?.id === svcs.json.services[0].id && t2.amount > 0); +check("new ticket has commission > 0", ourTicket && ourTicket.commission > 0, `ticket=${JSON.stringify(ourTicket)}`); + +console.log("\n=== NEW: CANCELLATION POLICY ==="); +const settings = await req("/api/settings", { headers: H(t) }); +check("settings loaded", settings.json.settings?.cancel_window_hours !== undefined); +check("booking slug set", settings.json.settings?.slug === "lumiere-estetica-spa"); +// Update policy (24h, 50% penalty) +const upd = await req("/api/settings", { + method: "PATCH", headers: H(t), + body: JSON.stringify({ cancel_window_hours: 24, cancel_penalty_pct: 50 }), +}); +check("update cancellation policy", upd.json.settings.cancel_penalty_pct === 50); +// Policy preview: appointment 1h from now should be within 24h window -> penalty +const nearAppt = await req("/api/appointments", { + method: "POST", headers: H(t), + body: JSON.stringify({ service_id: svcs.json.services[0].id, client_id: cls.json.clients[0].id, start_at: new Date(Date.now() + 60 * 60000).toISOString() }), +}); +check("created near-future appt for policy test", !!nearAppt.json.appointment?.id); +const policy = await req(`/api/appointments/${nearAppt.json.appointment.id}/cancellation-policy`, { headers: H(t) }); +check("policy preview within window", policy.json.within_window === true && policy.json.penalty_amount > 0); +// Far future appointment should be outside window +const farAppt = await req("/api/appointments", { + method: "POST", headers: H(t), + body: JSON.stringify({ service_id: svcs.json.services[0].id, client_id: cls.json.clients[0].id, start_at: new Date(Date.now() + 48 * 3600000).toISOString() }), +}); +const policyFar = await req(`/api/appointments/${farAppt.json.appointment.id}/cancellation-policy`, { headers: H(t) }); +check("policy preview outside window (no penalty)", policyFar.json.within_window === false && policyFar.json.penalty_amount === 0); +// Restore default 0% penalty for downstream tests +await req("/api/settings", { method: "PATCH", headers: H(t), body: JSON.stringify({ cancel_penalty_pct: 0 }) }); + +console.log("\n=== NEW: PUBLIC BOOKING ==="); +const pubInfo = await req(`${BASE}/api/public/lumiere-estetica-spa`); +check("public business info", pubInfo.json.business?.name === "Lumière Estética & Spa" && pubInfo.json.services.length === 12 && pubInfo.json.employees.length === 6); +const pubSlots = await req(`${BASE}/api/public/lumiere-estetica-spa/slots?service_id=1&date=2026-08-12`); +check("public slots > 0", pubSlots.json.slots?.length > 0); +const slotIso = pubSlots.json.slots[0].iso; +// Unique phone per run so the test is idempotent across runs +const uniq = Date.now().toString().slice(-8); +// Public book +const book = await req(`${BASE}/api/public/lumiere-estetica-spa/book`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + service_id: 1, employee_id: pubSlots.json.slots[0].employee_id, start_at: slotIso, + client: { name: "Cliente Web Test " + uniq, phone: "+52 55 " + uniq + " 1111", email: `webtest${uniq}@demo.com`, notes: "Online booking test" }, + }), +}); +check("public booking 201 + client_created", book.status === 201 && book.json.client_created === true && !!book.json.appointment?.id, `status=${book.status} body=${JSON.stringify(book.json).slice(0,160)}`); +// Verify the booking shows up in owner appointments (via the new client's history) +const webClient = (await req("/api/clients?q=" + uniq, { headers: H(t) })).json.clients.find((c) => c.phone && c.phone.includes(uniq)); +check("web client created & searchable", !!webClient); +// Booking-disabled gate +await req("/api/settings", { method: "PATCH", headers: H(t), body: JSON.stringify({ booking_enabled: 0 }) }); +const pubDis = await req(`${BASE}/api/public/lumiere-estetica-spa`); +check("booking disabled -> 403", pubDis.status === 403); +await req("/api/settings", { method: "PATCH", headers: H(t), body: JSON.stringify({ booking_enabled: 1 }) }); +// Invalid slug +const pubBad = await req(`${BASE}/api/public/no-such-slug`); +check("invalid slug -> 404", pubBad.status === 404); + +console.log("\n=== NEW: CASH REGISTER ==="); +// Defensive: close any leftover open session from previous runs +const sess0 = await req("/api/cash/session", { headers: H(t) }); +if (sess0.json.session) { + await req("/api/cash/close", { method: "POST", headers: H(t), body: JSON.stringify({ closing_amount: 0 }) }); + console.log(" (cleaned leftover open session)"); +} +const sessCheck = await req("/api/cash/session", { headers: H(t) }); +check("no open session initially", sessCheck.json.session === null); +const open = await req("/api/cash/open", { method: "POST", headers: H(t), body: JSON.stringify({ opening_amount: 500, note: "Apertura test" }) }); +check("open session 201", open.status === 201 && open.json.session?.status === "open", `status=${open.status} body=${JSON.stringify(open.json).slice(0,160)}`); +const open2 = await req("/api/cash/open", { method: "POST", headers: H(t), body: JSON.stringify({ opening_amount: 100 }) }); +check("cannot open second session (400)", open2.status === 400); +const entry1 = await req("/api/cash/entries", { method: "POST", headers: H(t), body: JSON.stringify({ type: "income", amount: 300, concept: "Venta producto", payment_method: "cash" }) }); +check("add income entry", entry1.status === 201 && entry1.json.entry?.type === "income", `status=${entry1.status} body=${JSON.stringify(entry1.json).slice(0,160)}`); +const entry2 = await req("/api/cash/entries", { method: "POST", headers: H(t), body: JSON.stringify({ type: "expense", amount: 50, concept: "Insumos", payment_method: "cash" }) }); +check("add expense entry", entry2.status === 201, `status=${entry2.status}`); +const sess = await req("/api/cash/session", { headers: H(t) }); +check("session stats: income=300 expense=50 expected=750", sess.json.stats?.income === 300 && sess.json.stats?.expense === 50, `stats=${JSON.stringify(sess.json.stats)} session=${sess.json.session?.id}`); +const close = await req("/api/cash/close", { method: "POST", headers: H(t), body: JSON.stringify({ closing_amount: 750 }) }); +check("close session (perfect cuadre)", close.status === 200 && close.json.expected === 750 && close.json.session.status === "closed", `status=${close.status}`); +const sessAfter = await req("/api/cash/session", { headers: H(t) }); +check("no open session after close", sessAfter.json.session === null); + +console.log("\n=== NEW: NOTIFICATIONS ==="); +const nstats = await req("/api/notifications/stats", { headers: H(t) }); +check("notifications stats", nstats.json.pending >= 0 && nstats.json.upcoming_reminders >= 0); +const regen = await req("/api/notifications/regenerate", { method: "POST", headers: H(t) }); +check("regenerate notifications", regen.json.ok === true); +const list = await req("/api/notifications?status=pending", { headers: H(t) }); +check("notifications list pending", list.json.notifications.length > 0); +const firstN = list.json.notifications[0]; +const send = await req(`/api/notifications/${firstN.id}/send`, { method: "POST", headers: H(t) }); +check("send notification (simulated)", send.json.ok === true); +const listSent = await req("/api/notifications?status=sent", { headers: H(t) }); +check("notification now sent", listSent.json.notifications.some((n) => n.id === firstN.id)); + +console.log("\n=== MULTI-TENANT ISOLATION ==="); +const newBiz = await req("/api/admin/businesses", { + method: "POST", headers: H(aT), + body: JSON.stringify({ name: "Audit Barbería", template: "barberia", ownerName: "Audit Owner", ownerEmail: "audit@barberia.mx" }), +}); +check("admin creates new biz + template seed", newBiz.status === 201 && newBiz.json.seeded?.services === 8); +const newOwner = await login("audit@barberia.mx"); +check("new owner login", newOwner.user.role === "owner" && newOwner.user.business_id === newBiz.json.business.id); +const isoEmps = await req("/api/employees", { headers: H(newOwner.token) }); +check("isolation: new owner sees only 4 employees", isoEmps.json.employees.length === 4); +const isoBiz = await req("/api/business", { headers: H(newOwner.token) }); +check("isolation: new owner sees only their business", isoBiz.json.business.name === "Audit Barbería"); +// Owner1 (Lumière) still 6 employees +const o1Emps = await req("/api/employees", { headers: H(t) }); +check("isolation: Lumière owner still 6 employees", o1Emps.json.employees.length === 6); +// Owner blocked from admin +const forb = await req("/api/admin/businesses", { headers: H(t) }); +check("owner blocked from admin (403)", forb.status === 403); +// Employee auto-assign still works +const empAppt = await req("/api/appointments", { + method: "POST", headers: H(eT), + body: JSON.stringify({ service_id: 1, client_id: cls.json.clients[0].id, start_at: "2026-10-01T10:00:00Z" }), +}); +check("employee auto-assign (matches emp_id)", empAppt.json.appointment?.employee_id === empLogin.user.employee_id); +// Cleanup test business +const del = await req(`/api/admin/businesses/${newBiz.json.business.id}`, { method: "DELETE", headers: H(aT) }); +check("admin delete business (cascade)", del.status === 200); +const gone = await req("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "audit@barberia.mx", password: "demo1234" }) }); +check("deleted business users removed", gone.status === 401); + +console.log("\n=== AUTH GUARDS ==="); +const badLogin = await req("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: "x@x.com", password: "wrong" }) }); +check("bad login 401", badLogin.status === 401); +const noAuth = await req("/api/employees"); check("no auth 401", noAuth.status === 401); +const empForb = await req("/api/dashboard/overview", { headers: H(eT) }); +check("employee blocked from dashboard 403", empForb.status === 403); +const empDash = await req("/api/dashboard/commissions", { headers: H(eT) }); +check("employee blocked from commissions 403", empDash.status === 403); +const empCash = await req("/api/cash/session", { headers: H(eT) }); +check("employee blocked from cash 403", empCash.status === 403); + +console.log(`\n${pass}/${pass + fail} passed`); +if (fail) { + console.log("\nFAILURES:"); + failures.forEach((f) => console.log(" •", f)); + process.exit(1); +} +process.exit(0); \ No newline at end of file diff --git a/e2e-test.mjs b/e2e-test.mjs new file mode 100644 index 0000000..3e2094b --- /dev/null +++ b/e2e-test.mjs @@ -0,0 +1,60 @@ +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(` ✓ ${name}`); } + else { fail++; console.log(` ✗ ${name} ${extra}`); } +} + +// /me now works (the bug we fixed) — login first to get a real token +const { json: login } = await req("POST", "/auth/login", { email: "owner@agendapro.demo", password: "demo1234" }); +check("login owner", !!login.token && login.user?.role === "owner", JSON.stringify(login).slice(0, 100)); +const t = login.token; +const { json: me } = await req("GET", "/auth/me", null, t); +check("/api/auth/me returns user", me.user?.email === "owner@agendapro.demo", JSON.stringify(me).slice(0, 100)); + +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], + ["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], + ["top-clients", "/dashboard/top-clients", (j) => j.clients?.length > 0], + ["frequent-clients", "/dashboard/frequent-clients", (j) => j.clients?.length > 0], + ["revenue-trend", "/dashboard/revenue-trend?days=30", (j) => j.points?.length === 30], + ["revenue-by-category", "/dashboard/revenue-by-category", (j) => j.slices?.length > 0], + ["tickets list", "/dashboard/tickets", (j) => j.tickets?.length > 0], +]; +for (const [name, path, cond] of checks) { + const { json, status } = await req("GET", path, null, t); + check(name, status === 200 && cond(json), `status=${status}`); +} + +// Lifecycle +const { json: created } = await req("POST", "/appointments", { service_id: 1, client_id: 1, start_at: "2026-09-15T11:00:00Z" }, 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: "mateo.herrera@lumiere.mx", 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); +const { status: badLogin } = await req("POST", "/auth/login", { email: "x@x.com", password: "wrong" }); +check("bad login 401", badLogin === 401); +const { status: noAuth } = await req("GET", "/employees"); +check("no auth 401", noAuth === 401); +const { status: empForb } = await req("GET", "/dashboard/overview", null, empLogin.token); +check("employee blocked dashboard 403", empForb === 403); + +console.log(`\n${pass}/${pass + fail} passed${fail ? `, ${fail} FAILED` : ""}`); +process.exit(fail ? 1 : 0); diff --git a/index.html b/index.html new file mode 100644 index 0000000..8e22776 --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + AgendaPro — Gestión visual de tu negocio + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c334a5d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6109 @@ +{ + "name": "agenda-pro", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agenda-pro", + "version": "1.0.0", + "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@fullcalendar/core": "^6.1.15", + "@fullcalendar/daygrid": "^6.1.15", + "@fullcalendar/interaction": "^6.1.15", + "@fullcalendar/list": "^6.1.21", + "@fullcalendar/react": "^6.1.15", + "@fullcalendar/timegrid": "^6.1.15", + "@tanstack/react-query": "^5.59.0", + "clsx": "^2.1.1", + "cors": "^2.8.5", + "express": "^4.21.0", + "lucide-react": "^0.451.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2", + "recharts": "^2.12.7", + "zod": "^3.23.8" + }, + "devDependencies": { + "@playwright/test": "^1.62.0", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^22.7.4", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "autoprefixer": "^10.4.20", + "concurrently": "^9.0.1", + "cross-env": "^7.0.3", + "eslint": "^9.12.0", + "playwright": "^1.62.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", + "tsx": "^4.19.1", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fullcalendar/core": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.21.tgz", + "integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==", + "license": "MIT", + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.21.tgz", + "integrity": "sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.21.tgz", + "integrity": "sha512-WPYpqtljDWmU0Xm2cOtFrLlocgxv7cgkOppj34Q6OUUat8a6Cnd6kYo2JR+irP223PE5lBYHFNp1qh7SIpJc0w==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21" + } + }, + "node_modules/@fullcalendar/list": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.21.tgz", + "integrity": "sha512-2rpIhs5pJmV7jyk4oX4bckNqurt6iHcsweE3FDYDdNpmRukPrARnyQYcaVFNVw2bnBFeR/jQW/St2MlauxF3GQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21" + } + }, + "node_modules/@fullcalendar/react": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.21.tgz", + "integrity": "sha512-TLpmGUd5k/PMdCh8XbeFC9PW9wuGvMms1oCxWgXyjK3EFPXAAd0PLfcvwKdyxoAS5eK1E4RJFkjMHvsYHpimcg==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21", + "react": "^16.7.0 || ^17 || ^18 || ^19", + "react-dom": "^16.7.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/@fullcalendar/timegrid": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.21.tgz", + "integrity": "sha512-2DnShx/jallGmb8QCkr6pAOu/zuPhJrP7+uTrAtSnbqsX7GF3lTxqSeNGkTQwsgF5g/ia8udhQ+JNYaE+TN1cQ==", + "license": "MIT", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.21" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.21" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.3.tgz", + "integrity": "sha512-sbT0Ui/CZwyAyy7icT1Gw5P1LKRlFaHwaF6tDCW5YHq2X5SeeZFphBuIagopSfwSSZq3sQcbmEL072yphxm7ew==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", + "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.9.0", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.451.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.451.0.tgz", + "integrity": "sha512-OwQ3uljZLp2cerj8sboy5rnhtGTCl9UCJIhT1J85/yOuGVlEH+xaUPR7tvNdddPvmV5M5VLdr7cQuWE3hzA4jw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..818d3bd --- /dev/null +++ b/package.json @@ -0,0 +1,63 @@ +{ + "name": "agenda-pro", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Visual scheduling and business management app for owners and employees", + "scripts": { + "dev": "concurrently -k -n SERVER,WEB -c blue,magenta \"npm:dev:server\" \"npm:dev:web\"", + "dev:server": "tsx watch server/index.ts", + "dev:web": "vite", + "build": "tsc -b && vite build", + "start": "cross-env NODE_ENV=production tsx server/index.ts", + "typecheck": "tsc -b --noEmit", + "lint": "eslint . --ext ts,tsx", + "seed": "tsx server/scripts/seed.ts", + "test:e2e": "node e2e-test.mjs", + "test:admin": "node admin-test.mjs", + "audit:visual": "node visual-audit.mjs" + }, + "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@fullcalendar/core": "^6.1.15", + "@fullcalendar/daygrid": "^6.1.15", + "@fullcalendar/interaction": "^6.1.15", + "@fullcalendar/list": "^6.1.21", + "@fullcalendar/react": "^6.1.15", + "@fullcalendar/timegrid": "^6.1.15", + "@tanstack/react-query": "^5.59.0", + "clsx": "^2.1.1", + "cors": "^2.8.5", + "express": "^4.21.0", + "lucide-react": "^0.451.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2", + "recharts": "^2.12.7", + "zod": "^3.23.8" + }, + "devDependencies": { + "@playwright/test": "^1.62.0", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^22.7.4", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "autoprefixer": "^10.4.20", + "concurrently": "^9.0.1", + "cross-env": "^7.0.3", + "eslint": "^9.12.0", + "playwright": "^1.62.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", + "tsx": "^4.19.1", + "typescript": "^5.6.2", + "vite": "^5.4.8" + }, + "allowScripts": { + "esbuild@0.28.1": true, + "esbuild@0.21.5": true + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6c749b5 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/server/db.ts b/server/db.ts new file mode 100644 index 0000000..7029fcc --- /dev/null +++ b/server/db.ts @@ -0,0 +1,320 @@ +import { DatabaseSync } from "node:sqlite"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import fs from "node:fs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const dataDir = path.resolve(__dirname, "..", "data"); +fs.mkdirSync(dataDir, { recursive: true }); +const dbPath = path.join(dataDir, "agendapro.db"); + +if (process.env.RESET_DB === "1" && fs.existsSync(dbPath)) { + fs.unlinkSync(dbPath); +} + +export const db = new DatabaseSync(dbPath); +db.exec("PRAGMA journal_mode = WAL;"); +db.exec("PRAGMA foreign_keys = ON;"); + +// Fresh-install schema (multi-tenant). For existing v1 DBs, runMigrations() transforms them. +export const SCHEMA = ` +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS businesses ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + industry TEXT NOT NULL, + currency TEXT NOT NULL DEFAULT 'MXN', + currency_symbol TEXT NOT NULL DEFAULT '$', + phone TEXT, + address TEXT, + slug TEXT, + plan TEXT NOT NULL DEFAULT 'trial', + status TEXT NOT NULL DEFAULT 'active', + template TEXT, + trial_ends_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS employees ( + id INTEGER PRIMARY KEY, + business_id INTEGER NOT NULL REFERENCES businesses(id), + name TEXT NOT NULL, + email TEXT, + phone TEXT, + color TEXT NOT NULL DEFAULT '#3b66ff', + role TEXT NOT NULL DEFAULT 'specialist', + active INTEGER NOT NULL DEFAULT 1, + rating REAL NOT NULL DEFAULT 5.0, + hire_date TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS services ( + id INTEGER PRIMARY KEY, + business_id INTEGER NOT NULL REFERENCES businesses(id), + name TEXT NOT NULL, + description TEXT, + category TEXT NOT NULL DEFAULT 'General', + duration_min INTEGER NOT NULL DEFAULT 60, + price REAL NOT NULL DEFAULT 0, + color TEXT NOT NULL DEFAULT '#3b66ff', + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS employee_services ( + employee_id INTEGER NOT NULL REFERENCES employees(id) ON DELETE CASCADE, + service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE, + PRIMARY KEY (employee_id, service_id) +); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + business_id INTEGER REFERENCES businesses(id), + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('admin','owner','employee')), + employee_id INTEGER REFERENCES employees(id), + avatar_color TEXT NOT NULL DEFAULT '#3b66ff', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS clients ( + id INTEGER PRIMARY KEY, + business_id INTEGER NOT NULL REFERENCES businesses(id), + name TEXT NOT NULL, + email TEXT, + phone TEXT, + notes TEXT, + tags TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS appointments ( + id INTEGER PRIMARY KEY, + business_id INTEGER NOT NULL REFERENCES businesses(id), + service_id INTEGER NOT NULL REFERENCES services(id), + employee_id INTEGER NOT NULL REFERENCES employees(id), + client_id INTEGER NOT NULL REFERENCES clients(id), + start_at TEXT NOT NULL, + end_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled','completed','cancelled','no_show')), + price REAL NOT NULL DEFAULT 0, + notes TEXT, + created_by_user_id INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_appointments_start ON appointments(start_at); +CREATE INDEX IF NOT EXISTS idx_appointments_employee ON appointments(employee_id); +CREATE INDEX IF NOT EXISTS idx_appointments_client ON appointments(client_id); +CREATE INDEX IF NOT EXISTS idx_appointments_status ON appointments(status); + +CREATE TABLE IF NOT EXISTS reviews ( + id INTEGER PRIMARY KEY, + appointment_id INTEGER NOT NULL REFERENCES appointments(id) ON DELETE CASCADE, + client_id INTEGER NOT NULL REFERENCES clients(id), + employee_id INTEGER NOT NULL REFERENCES employees(id), + rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5), + comment TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY, + business_id INTEGER NOT NULL REFERENCES businesses(id), + appointment_id INTEGER REFERENCES appointments(id), + client_id INTEGER NOT NULL REFERENCES clients(id), + employee_id INTEGER NOT NULL REFERENCES employees(id), + service_id INTEGER NOT NULL REFERENCES services(id), + amount REAL NOT NULL, + tip REAL NOT NULL DEFAULT 0, + payment_method TEXT NOT NULL DEFAULT 'card', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_tickets_created ON tickets(created_at); +CREATE INDEX IF NOT EXISTS idx_tickets_employee ON tickets(employee_id); +CREATE INDEX IF NOT EXISTS idx_tickets_client ON tickets(client_id); +`; + +export function getMeta(key: string, fallback = ""): string { + const row = db.prepare(`SELECT value FROM meta WHERE key = ?`).get(key) as { value: string } | undefined; + return row?.value ?? fallback; +} + +export function setMeta(key: string, value: string) { + db.prepare(`INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run( + key, + value + ); +} + +function tableExists(name: string): boolean { + return !!db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(name); +} + +function columnExists(table: string, column: string): boolean { + const rows = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; + return rows.some((r) => r.name === column); +} + +/** Migrate an existing v1 DB (single-tenant) to the multi-tenant v2 schema, preserving all data. */ +function migrateV1ToV2() { + if (getMeta("schema_version") >= "2") return; + if (!tableExists("users")) { + setMeta("schema_version", "2"); + return; + } + + // Detect if users table is already v2 (CHECK includes 'admin') + const usersSql = (db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='users'`).get() as { sql: string } | undefined)?.sql ?? ""; + const isAlreadyV2 = usersSql.includes("'admin'"); + + if (!isAlreadyV2) { + const fkWasOn = db.prepare("PRAGMA foreign_keys").get() as { foreign_keys: number }; + db.exec("PRAGMA foreign_keys = OFF;"); + try { + db.exec(` + CREATE TABLE IF NOT EXISTS users_v2 ( + id INTEGER PRIMARY KEY, + business_id INTEGER REFERENCES businesses(id), + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('admin','owner','employee')), + employee_id INTEGER REFERENCES employees(id), + avatar_color TEXT NOT NULL DEFAULT '#3b66ff', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + const existing = db + .prepare(`SELECT id, business_id, email, password, name, role, employee_id, avatar_color, created_at FROM users`) + .all() as any[]; + const ins = db.prepare( + `INSERT OR IGNORE INTO users_v2 (id, business_id, email, password, name, role, employee_id, avatar_color, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + for (const u of existing) { + ins.run(u.id, u.business_id ?? null, u.email, u.password, u.name, u.role, u.employee_id ?? null, u.avatar_color ?? "#3b66ff", u.created_at); + } + db.exec(`DROP TABLE users; ALTER TABLE users_v2 RENAME TO users;`); + } finally { + db.exec(`PRAGMA foreign_keys = ${fkWasOn.foreign_keys ? "ON" : "OFF"};`); + } + } + + // Add new business columns if missing + if (!columnExists("businesses", "slug")) db.exec(`ALTER TABLE businesses ADD COLUMN slug TEXT;`); + if (!columnExists("businesses", "plan")) db.exec(`ALTER TABLE businesses ADD COLUMN plan TEXT NOT NULL DEFAULT 'trial';`); + if (!columnExists("businesses", "status")) db.exec(`ALTER TABLE businesses ADD COLUMN status TEXT NOT NULL DEFAULT 'active';`); + if (!columnExists("businesses", "template")) db.exec(`ALTER TABLE businesses ADD COLUMN template TEXT;`); + if (!columnExists("businesses", "trial_ends_at")) db.exec(`ALTER TABLE businesses ADD COLUMN trial_ends_at TEXT;`); + + // Meta table (created by SCHEMA on fresh; ensure here for migrated DBs) + db.exec(`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`); + + setMeta("schema_version", "2"); +} + +export function runMigrations() { + // Ensure base schema exists (no-op on existing tables) + db.exec(SCHEMA); + migrateV1ToV2(); + migrateV2ToV3(); +} + +/** v3: cancellation policy, commissions, cash register, notifications/reminders, booking slug. */ +function migrateV2ToV3() { + if (getMeta("schema_version") >= "3") return; + + // Business-level settings (cancellation policy + deposit + booking enabled) + const bizCols: [string, string][] = [ + ["booking_enabled", "INTEGER NOT NULL DEFAULT 1"], + ["cancel_window_hours", "INTEGER NOT NULL DEFAULT 24"], + ["cancel_penalty_pct", "REAL NOT NULL DEFAULT 0"], + ["require_deposit", "INTEGER NOT NULL DEFAULT 0"], + ["deposit_pct", "REAL NOT NULL DEFAULT 0"], + ["timezone", "TEXT NOT NULL DEFAULT 'America/Mexico_City'"], + ]; + for (const [col, def] of bizCols) { + if (!columnExists("businesses", col)) db.exec(`ALTER TABLE businesses ADD COLUMN ${col} ${def};`); + } + // Commission rates + if (!columnExists("services", "commission_pct")) db.exec(`ALTER TABLE services ADD COLUMN commission_pct REAL NOT NULL DEFAULT 0;`); + if (!columnExists("employees", "commission_pct")) db.exec(`ALTER TABLE employees ADD COLUMN commission_pct REAL NOT NULL DEFAULT 0;`); + // Commission snapshot on tickets (computed at completion) + if (!columnExists("tickets", "commission")) db.exec(`ALTER TABLE tickets ADD COLUMN commission REAL NOT NULL DEFAULT 0;`); + + // Cash register + db.exec(` + CREATE TABLE IF NOT EXISTS cash_sessions ( + id INTEGER PRIMARY KEY, + business_id INTEGER NOT NULL REFERENCES businesses(id), + opened_by_user_id INTEGER REFERENCES users(id), + opened_at TEXT NOT NULL DEFAULT (datetime('now')), + closed_at TEXT, + opening_amount REAL NOT NULL DEFAULT 0, + closing_amount REAL, + expected_amount REAL, + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','closed')), + note TEXT + ); + CREATE INDEX IF NOT EXISTS idx_cash_sessions_business ON cash_sessions(business_id); + CREATE INDEX IF NOT EXISTS idx_cash_sessions_status ON cash_sessions(status); + CREATE TABLE IF NOT EXISTS cash_entries ( + id INTEGER PRIMARY KEY, + business_id INTEGER NOT NULL REFERENCES businesses(id), + session_id INTEGER REFERENCES cash_sessions(id) ON DELETE CASCADE, + ticket_id INTEGER REFERENCES tickets(id), + type TEXT NOT NULL CHECK (type IN ('income','expense')), + amount REAL NOT NULL, + concept TEXT NOT NULL, + payment_method TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_cash_entries_session ON cash_entries(session_id); + CREATE INDEX IF NOT EXISTS idx_cash_entries_business ON cash_entries(business_id); + `); + + // Reminders / notifications center + db.exec(` + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY, + business_id INTEGER NOT NULL REFERENCES businesses(id), + appointment_id INTEGER REFERENCES appointments(id) ON DELETE CASCADE, + client_id INTEGER REFERENCES clients(id), + channel TEXT NOT NULL CHECK (channel IN ('whatsapp','sms','email')), + kind TEXT NOT NULL CHECK (kind IN ('confirmation','reminder','cancellation','follow_up','review')), + send_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed','canceled')), + message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_notif_send ON notifications(send_at); + CREATE INDEX IF NOT EXISTS idx_notif_business ON notifications(business_id); + CREATE INDEX IF NOT EXISTS idx_notif_status ON notifications(status); + `); + + // Ensure every business has a slug (for public booking /b/:slug) + const bizs = db.prepare(`SELECT id, name, slug FROM businesses WHERE slug IS NULL OR slug = ''`).all() as any[]; + const slugify = (s: string) => + s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || `negocio`; + for (const b of bizs) { + let slug = slugify(b.name); + let n = 1; + while (db.prepare(`SELECT id FROM businesses WHERE slug = ? AND id != ?`).get(slug, b.id)) { + slug = `${slugify(b.name)}-${n++}`; + } + db.prepare(`UPDATE businesses SET slug = ? WHERE id = ?`).run(slug, b.id); + } + + setMeta("schema_version", "3"); +} + +runMigrations(); diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..1a20d97 --- /dev/null +++ b/server/index.ts @@ -0,0 +1,94 @@ +import express from "express"; +import cors from "cors"; +import path from "node:path"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import { db } from "./db.ts"; +import { ensurePlatformAdmin, seedBusiness, getTemplate, DEFAULT_TEMPLATE_KEY } from "./scripts/seed.ts"; +import { authRouter } from "./routes/auth.ts"; +import { businessRouter } from "./routes/business.ts"; +import { employeesRouter } from "./routes/employees.ts"; +import { servicesRouter } from "./routes/services.ts"; +import { clientsRouter } from "./routes/clients.ts"; +import { appointmentsRouter } from "./routes/appointments.ts"; +import { dashboardRouter } from "./routes/dashboard.ts"; +import { adminRouter } from "./routes/admin.ts"; +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 { authRequired } from "./lib/auth.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, ".."); + +// Auto-seed demo data if the database is empty (first run). Preserves existing data. +function ensureSeed() { + ensurePlatformAdmin(); + const row = db.prepare(`SELECT COUNT(*) c FROM businesses`).get() as { c: number }; + if (row.c === 0) { + console.log("[seed] Base de datos vacía — creando negocio demo y admin…"); + const biz = db + .prepare( + `INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status) + 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 }; + seedBusiness({ + businessId: biz.id, + template: getTemplate(DEFAULT_TEMPLATE_KEY)!, + ownerEmail: "owner@agendapro.demo", + ownerName: "Daniela Reyes", + }); + } +} +ensureSeed(); +// On startup, refresh pending reminders for all active businesses (idempotent). +try { + const bids = db.prepare(`SELECT id FROM businesses WHERE status = 'active'`).all() as { id: number }[]; + for (const b of bids) scheduleReminders(b.id); +} catch (e) { + console.warn("[reminders] no se pudieron generar al inicio:", (e as Error).message); +} + +const app = express(); +app.use(cors()); +app.use(express.json({ limit: "1mb" })); + +app.use("/api/auth", authRouter); +// Public booking (no auth) +app.use("/api/public", bookingRouter); +// Protected APIs +app.use("/api/business", authRequired, businessRouter); +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/dashboard", authRequired, dashboardRouter); +app.use("/api/admin", authRequired, adminRouter); +app.use("/api/settings", authRequired, settingsRouter); +app.use("/api/cash", authRequired, cashRouter); +app.use("/api/notifications", authRequired, notificationsRouter); + +app.get("/api/health", (_req, res) => res.json({ ok: true, ts: Date.now() })); + +// Serve built frontend in production +const distDir = path.join(ROOT, "dist"); +if (fs.existsSync(distDir)) { + app.use(express.static(distDir)); + app.get("*", (req, res, next) => { + if (req.path.startsWith("/api/")) return next(); + res.sendFile(path.join(distDir, "index.html")); + }); +} + +app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + console.error("[server error]", err); + res.status(err.status || 500).json({ error: err.message || "Error interno" }); +}); + +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}`); +}); diff --git a/server/lib/auth.ts b/server/lib/auth.ts new file mode 100644 index 0000000..9e236af --- /dev/null +++ b/server/lib/auth.ts @@ -0,0 +1,50 @@ +import type { Request, Response, NextFunction } from "express"; +import { db } from "../db.ts"; +import type { User } from "../../shared/types.ts"; + +export interface AuthedRequest extends Request { + user?: User; +} + +export function authRequired(req: AuthedRequest, res: Response, next: NextFunction) { + const header = req.header("authorization") || ""; + const token = header.startsWith("Bearer ") ? header.slice(7) : req.header("x-user-id"); + if (!token) { + res.status(401).json({ error: "No autorizado" }); + return; + } + const userId = Number(token); + if (!Number.isFinite(userId)) { + res.status(401).json({ error: "Token inválido" }); + return; + } + const user = db + .prepare(`SELECT id, business_id, email, name, role, employee_id, avatar_color FROM users WHERE id = ?`) + .get(userId) as User | undefined; + if (!user) { + res.status(401).json({ error: "Usuario no encontrado" }); + return; + } + req.user = user; + next(); +} + +export function ownerOnly(req: AuthedRequest, res: Response, next: NextFunction) { + if (req.user?.role !== "owner") { + res.status(403).json({ error: "Solo el dueño puede realizar esta acción" }); + return; + } + next(); +} + +export function adminOnly(req: AuthedRequest, res: Response, next: NextFunction) { + if (req.user?.role !== "admin") { + res.status(403).json({ error: "Acceso restringido al administrador de la plataforma" }); + return; + } + next(); +} + +export function err(res: Response, status: number, message: string) { + return res.status(status).json({ error: message }); +} diff --git a/server/lib/templates.ts b/server/lib/templates.ts new file mode 100644 index 0000000..536a213 --- /dev/null +++ b/server/lib/templates.ts @@ -0,0 +1,168 @@ +// Predefined business templates for the admin "create business" flow. +// Each template describes the demo seed (services, employees, sample clients) +// that gets loaded into a freshly created tenant. + +const C = { + blue: "#3b66ff", + orange: "#f17616", + green: "#10b981", + purple: "#a855f7", + pink: "#ec4899", + teal: "#14b8a6", + amber: "#f59e0b", + indigo: "#6366f1", +}; + +export interface TemplateService { + name: string; + category: string; + duration_min: number; + price: number; + color: string; + employees: number[]; // indexes into template.employees +} +export interface TemplateEmployee { + name: string; + role: string; + color: string; +} +export interface ClientSeed { + name: string; + tags?: string; + notes?: string; +} +export interface TemplateDef { + key: string; + name: string; + industry: string; + currency: string; + currency_symbol: string; + description: string; + emailDomain: string; + employees: TemplateEmployee[]; + services: TemplateService[]; + clients: ClientSeed[]; +} + +const SPA_CLIENTS: ClientSeed[] = [ + "Alejandra Pérez","Roberto Díaz","Mariana López","Fernando Ruiz","Gabriela Sánchez", + "Ricardo Vargas","Patricia Moreno","Javier Estrada","Lucía Fernández","Andrés Ortega", + "Daniela Castro","Eduardo Mendoza","Valeria Guzmán","Carlos Iglesias","Fernanda Romero", +].map((n, i) => ({ + name: n, + tags: i % 4 === 0 ? "VIP" : i % 3 === 0 ? "Frecuente" : undefined, + notes: i % 2 === 0 ? "Prefiere horario matutino" : undefined, +})); + +export const TEMPLATES: TemplateDef[] = [ + { + key: "estetica-spa", + name: "Lumière Estética & Spa", + industry: "Estética y Spa", + currency: "MXN", + currency_symbol: "$", + description: "Salón de belleza y spa: cabello, barbería, faciales, masajes y uñas.", + emailDomain: "@lumiere.mx", + employees: [ + { name: "Valentina Cruz", role: "Estilista Senior", color: C.blue }, + { name: "Mateo Herrera", role: "Barbero", color: C.orange }, + { name: "Sofía Ramírez", role: "Facialista", color: C.green }, + { name: "Diego Castillo", role: "Masajista", color: C.purple }, + { name: "Isabela Torres", role: "Manicurista", color: C.pink }, + { name: "Carolina Méndez", role: "Estilista Junior", color: C.teal }, + ], + services: [ + { name: "Corte y peinado", category: "Cabello", duration_min: 60, price: 350, color: C.blue, employees: [0, 5] }, + { name: "Coloración global", category: "Cabello", duration_min: 120, price: 1200, color: C.blue, employees: [0] }, + { name: "Mechas balayage", category: "Cabello", duration_min: 180, price: 1800, color: C.indigo, employees: [0] }, + { name: "Tratamiento de keratina", category: "Cabello", duration_min: 150, price: 1500, color: C.teal, employees: [0, 5] }, + { name: "Corte caballero", category: "Barbería", duration_min: 30, price: 180, color: C.orange, employees: [1] }, + { name: "Corte + arreglo de barba", category: "Barbería", duration_min: 45, price: 280, color: C.orange, employees: [1] }, + { name: "Limpieza facial profunda", category: "Facial", duration_min: 75, price: 700, color: C.green, employees: [2] }, + { name: "Facial anti-edad", category: "Facial", duration_min: 90, price: 950, color: C.green, employees: [2] }, + { name: "Masaje relajante 60 min", category: "Spa", duration_min: 60, price: 800, color: C.purple, employees: [3] }, + { name: "Masaje descontracturante 90 min", category: "Spa", duration_min: 90, price: 1100, color: C.purple, employees: [3] }, + { name: "Manicura semipermanente", category: "Uñas", duration_min: 60, price: 450, color: C.pink, employees: [4] }, + { name: "Pedicura spa", category: "Uñas", duration_min: 60, price: 550, color: C.pink, employees: [4] }, + ], + clients: SPA_CLIENTS, + }, + { + key: "barberia", + name: "Urban Cut Barbería", + industry: "Barbería", + currency: "MXN", + currency_symbol: "$", + description: "Barbería moderna: cortes, arreglo de barba, tratamientos capilares para caballero.", + emailDomain: "@urbancut.mx", + employees: [ + { name: "Andrés Morales", role: "Barbero Senior", color: C.orange }, + { name: "Tomás Rivas", role: "Barbero", color: C.blue }, + { name: "Bruno Salazar", role: "Barbero Junior", color: C.green }, + { name: "Hugo Beltrán", role: "Especialista en Barba", color: C.amber }, + ], + services: [ + { name: "Corte clásico", category: "Cortes", duration_min: 30, price: 150, color: C.orange, employees: [0, 1, 2] }, + { name: "Corte + diseño", category: "Cortes", duration_min: 45, price: 220, color: C.orange, employees: [0, 1] }, + { name: "Arreglo de barba", category: "Barba", duration_min: 30, price: 120, color: C.amber, employees: [3] }, + { name: "Corte + barba", category: "Combo", duration_min: 60, price: 280, color: C.indigo, employees: [0, 1, 3] }, + { name: "Rasurado tradicional con toalla", category: "Barba", duration_min: 45, price: 200, color: C.amber, employees: [3] }, + { name: "Tinte de cabello", category: "Color", duration_min: 90, price: 600, color: C.purple, employees: [0] }, + { name: "Tratamiento hidratante capilar", category: "Tratamientos", duration_min: 40, price: 350, color: C.teal, employees: [0, 1] }, + { name: "Cejas masculinas", category: "Detalles", duration_min: 20, price: 80, color: C.pink, employees: [2, 3] }, + ], + clients: [ + "Ricardo Vargas","Javier Estrada","Andrés Ortega","Eduardo Mendoza","Carlos Iglesias", + "Miguel Torres","Sebastián Navarro","Bruno Aguilar","Emilio Domínguez","Rodrigo Herrera", + "Fernando Ruiz","Diego Soto","Hugo Mendoza","Tomás Cisneros","Manuel Vega", + ].map((n, i) => ({ name: n, tags: i % 5 === 0 ? "VIP" : i % 3 === 0 ? "Frecuente" : undefined })), + }, + { + key: "clinica", + name: "Clínica Dental Sonrisa", + industry: "Salud / Dental", + currency: "MXN", + currency_symbol: "$", + description: "Clínica dental: limpieza, ortodoncia, estética dental y consultas.", + emailDomain: "@sonrisa.mx", + employees: [ + { name: "Dra. Elena Vargas", role: "Dentista General", color: C.blue }, + { name: "Dr. Pablo Núñez", role: "Ortodoncista", color: C.green }, + { name: "Dra. Reina Flores", role: "Estética Dental", color: C.pink }, + { name: "Lic. Martha Ibáñez", role: "Higienista", color: C.teal }, + ], + services: [ + { name: "Consulta y diagnóstico", category: "Consulta", duration_min: 30, price: 400, color: C.blue, employees: [0] }, + { name: "Limpieza dental profunda", category: "Higiene", duration_min: 60, price: 700, color: C.teal, employees: [3] }, + { name: "Blanqueamiento dental", category: "Estética", duration_min: 90, price: 2500, color: C.pink, employees: [2] }, + { name: "Carillas dentales", category: "Estética", duration_min: 120, price: 4500, color: C.pink, employees: [2] }, + { name: "Consulta de ortodoncia", category: "Ortodoncia", duration_min: 45, price: 600, color: C.green, employees: [1] }, + { name: "Ajuste de brackets", category: "Ortodoncia", duration_min: 60, price: 800, color: C.green, employees: [1] }, + { name: "Extracción simple", category: "Cirugía", duration_min: 60, price: 1200, color: C.orange, employees: [0] }, + { name: "Resina (obturación)", category: "Restauración", duration_min: 45, price: 900, color: C.indigo, employees: [0] }, + ], + clients: [ + "Mariana López","Patricia Moreno","Lucía Fernández","Daniela Castro","Valeria Guzmán", + "Fernanda Romero","Andrea Vega","Natalia Reyes","Camila Soto","Renata Domínguez", + "Alejandra Pérez","Gabriela Sánchez","Roberto Díaz","Carlos Iglesias","Eduardo Mendoza", + ].map((n, i) => ({ name: n, tags: i % 4 === 0 ? "VIP" : undefined, notes: i % 2 === 0 ? "Alergia a anestesia local" : undefined })), + }, + { + key: "blank", + name: "Nuevo Negocio", + industry: "General", + currency: "MXN", + currency_symbol: "$", + description: "Plantilla vacía: sin servicios ni datos demo. Configura todo desde cero.", + emailDomain: "@demo.mx", + employees: [], + services: [], + clients: [], + }, +]; + +export function getTemplate(key: string): TemplateDef | undefined { + return TEMPLATES.find((t) => t.key === key); +} + +export const DEFAULT_TEMPLATE_KEY = "estetica-spa"; diff --git a/server/routes/admin.ts b/server/routes/admin.ts new file mode 100644 index 0000000..626ee1a --- /dev/null +++ b/server/routes/admin.ts @@ -0,0 +1,210 @@ +import { Router } from "express"; +import { db } from "../db.ts"; +import { adminOnly, err, type AuthedRequest } from "../lib/auth.ts"; +import { TEMPLATES, getTemplate, type TemplateDef } from "../lib/templates.ts"; +import { seedBusiness, clearBusinessData } from "../scripts/seed.ts"; + +export const adminRouter = Router(); + +adminRouter.use(adminOnly); + +function businessStats(businessId: number) { + const scalar = (sql: string) => (db.prepare(sql).get(businessId) as any)?.v ?? 0; + return { + users: scalar(`SELECT COUNT(*) v FROM users WHERE business_id = ?`), + employees: scalar(`SELECT COUNT(*) v FROM employees WHERE business_id = ?`), + services: scalar(`SELECT COUNT(*) v FROM services WHERE business_id = ?`), + clients: scalar(`SELECT COUNT(*) v FROM clients WHERE business_id = ?`), + appointments: scalar(`SELECT COUNT(*) v FROM appointments WHERE business_id = ?`), + appointments_upcoming: scalar( + `SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status='scheduled' AND start_at >= datetime('now')` + ), + revenue_30d: scalar( + `SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')` + ), + tickets: scalar(`SELECT COUNT(*) v FROM tickets WHERE business_id = ?`), + }; +} + +function businessRow(id: number) { + return db + .prepare( + `SELECT id, name, industry, currency, currency_symbol, phone, address, slug, plan, status, template, trial_ends_at, created_at + FROM businesses WHERE id = ?` + ) + .get(id) as any; +} + +// Platform overview +adminRouter.get("/overview", (_req: AuthedRequest, res) => { + const scalar = (sql: string) => (db.prepare(sql).get() as any)?.v ?? 0; + res.json({ + businesses: scalar(`SELECT COUNT(*) v FROM businesses`), + active_businesses: scalar(`SELECT COUNT(*) v FROM businesses WHERE status='active'`), + trial_businesses: scalar(`SELECT COUNT(*) v FROM businesses WHERE plan='trial'`), + total_users: scalar(`SELECT COUNT(*) v FROM users WHERE role IN ('owner','employee')`), + total_appointments: scalar(`SELECT COUNT(*) v FROM appointments`), + revenue_30d: scalar(`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE created_at >= datetime('now','-30 days')`), + upcoming_appointments: scalar( + `SELECT COUNT(*) v FROM appointments WHERE status='scheduled' AND start_at >= datetime('now')` + ), + templates: TEMPLATES.length, + }); +}); + +// List templates +adminRouter.get("/templates", (_req: AuthedRequest, res) => { + const out = TEMPLATES.map((t) => ({ + key: t.key, + name: t.name, + industry: t.industry, + description: t.description, + currency: t.currency, + currency_symbol: t.currency_symbol, + employees: t.employees.length, + services: t.services.length, + clients: t.clients.length, + })); + res.json({ templates: out }); +}); + +// List all businesses with stats +adminRouter.get("/businesses", (_req: AuthedRequest, res) => { + const rows = db + .prepare( + `SELECT id, name, industry, currency, currency_symbol, phone, address, slug, plan, status, template, trial_ends_at, created_at + FROM businesses ORDER BY created_at DESC` + ) + .all() as any[]; + const out = rows.map((b) => ({ business: b, stats: businessStats(b.id) })); + res.json({ businesses: out }); +}); + +// Business detail +adminRouter.get("/businesses/:id", (req: AuthedRequest, res) => { + const id = Number(req.params.id); + const b = businessRow(id); + if (!b) return err(res, 404, "Negocio no encontrado"); + res.json({ business: b, stats: businessStats(id) }); +}); + +// Create business + owner + template seed +adminRouter.post("/businesses", (req: AuthedRequest, res) => { + const { name, industry, template, ownerName, ownerEmail, ownerPassword, plan, currency, currency_symbol } = req.body ?? {}; + if (!name) return err(res, 400, "El nombre del negocio es obligatorio"); + if (!ownerEmail) return err(res, 400, "El correo del dueño es obligatorio"); + + const tpl: TemplateDef | undefined = template ? getTemplate(template) : getTemplate("blank"); + if (!tpl) return err(res, 400, "Plantilla no válida"); + + const email = String(ownerEmail).toLowerCase().trim(); + const existing = db.prepare(`SELECT id FROM users WHERE email = ?`).get(email); + if (existing) return err(res, 409, "Ya existe un usuario con ese correo"); + + const biz = db + .prepare( + `INSERT INTO businesses (name, industry, currency, currency_symbol, plan, status, template) + VALUES (?, ?, ?, ?, ?, 'active', ?) RETURNING id` + ) + .get( + name, + industry || tpl.industry, + currency || tpl.currency || "MXN", + currency_symbol || tpl.currency_symbol || "$", + plan || "trial", + tpl.key + ) as { id: number }; + + const result = seedBusiness({ + businessId: biz.id, + template: tpl, + ownerEmail: email, + ownerName: ownerName || "Dueño", + ownerPassword: ownerPassword || "demo1234", + withHistory: tpl.key !== "blank", + withUpcoming: tpl.key !== "blank", + }); + + res.status(201).json({ + business: businessRow(biz.id), + stats: businessStats(biz.id), + seeded: result, + message: `Negocio "${name}" creado con plantilla "${tpl.name}"`, + }); +}); + +// Update business +adminRouter.patch("/businesses/:id", (req: AuthedRequest, res) => { + const id = Number(req.params.id); + const existing = businessRow(id); + if (!existing) return err(res, 404, "Negocio no encontrado"); + const { name, industry, phone, address, slug, plan, status, trial_ends_at } = req.body ?? {}; + db.prepare( + `UPDATE businesses SET name=?, industry=?, phone=?, address=?, slug=?, plan=?, status=?, trial_ends_at=? WHERE id=?` + ).run( + name ?? existing.name, + industry ?? existing.industry, + phone ?? existing.phone, + address ?? existing.address, + slug ?? existing.slug, + plan ?? existing.plan, + status ?? existing.status, + trial_ends_at ?? existing.trial_ends_at, + id + ); + res.json({ business: businessRow(id), stats: businessStats(id) }); +}); + +// Delete business + all its data +adminRouter.delete("/businesses/:id", (req: AuthedRequest, res) => { + const id = Number(req.params.id); + const existing = businessRow(id); + if (!existing) return err(res, 404, "Negocio no encontrado"); + clearBusinessData(id); + // clearBusinessData preserves user accounts; a full delete removes them too + db.prepare(`DELETE FROM users WHERE business_id = ?`).run(id); + db.prepare(`DELETE FROM businesses WHERE id = ?`).run(id); + res.json({ ok: true }); +}); + +// Load template data into an existing business (clearFirst optional) +adminRouter.post("/businesses/:id/seed-template", (req: AuthedRequest, res) => { + const id = Number(req.params.id); + const b = businessRow(id); + if (!b) return err(res, 404, "Negocio no encontrado"); + const { template, clearFirst = true, ownerEmail, ownerName, ownerPassword, withHistory = true, withUpcoming = true } = req.body ?? {}; + const tpl: TemplateDef | undefined = template ? getTemplate(template) : b.template ? getTemplate(b.template) : getTemplate("blank"); + if (!tpl) return err(res, 400, "Plantilla no válida"); + const result = seedBusiness({ + businessId: id, + template: tpl, + ownerEmail: ownerEmail ?? (db.prepare(`SELECT email FROM users WHERE business_id=? AND role='owner'`).get(id) as any)?.email, + ownerName: ownerName ?? "Dueño", + ownerPassword: ownerPassword ?? "demo1234", + clearFirst: clearFirst !== false, + withHistory, + withUpcoming, + }); + res.json({ business: businessRow(id), stats: businessStats(id), seeded: result }); +}); + +// Reset demo data (clear + reseed with current template) +adminRouter.post("/businesses/:id/reset-demo", (req: AuthedRequest, res) => { + const id = Number(req.params.id); + const b = businessRow(id); + if (!b) return err(res, 404, "Negocio no encontrado"); + const tplKey = req.body?.template || b.template || "blank"; + const tpl = getTemplate(tplKey); + if (!tpl) return err(res, 400, "Plantilla no válida"); + const owner = db.prepare(`SELECT email, name FROM users WHERE business_id=? AND role='owner'`).get(id) as any; + const result = seedBusiness({ + businessId: id, + template: tpl, + ownerEmail: owner?.email, + ownerName: owner?.name ?? "Dueño", + clearFirst: true, + withHistory: tpl.key !== "blank", + withUpcoming: tpl.key !== "blank", + }); + res.json({ business: businessRow(id), stats: businessStats(id), seeded: result }); +}); diff --git a/server/routes/appointments.ts b/server/routes/appointments.ts new file mode 100644 index 0000000..c710c24 --- /dev/null +++ b/server/routes/appointments.ts @@ -0,0 +1,303 @@ +import { Router } from "express"; +import { db } from "../db.ts"; +import type { AuthedRequest } from "../lib/auth.ts"; +import { err } from "../lib/auth.ts"; + +export const appointmentsRouter = Router(); + +/** Compute commission for a ticket: service rate, fallback employee rate. */ +function computeCommission(businessId: number, serviceId: number, employeeId: number, amount: number): number { + const svc = db.prepare(`SELECT commission_pct p FROM services WHERE id = ? AND business_id = ?`).get(serviceId, businessId) as { p: number } | undefined; + let pct = svc?.p ?? 0; + if (!pct) { + const emp = db.prepare(`SELECT commission_pct p FROM employees WHERE id = ? AND business_id = ?`).get(employeeId, businessId) as { p: number } | undefined; + pct = emp?.p ?? 0; + } + return Math.round(((amount * pct) / 100) * 100) / 100; +} + +function joinAppointment(a: any) { + if (!a) return a; + a.service = db + .prepare(`SELECT id, name, category, color, duration_min, price FROM services WHERE id = ?`) + .get(a.service_id); + a.employee = db + .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 = ?`) + .get(a.client_id); + return a; +} + +appointmentsRouter.get("/", (req: AuthedRequest, res) => { + const { from, to, employee_id, client_id, status, limit } = req.query; + const conds: string[] = ["business_id = ?"]; + const args: any[] = [req.user!.business_id]; + if (from) { + conds.push("start_at >= ?"); + args.push(String(from)); + } + if (to) { + conds.push("start_at <= ?"); + args.push(String(to)); + } + if (employee_id) { + conds.push("employee_id = ?"); + args.push(Number(employee_id)); + } + if (client_id) { + conds.push("client_id = ?"); + args.push(Number(client_id)); + } + if (status) { + conds.push("status = ?"); + args.push(String(status)); + } + // Employees only see their own appointments (still can see all to plan, but filter by default) + // We let them see all so they can coordinate; UI filters by default. + let sql = `SELECT * FROM appointments WHERE ${conds.join(" AND ")} ORDER BY start_at`; + if (limit) sql += ` LIMIT ${Number(limit)}`; + const rows = db.prepare(sql).all(...args) as any[]; + rows.forEach(joinAppointment); + res.json({ appointments: rows }); +}); + +appointmentsRouter.get("/:id", (req: AuthedRequest, res) => { + const a = db + .prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`) + .get(Number(req.params.id), req.user!.business_id) as any; + if (!a) return err(res, 404, "Cita no encontrada"); + joinAppointment(a); + res.json({ appointment: a }); +}); + +appointmentsRouter.post("/", (req: AuthedRequest, res) => { + const { + service_id, + employee_id, + client_id, + new_client, + start_at, + duration_min, + price_override, + notes, + status, + } = req.body ?? {}; + + if (!service_id) return err(res, 400, "Selecciona un servicio"); + if (!start_at) return err(res, 400, "Selecciona una fecha y hora"); + + const service = db + .prepare(`SELECT * FROM services WHERE id = ? AND business_id = ?`) + .get(Number(service_id), req.user!.business_id) as any; + if (!service) return err(res, 400, "Servicio no válido"); + + // Resolve client: existing id, or create a new one from new_client payload + let clientId = client_id ? Number(client_id) : null; + if (!clientId && new_client) { + if (!new_client.name) return err(res, 400, "El nombre del cliente es obligatorio"); + const created = db + .prepare( + `INSERT INTO clients (business_id, name, email, phone, notes, tags) VALUES (?, ?, ?, ?, ?, ?) RETURNING id` + ) + .get( + req.user!.business_id, + new_client.name, + new_client.email || null, + new_client.phone || null, + new_client.notes || null, + new_client.tags || null + ) as { id: number }; + clientId = created.id; + } + 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 finalStatus = status || "scheduled"; + const price = price_override !== undefined ? Number(price_override) : service.price; + + const r = 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; + + 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); + } + + joinAppointment(r); + res.status(201).json({ appointment: r }); +}); + +appointmentsRouter.patch("/:id", (req: AuthedRequest, res) => { + const id = Number(req.params.id); + const existing = db + .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"); + + const { + service_id, + employee_id, + client_id, + start_at, + end_at, + duration_min, + status, + price, + notes, + } = req.body ?? {}; + + let startIso = existing.start_at; + let endIso = existing.end_at; + if (start_at) { + const start = new Date(start_at); + if (isNaN(start.getTime())) return err(res, 400, "Fecha inválida"); + startIso = start.toISOString().replace(/\.\d{3}Z$/, "Z"); + const svc = service_id + ? db.prepare(`SELECT duration_min FROM services WHERE id = ?`).get(Number(service_id)) as any + : { duration_min: null }; + const dur = Number(duration_min) || svc?.duration_min || null; + if (end_at) { + endIso = new Date(end_at).toISOString().replace(/\.\d{3}Z$/, "Z"); + } else if (dur) { + endIso = new Date(start.getTime() + dur * 60000).toISOString().replace(/\.\d{3}Z$/, "Z"); + } + } else if (duration_min) { + const start = new Date(existing.start_at); + endIso = new Date(start.getTime() + Number(duration_min) * 60000).toISOString().replace(/\.\d{3}Z$/, "Z"); + } + + const finalStatus = status ?? existing.status; + const finalPrice = price !== undefined ? Number(price) : existing.price; + + db.prepare( + `UPDATE appointments + SET service_id = ?, employee_id = ?, client_id = ?, start_at = ?, end_at = ?, status = ?, price = ?, notes = ?, updated_at = datetime('now') + WHERE id = ?` + ).run( + service_id !== undefined ? Number(service_id) : existing.service_id, + employee_id !== undefined ? Number(employee_id) : existing.employee_id, + client_id !== undefined ? Number(client_id) : existing.client_id, + startIso, + endIso, + finalStatus, + finalPrice, + notes !== undefined ? notes : existing.notes, + id + ); + + // If transitioned to completed and no ticket yet, create one + if (finalStatus === "completed" && existing.status !== "completed") { + const hasTicket = db + .prepare(`SELECT id FROM tickets WHERE appointment_id = ?`) + .get(id); + if (!hasTicket) { + const appt = db.prepare(`SELECT * FROM appointments WHERE id = ?`).get(id) as any; + const commission = computeCommission(appt.business_id, appt.service_id, appt.employee_id, appt.price); + db.prepare( + `INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method, commission) + VALUES (?, ?, ?, ?, ?, ?, 0, 'card', ?)` + ).run(appt.business_id, id, appt.client_id, appt.employee_id, appt.service_id, appt.price, commission); + } + } + + const updated = db.prepare(`SELECT * FROM appointments WHERE id = ?`).get(id) as any; + joinAppointment(updated); + res.json({ appointment: updated }); +}); + +appointmentsRouter.post("/:id/complete", (req: AuthedRequest, res) => { + const id = Number(req.params.id); + const appt = db + .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"); + 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); + if (!hasTicket) { + const commission = computeCommission(appt.business_id, appt.service_id, appt.employee_id, appt.price); + db.prepare( + `INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method, commission) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run(appt.business_id, id, appt.client_id, appt.employee_id, appt.service_id, appt.price, Number(tip), payment_method, commission); + } else { + // ticket already exists (e.g. completed via PATCH) — backfill commission + tip if provided + db.prepare(`UPDATE tickets SET commission = COALESCE(NULLIF(commission,0), ?) WHERE appointment_id = ? AND commission = 0`).run( + computeCommission(appt.business_id, appt.service_id, appt.employee_id, appt.price), + id + ); + } + const updated = db.prepare(`SELECT * FROM appointments WHERE id = ?`).get(id) as any; + joinAppointment(updated); + res.json({ appointment: updated }); +}); + +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); + if (!existing) return err(res, 404, "Cita no encontrada"); + db.prepare(`DELETE FROM appointments WHERE id = ?`).run(id); + res.json({ ok: true }); +}); + +/** Preview the cancellation policy outcome for an appointment (penalty based on hours left). */ +appointmentsRouter.get("/:id/cancellation-policy", (req: AuthedRequest, res) => { + const id = Number(req.params.id); + const appt = db.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"); + const biz = db + .prepare(`SELECT cancel_window_hours wh, cancel_penalty_pct pen, require_deposit rd, deposit_pct dp FROM businesses WHERE id = ?`) + .get(req.user!.business_id) as any; + const hoursLeft = (new Date(appt.start_at).getTime() - Date.now()) / 3600000; + const withinWindow = hoursLeft < (biz?.wh ?? 24); + const penalty = withinWindow ? Math.round(((appt.price * (biz?.pen ?? 0)) / 100) * 100) / 100 : 0; + res.json({ + hours_left: Math.round(hoursLeft), + within_window: withinWindow, + window_hours: biz?.wh ?? 24, + penalty_pct: withinWindow ? biz?.pen ?? 0 : 0, + penalty_amount: penalty, + policy_active: (biz?.pen ?? 0) > 0, + }); +}); diff --git a/server/routes/auth.ts b/server/routes/auth.ts new file mode 100644 index 0000000..581ec1e --- /dev/null +++ b/server/routes/auth.ts @@ -0,0 +1,32 @@ +import { Router } from "express"; +import { db } from "../db.ts"; +import { authRequired, type AuthedRequest } from "../lib/auth.ts"; + +export const authRouter = Router(); + +authRouter.post("/login", (req, res) => { + const { email, password } = req.body ?? {}; + if (!email || !password) return res.status(400).json({ error: "Faltan credenciales" }); + const user = db + .prepare(`SELECT id, business_id, email, name, role, employee_id, avatar_color, password FROM users WHERE email = ?`) + .get(String(email).toLowerCase().trim()) as any; + if (!user || user.password !== password) { + return res.status(401).json({ error: "Correo o contraseña incorrectos" }); + } + const { password: _pw, ...safe } = user; + res.json({ token: String(user.id), user: safe }); +}); + +authRouter.get("/me", authRequired, (req: AuthedRequest, res) => { + res.json({ user: req.user }); +}); + +authRouter.get("/demo-users", (_req, res) => { + const users = db + .prepare( + `SELECT email, name, role, avatar_color FROM users + ORDER BY CASE role WHEN 'admin' THEN 0 WHEN 'owner' THEN 1 ELSE 2 END, name` + ) + .all(); + res.json({ users }); +}); diff --git a/server/routes/booking.ts b/server/routes/booking.ts new file mode 100644 index 0000000..a80bf75 --- /dev/null +++ b/server/routes/booking.ts @@ -0,0 +1,154 @@ +import { Router } from "express"; +import { db } from "../db.ts"; + +export const bookingRouter = Router(); + +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 + FROM businesses WHERE slug = ? AND status = 'active'` + ) + .get(slug) as any; +} + +// Public business info + services + employees +bookingRouter.get("/:slug", (req, res) => { + const biz = publicBusiness(req.params.slug); + if (!biz) return res.status(404).json({ error: "Negocio no encontrado" }); + if (!biz.booking_enabled) return res.status(403).json({ error: "Las reservas online están desactivadas" }); + 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`) + .all(biz.id); + const employees = db + .prepare(`SELECT id, name, role, color FROM employees WHERE business_id = ? AND active = 1 ORDER BY name`) + .all(biz.id); + res.json({ business: { ...biz, currency_symbol: biz.currency_symbol || "$" }, services, employees }); +}); + +// Available slots for a service + employee on a given date +bookingRouter.get("/:slug/slots", (req, res) => { + const biz = publicBusiness(req.params.slug); + if (!biz || !biz.booking_enabled) return res.status(404).json({ error: "No disponible" }); + const serviceId = Number(req.query.service_id); + const employeeId = req.query.employee_id ? Number(req.query.employee_id) : null; + const date = req.query.date as string; // YYYY-MM-DD + if (!serviceId || !date) return res.status(400).json({ error: "service_id y date son obligatorios" }); + 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" }); + + // candidate employees + let candidates: number[]; + if (employeeId) { + const ok = db.prepare(`SELECT 1 FROM employee_services WHERE service_id = ? AND employee_id = ?`).get(serviceId, employeeId); + if (!ok) return res.status(400).json({ error: "El especialista no ofrece este servicio" }); + candidates = [employeeId]; + } 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] : []; + } + } + 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 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[]; + + for (let t = dayStart.getTime(); t + dur * 60000 <= dayEnd.getTime(); t += 30 * 60000) { + if (t < now.getTime() + 30 * 60000) continue; // skip past / too-soon + 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() + ); + if (!busy) { + const emp = db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any; + 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, + }); + 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 } }); +}); + +// 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); + if (!biz || !biz.booking_enabled) return res.status(404).json({ error: "No disponible" }); + 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)" }); + const service = db.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ? AND active = 1`).get(Number(service_id), biz.id) as any; + if (!service) return res.status(400).json({ error: "Servicio no válido" }); + 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" }); + + // resolve client (by phone/email or create) + 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) + : client.email + ? (db.prepare(`SELECT id FROM clients WHERE business_id = ? AND email = ?`).get(biz.id, client.email) as any) + : null; + if (match) clientId = match.id; + else { + const created = db + .prepare(`INSERT INTO clients (business_id, name, email, phone, tags) VALUES (?, ?, ?, ?, 'Online') RETURNING id`) + .get(biz.id, client.name, client.email || null, client.phone || null) as any; + 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"); + 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; + + res.status(201).json({ + appointment: { + id: appt.id, + start_at: appt.start_at, + end_at: appt.end_at, + price: appt.price, + service_name: service.name, + employee_name: (db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any)?.name, + }, + business: { name: biz.name, currency_symbol: biz.currency_symbol }, + client_created: !match, + }); +}); diff --git a/server/routes/business.ts b/server/routes/business.ts new file mode 100644 index 0000000..482e47f --- /dev/null +++ b/server/routes/business.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; +import { db } from "../db.ts"; +import type { AuthedRequest } from "../lib/auth.ts"; +import { err } from "../lib/auth.ts"; + +export const businessRouter = Router(); + +businessRouter.get("/", (req: AuthedRequest, res) => { + const b = db + .prepare( + `SELECT id, name, industry, currency, currency_symbol, phone, address FROM businesses WHERE id = ?` + ) + .get(req.user!.business_id); + if (!b) return err(res, 404, "Negocio no encontrado"); + res.json({ business: b }); +}); diff --git a/server/routes/cash.ts b/server/routes/cash.ts new file mode 100644 index 0000000..fe9d963 --- /dev/null +++ b/server/routes/cash.ts @@ -0,0 +1,119 @@ +import { Router } from "express"; +import { db } from "../db.ts"; +import type { AuthedRequest } from "../lib/auth.ts"; +import { err, ownerOnly } from "../lib/auth.ts"; + +export const cashRouter = Router(); +cashRouter.use(ownerOnly); + +function sessionStats(sessionId: number, businessId: number) { + const row = db + .prepare( + `SELECT + COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END),0) income, + COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END),0) expense, + COUNT(*) n + FROM cash_entries WHERE session_id = ? AND business_id = ?` + ) + .get(sessionId, businessId) as { income: number; expense: number; n: number }; + const tickets = db + .prepare( + `SELECT COALESCE(SUM(amount),0) s, COUNT(*) n FROM cash_entries WHERE session_id = ? AND business_id = ? AND ticket_id IS NOT NULL` + ) + .get(sessionId, businessId) as { s: number; n: number }; + return { + income: Math.round(row.income * 100) / 100, + expense: Math.round(row.expense * 100) / 100, + entries: row.n, + ticket_income: Math.round(tickets.s * 100) / 100, + ticket_count: tickets.n, + }; +} + +cashRouter.get("/session", (req: AuthedRequest, res) => { + const bid = req.user!.business_id as number; + const s = db + .prepare(`SELECT * FROM cash_sessions WHERE business_id = ? AND status = 'open' ORDER BY opened_at DESC LIMIT 1`) + .get(bid) as any; + if (!s) return res.json({ session: null }); + res.json({ session: s, stats: sessionStats(s.id, bid) }); +}); + +cashRouter.get("/sessions", (req: AuthedRequest, res) => { + const bid = req.user!.business_id as number; + const limit = Math.min(60, Number(req.query.limit) || 30); + const rows = db + .prepare(`SELECT * FROM cash_sessions WHERE business_id = ? ORDER BY opened_at DESC LIMIT ?`) + .all(bid, limit) as any[]; + const out = rows.map((s) => ({ session: s, stats: sessionStats(s.id, bid) })); + res.json({ sessions: out }); +}); + +cashRouter.post("/open", (req: AuthedRequest, res) => { + const bid = req.user!.business_id as number; + const open = db.prepare(`SELECT id FROM cash_sessions WHERE business_id = ? AND status = 'open'`).get(bid); + if (open) return err(res, 400, "Ya hay una caja abierta — ciérrala primero"); + const { opening_amount = 0, note } = req.body ?? {}; + const s = db + .prepare( + `INSERT INTO cash_sessions (business_id, opened_by_user_id, opening_amount, note) VALUES (?, ?, ?, ?) RETURNING *` + ) + .get(bid, req.user!.id, Number(opening_amount) || 0, note || null) as any; + res.status(201).json({ session: s, stats: sessionStats(s.id, bid) }); +}); + +cashRouter.post("/close", (req: AuthedRequest, res) => { + const bid = req.user!.business_id as number; + const s = db.prepare(`SELECT * FROM cash_sessions WHERE business_id = ? AND status = 'open'`).get(bid) as any; + if (!s) return err(res, 400, "No hay caja abierta"); + const { closing_amount, note } = req.body ?? {}; + const stats = sessionStats(s.id, bid); + const expected = Number(s.opening_amount) + stats.income - stats.expense; + db.prepare(`UPDATE cash_sessions SET status='closed', closed_at=datetime('now'), closing_amount=?, expected_amount=?, note=COALESCE(?,note) WHERE id=?`).run( + Number(closing_amount) || 0, + Math.round(expected * 100) / 100, + note || null, + s.id + ); + const updated = db.prepare(`SELECT * FROM cash_sessions WHERE id=?`).get(s.id) as any; + res.json({ session: updated, stats, expected: Math.round(expected * 100) / 100 }); +}); + +cashRouter.get("/entries", (req: AuthedRequest, res) => { + const bid = req.user!.business_id as number; + const sessionId = req.query.session_id ? Number(req.query.session_id) : null; + let rows: any[]; + if (sessionId) { + rows = db.prepare(`SELECT * FROM cash_entries WHERE business_id = ? AND session_id = ? ORDER BY created_at DESC`).all(bid, sessionId); + } else { + const s = db.prepare(`SELECT id FROM cash_sessions WHERE business_id = ? AND status='open'`).get(bid) as any; + rows = s + ? db.prepare(`SELECT * FROM cash_entries WHERE business_id = ? AND session_id = ? ORDER BY created_at DESC`).all(bid, s.id) + : []; + } + res.json({ entries: rows }); +}); + +cashRouter.post("/entries", (req: AuthedRequest, res) => { + const bid = req.user!.business_id as number; + const s = db.prepare(`SELECT id FROM cash_sessions WHERE business_id = ? AND status='open'`).get(bid) as any; + if (!s) return err(res, 400, "No hay caja abierta"); + const { type, amount, concept, payment_method } = req.body ?? {}; + if (!["income", "expense"].includes(type)) return err(res, 400, "Tipo inválido (income/expense)"); + if (!amount || !concept) return err(res, 400, "Monto y concepto son obligatorios"); + const r = db + .prepare( + `INSERT INTO cash_entries (business_id, session_id, type, amount, concept, payment_method) VALUES (?, ?, ?, ?, ?, ?) RETURNING *` + ) + .get(bid, s.id, type, Number(amount), concept, payment_method || null) as any; + res.status(201).json({ entry: r, stats: sessionStats(s.id, bid) }); +}); + +cashRouter.delete("/entries/:id", (req: AuthedRequest, res) => { + const bid = req.user!.business_id as number; + const id = Number(req.params.id); + const e = db.prepare(`SELECT session_id FROM cash_entries WHERE id = ? AND business_id = ?`).get(id, bid) as any; + if (!e) return err(res, 404, "Movimiento no encontrado"); + db.prepare(`DELETE FROM cash_entries WHERE id = ?`).run(id); + res.json({ ok: true, stats: sessionStats(e.session_id, bid) }); +}); diff --git a/server/routes/clients.ts b/server/routes/clients.ts new file mode 100644 index 0000000..a746eb1 --- /dev/null +++ b/server/routes/clients.ts @@ -0,0 +1,111 @@ +import { Router } from "express"; +import { db } from "../db.ts"; +import type { AuthedRequest } from "../lib/auth.ts"; +import { err } from "../lib/auth.ts"; +import type { ClientStats } from "../../shared/types.ts"; + +export const clientsRouter = Router(); + +function statsFor(clientId: number): ClientStats { + const agg = db + .prepare( + `SELECT + COUNT(*) c, + COALESCE(SUM(amount+tip),0) s, + MAX(created_at) last, + COALESCE(SUM(CASE WHEN amount > 0 THEN 1 ELSE 0 END)*1.0 / NULLIF(COUNT(*),0),0) ar + FROM tickets WHERE client_id = ?` + ) + .get(clientId) as { c: number; s: number; last: string | null; ar: number }; + const noShow = db + .prepare(`SELECT COUNT(*) c FROM appointments WHERE client_id = ? AND status = 'no_show'`) + .get(clientId) as { c: number }; + return { + visits: agg.c, + total_spent: Math.round(agg.s * 100) / 100, + last_visit: agg.last, + avg_ticket: agg.c > 0 ? Math.round((agg.s / agg.c) * 100) / 100 : 0, + no_show_count: noShow.c, + }; +} + +clientsRouter.get("/", (req: AuthedRequest, res) => { + const q = (req.query.q as string | undefined)?.trim(); + let rows: any[]; + if (q) { + const like = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`; + rows = db + .prepare( + `SELECT * FROM clients + WHERE business_id = ? AND (name LIKE ? ESCAPE '\\' OR phone LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\') + ORDER BY name LIMIT 50` + ) + .all(req.user!.business_id, like, like, like); + } else { + rows = db + .prepare(`SELECT * FROM clients WHERE business_id = ? ORDER BY name LIMIT 50`) + .all(req.user!.business_id); + } + rows.forEach((r) => (r.stats = statsFor(r.id))); + res.json({ clients: rows }); +}); + +clientsRouter.get("/:id", (req: AuthedRequest, res) => { + const c = db + .prepare(`SELECT * FROM clients WHERE id = ? AND business_id = ?`) + .get(Number(req.params.id), req.user!.business_id) as any; + if (!c) return err(res, 404, "Cliente no encontrado"); + c.stats = statsFor(c.id); + res.json({ client: c }); +}); + +clientsRouter.post("/", (req: AuthedRequest, res) => { + const { name, email, phone, notes, tags } = req.body ?? {}; + if (!name) return err(res, 400, "El nombre es obligatorio"); + const r = db + .prepare( + `INSERT INTO clients (business_id, name, email, phone, notes, tags) VALUES (?, ?, ?, ?, ?, ?) RETURNING *` + ) + .get( + req.user!.business_id, + name, + email || null, + phone || null, + notes || null, + tags || null + ) as any; + r.stats = statsFor(r.id); + res.status(201).json({ client: r }); +}); + +clientsRouter.patch("/:id", (req: AuthedRequest, res) => { + const id = Number(req.params.id); + const existing = db + .prepare(`SELECT * FROM clients WHERE id = ? AND business_id = ?`) + .get(id, req.user!.business_id) as any; + if (!existing) return err(res, 404, "Cliente no encontrado"); + const { name, email, phone, notes, tags } = req.body ?? {}; + db.prepare( + `UPDATE clients SET name = ?, email = ?, phone = ?, notes = ?, tags = ? WHERE id = ?` + ).run( + name ?? existing.name, + email ?? existing.email, + phone ?? existing.phone, + notes ?? existing.notes, + tags ?? existing.tags, + id + ); + const updated = db.prepare(`SELECT * FROM clients WHERE id = ?`).get(id) as any; + updated.stats = statsFor(id); + res.json({ client: updated }); +}); + +clientsRouter.delete("/:id", (req: AuthedRequest, res) => { + const id = Number(req.params.id); + const existing = db + .prepare(`SELECT id FROM clients WHERE id = ? AND business_id = ?`) + .get(id, req.user!.business_id); + if (!existing) return err(res, 404, "Cliente no encontrado"); + db.prepare(`DELETE FROM clients WHERE id = ?`).run(id); + res.json({ ok: true }); +}); diff --git a/server/routes/dashboard.ts b/server/routes/dashboard.ts new file mode 100644 index 0000000..d27ca80 --- /dev/null +++ b/server/routes/dashboard.ts @@ -0,0 +1,337 @@ +import { Router } from "express"; +import { db } from "../db.ts"; +import type { AuthedRequest } from "../lib/auth.ts"; +import { ownerOnly } from "../lib/auth.ts"; + +export const dashboardRouter = Router(); + +dashboardRouter.use(ownerOnly); + +dashboardRouter.get("/overview", (req: AuthedRequest, res) => { + const bid = req.user!.business_id; + const scalar = (sql: string, ...args: any[]) => (db.prepare(sql).get(...args) as any)?.v ?? 0; + + const revenue_today = scalar( + `SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND date(created_at) = date('now')`, + bid + ); + 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_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 + ); + const revenue_change_pct = + revenue_prev > 0 ? Math.round(((revenue_month - 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 + ); + const appts_week = scalar( + `SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= datetime('now','-7 days')`, + bid + ); + const appts_upcoming = scalar( + `SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'scheduled' AND start_at >= datetime('now')`, + bid + ); + const active_clients = scalar( + `SELECT COUNT(DISTINCT client_id) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`, + bid + ); + const avg_ticket = scalar( + `SELECT COALESCE(AVG(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`, + bid + ); + const total_range = scalar( + `SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND created_at >= datetime('now','-30 days')`, + bid + ); + 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 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 occupancy_pct = total_range > 0 ? Math.round((completed / total_range) * 100) : 0; + + 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_change_pct, + appts_today, + appts_week, + appts_upcoming, + active_clients, + avg_ticket: Math.round(avg_ticket * 100) / 100, + cancel_rate, + occupancy_pct, + }); +}); + +dashboardRouter.get("/best-employees", (req: AuthedRequest, res) => { + const bid = req.user!.business_id; + const range = (req.query.range as string) || "30"; + 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 + FROM employees e + LEFT JOIN tickets t ON t.employee_id = e.id AND t.created_at >= datetime('now','-${Number(range)} days') + WHERE e.business_id = ? + GROUP BY e.id + ORDER BY revenue DESC` + ) + .all(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, + })); + 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 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') + WHERE s.business_id = ? + GROUP BY s.id + ORDER BY revenue DESC` + ) + .all(bid) as any[]; + const out = rows.map((r) => ({ + service: { id: r.id, name: r.name, category: r.category, color: r.color }, + count: r.count, + revenue: Math.round(r.revenue * 100) / 100, + })); + res.json({ services: out }); +}); + +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 rows = db + .prepare( + `SELECT t.*, s.name service_name, s.category service_category, s.color service_color, + e.name employee_name, e.color employee_color, + c.name client_name + FROM tickets t + 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') + ORDER BY (t.amount + t.tip) DESC + LIMIT ?` + ) + .all(bid, limit) as any[]; + const out = rows.map((t) => ({ + ticket: { + id: t.id, + business_id: t.business_id, + appointment_id: t.appointment_id, + client_id: t.client_id, + employee_id: t.employee_id, + service_id: t.service_id, + amount: t.amount, + tip: t.tip, + payment_method: t.payment_method, + created_at: t.created_at, + service: { id: t.service_id, name: t.service_name, category: t.service_category, color: t.service_color }, + employee: { id: t.employee_id, name: t.employee_name, color: t.employee_color }, + client: { id: t.client_id, name: t.client_name }, + }, + })); + res.json({ tickets: out }); +}); + +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 rows = db + .prepare( + `SELECT c.id, c.name, c.tags, c.phone, + COUNT(t.id) visits, + 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') + WHERE c.business_id = ? + GROUP BY c.id + HAVING visits > 0 + ORDER BY total DESC + LIMIT ?` + ) + .all(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, + total_spent: Math.round(r.total * 100) / 100, + last_visit: r.last_visit, + })); + res.json({ clients: out }); +}); + +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 rows = db + .prepare( + `SELECT c.id, c.name, c.tags, c.phone, + COUNT(t.id) visits, + 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') + WHERE c.business_id = ? + GROUP BY c.id + ORDER BY visits DESC, total DESC + LIMIT ?` + ) + .all(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, + total_spent: Math.round(r.total * 100) / 100, + last_visit: r.last_visit, + })); + res.json({ clients: out }); +}); + +dashboardRouter.get("/revenue-trend", (req: AuthedRequest, res) => { + const bid = req.user!.business_id; + const days = Math.min(120, Number(req.query.days) || 30); + 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` + ) + .all(bid) as any[]; + // Fill missing days with 0 + const map = new Map(rows.map((r) => [r.date, r])); + const out: { date: string; revenue: number; appts: number }[] = []; + const today = 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 row = map.get(key); + out.push({ + date: key, + revenue: row ? Math.round(row.revenue * 100) / 100 : 0, + appts: row ? row.appts : 0, + }); + } + res.json({ points: out }); +}); + +dashboardRouter.get("/revenue-by-category", (req: AuthedRequest, res) => { + const bid = req.user!.business_id; + const range = (req.query.range as string) || "30"; + 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') + GROUP BY s.category + ORDER BY revenue DESC` + ) + .all(bid) as any[]; + const out = rows.map((r) => ({ + category: r.category, + count: r.count, + revenue: Math.round(r.revenue * 100) / 100, + })); + res.json({ slices: out }); +}); + +dashboardRouter.get("/tickets", (req: AuthedRequest, res) => { + const bid = req.user!.business_id; + const limit = Math.min(200, Number(req.query.limit) || 50); + const rows = db + .prepare( + `SELECT t.*, s.name service_name, s.category service_category, s.color service_color, + e.name employee_name, e.color employee_color, + c.name client_name + FROM tickets t + 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 = ? + ORDER BY t.created_at DESC + LIMIT ?` + ) + .all(bid, limit) as any[]; + const out = rows.map((t) => ({ + id: t.id, + business_id: t.business_id, + appointment_id: t.appointment_id, + client_id: t.client_id, + employee_id: t.employee_id, + service_id: t.service_id, + amount: t.amount, + tip: t.tip, + commission: t.commission, + payment_method: t.payment_method, + created_at: t.created_at, + service: { id: t.service_id, name: t.service_name, category: t.service_category, color: t.service_color }, + employee: { id: t.employee_id, name: t.employee_name, color: t.employee_color }, + client: { id: t.client_id, name: t.client_name }, + })); + res.json({ tickets: out }); +}); + +dashboardRouter.get("/commissions", (req: AuthedRequest, res) => { + const bid = req.user!.business_id; + const range = (req.query.range as string) || "30"; + const rows = db + .prepare( + `SELECT e.id, e.name, e.color, e.role, + COUNT(t.id) sales, + 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') + WHERE e.business_id = ? + GROUP BY e.id + ORDER BY commission DESC` + ) + .all(bid) as any[]; + const out = rows.map((r) => ({ + employee: { id: r.id, name: r.name, color: r.color, role: r.role }, + sales: r.sales, + revenue: Math.round(r.revenue * 100) / 100, + commission: Math.round(r.commission * 100) / 100, + })); + const total = out.reduce((a, r) => a + r.commission, 0); + res.json({ rows: out, total: Math.round(total * 100) / 100 }); +}); diff --git a/server/routes/employees.ts b/server/routes/employees.ts new file mode 100644 index 0000000..abd5e77 --- /dev/null +++ b/server/routes/employees.ts @@ -0,0 +1,136 @@ +import { Router } from "express"; +import { db } from "../db.ts"; +import type { AuthedRequest } from "../lib/auth.ts"; +import { err, ownerOnly } from "../lib/auth.ts"; +import type { EmployeeStats } from "../../shared/types.ts"; + +export const employeesRouter = Router(); + +function attachServices(emp: any) { + const ids = db + .prepare(`SELECT service_id FROM employee_services WHERE employee_id = ?`) + .all(emp.id) + .map((r: any) => r.service_id); + emp.service_ids = ids; + return emp; +} + +function computeStats(employeeId: number, businessId: number): EmployeeStats { + const completed = db + .prepare( + `SELECT COUNT(*) c, COALESCE(SUM(price),0) s FROM appointments WHERE employee_id = ? AND status = 'completed'` + ) + .get(employeeId) as { c: number; s: number }; + const total = db + .prepare(`SELECT COUNT(*) c FROM appointments WHERE employee_id = ?`) + .get(employeeId) as { c: number }; + const rating = db + .prepare( + `SELECT COALESCE((SELECT AVG(rating) FROM reviews WHERE employee_id = ?), (SELECT rating FROM employees WHERE id = ?)) r` + ) + .get(employeeId, employeeId) as { r: number }; + const topEmp = db + .prepare( + `SELECT COUNT(*) c FROM appointments WHERE employee_id = ? AND status IN ('completed','scheduled')` + ) + .get(employeeId) as { c: number }; + const totalBiz = db + .prepare( + `SELECT COUNT(*) c FROM appointments WHERE business_id = ? AND status IN ('completed','scheduled')` + ) + .get(businessId) as { c: number }; + const utilization = totalBiz.c > 0 ? Math.round((topEmp.c / totalBiz.c) * 100) : 0; + return { + appointments_total: total.c, + appointments_completed: completed.c, + revenue_total: completed.s, + avg_rating: Math.round((rating.r ?? 5) * 10) / 10, + utilization_pct: utilization, + }; +} + +employeesRouter.get("/", (req: AuthedRequest, res) => { + const rows = db + .prepare(`SELECT * FROM employees WHERE business_id = ? ORDER BY active DESC, name`) + .all(req.user!.business_id) as any[]; + const out = rows.map((r) => { + attachServices(r); + r.stats = computeStats(r.id, req.user!.business_id as number); + return r; + }); + res.json({ employees: out }); +}); + +employeesRouter.get("/:id", (req: AuthedRequest, res) => { + const emp = db.prepare(`SELECT * FROM employees WHERE id = ? AND business_id = ?`).get( + Number(req.params.id), + req.user!.business_id + ) as any; + if (!emp) return err(res, 404, "Empleado no encontrado"); + attachServices(emp); + emp.stats = computeStats(emp.id, req.user!.business_id as number); + res.json({ employee: emp }); +}); + +employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => { + const { name, role, email, phone, color, service_ids } = 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 *` + ) + .get( + req.user!.business_id, + name, + role || "Especialista", + email || null, + phone || null, + color || "#3b66ff" + ) as any; + if (Array.isArray(service_ids)) { + const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`); + for (const sid of service_ids) ins.run(r.id, Number(sid)); + } + attachServices(r); + r.stats = computeStats(r.id, req.user!.business_id as number); + res.status(201).json({ employee: r }); +}); + +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; + if (!existing) return err(res, 404, "Empleado no encontrado"); + const { name, role, email, phone, color, active, service_ids } = req.body ?? {}; + const merged = { + name: name ?? existing.name, + role: role ?? existing.role, + email: email ?? existing.email, + phone: phone ?? existing.phone, + color: color ?? existing.color, + active: active === undefined ? existing.active : active ? 1 : 0, + }; + 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); + 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 (?, ?)`); + for (const sid of service_ids) ins.run(id, Number(sid)); + } + const updated = db.prepare(`SELECT * FROM employees WHERE id = ?`).get(id) as any; + attachServices(updated); + updated.stats = computeStats(updated.id, req.user!.business_id as number); + res.json({ employee: updated }); +}); + +employeesRouter.delete("/:id", ownerOnly, (req: AuthedRequest, res) => { + const id = Number(req.params.id); + const existing = db + .prepare(`SELECT id FROM employees WHERE id = ? AND business_id = ?`) + .get(id, req.user!.business_id as number); + if (!existing) return err(res, 404, "Empleado no encontrado"); + db.prepare(`DELETE FROM employees WHERE id = ?`).run(id); + res.json({ ok: true }); +}); diff --git a/server/routes/notifications.ts b/server/routes/notifications.ts new file mode 100644 index 0000000..2a69bc0 --- /dev/null +++ b/server/routes/notifications.ts @@ -0,0 +1,147 @@ +import { Router } from "express"; +import { db } from "../db.ts"; +import type { AuthedRequest } from "../lib/auth.ts"; +import { err } from "../lib/auth.ts"; + +export const notificationsRouter = Router(); + +/** Build a human message for an appointment reminder/confirmation. */ +function buildMessage(biz: any, appt: any, kind: string): string { + const when = new Date(appt.start_at).toLocaleString("es-MX", { + weekday: "long", + day: "numeric", + month: "long", + hour: "2-digit", + minute: "2-digit", + timeZone: biz.timezone || "America/Mexico_City", + }); + const svc = appt.service_name || "tu servicio"; + const emp = appt.employee_name ? ` con ${appt.employee_name}` : ""; + if (kind === "confirmation") + return `¡Hola ${appt.client_name}! Tu cita en ${biz.name} está confirmada: ${svc}${emp}, ${when}.`; + if (kind === "cancellation") + return `Hola ${appt.client_name}, tu cita en ${biz.name} (${svc}) fue cancelada. Para reagendar responde a este mensaje.`; + if (kind === "review") + return `¡Gracias por tu visita a ${biz.name}, ${appt.client_name}! ¿Nos calificas? Tu opinión nos ayuda a mejorar.`; + return `Recordatorio ${biz.name}: tienes ${svc}${emp} el ${when}. ¡Te esperamos!`; +} + +/** (Re)generate pending notifications for the upcoming appointments of a business. */ +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 + const appts = db + .prepare( + `SELECT a.id, a.start_at, a.status, a.client_id, c.name client_name, c.phone, c.email, + s.name service_name, e.name employee_name + FROM appointments a + JOIN clients c ON c.id = a.client_id + 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') + ORDER BY a.start_at ASC` + ) + .all(businessId) 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')` + ).run(businessId); + for (const a of appts) { + const start = new Date(a.start_at); + const channel = a.phone ? "whatsapp" : a.email ? "email" : "whatsapp"; + // confirmation (immediate) + const confSend = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); + db.prepare( + `INSERT INTO notifications (business_id, appointment_id, client_id, channel, kind, send_at, status, message) + VALUES (?, ?, ?, ?, 'confirmation', ?, 'pending', ?)` + ).run(businessId, a.id, a.client_id, channel, confSend, buildMessage(biz, a, "confirmation")); + // reminder 24h before + const reminderAt = new Date(start.getTime() - 24 * 3600000); + if (reminderAt.getTime() > Date.now()) { + db.prepare( + `INSERT INTO notifications (business_id, appointment_id, client_id, channel, kind, send_at, status, message) + VALUES (?, ?, ?, ?, 'reminder', ?, 'pending', ?)` + ).run( + businessId, + a.id, + a.client_id, + channel, + reminderAt.toISOString().replace(/\.\d{3}Z$/, "Z"), + buildMessage(biz, a, "reminder") + ); + } + } +} + +notificationsRouter.get("/", (req: AuthedRequest, res) => { + const bid = req.user!.business_id as number; + const status = req.query.status as string | undefined; + let rows: any[]; + if (status && status !== "all") { + rows = db + .prepare( + `SELECT n.*, c.name client_name, c.phone, c.email, s.name service_name + FROM notifications n + LEFT JOIN clients c ON c.id = n.client_id + LEFT JOIN appointments a ON a.id = n.appointment_id + LEFT JOIN services s ON s.id = a.service_id + WHERE n.business_id = ? AND n.status = ? + ORDER BY n.send_at DESC LIMIT 200` + ) + .all(bid, status); + } else { + rows = db + .prepare( + `SELECT n.*, c.name client_name, c.phone, c.email, s.name service_name + FROM notifications n + LEFT JOIN clients c ON c.id = n.client_id + LEFT JOIN appointments a ON a.id = n.appointment_id + LEFT JOIN services s ON s.id = a.service_id + WHERE n.business_id = ? + ORDER BY n.send_at DESC LIMIT 200` + ) + .all(bid); + } + res.json({ notifications: rows }); +}); + +notificationsRouter.post("/regenerate", (req: AuthedRequest, res) => { + scheduleReminders(req.user!.business_id as number); + res.json({ ok: true }); +}); + +/** "Send" a notification — in this demo it simulates sending (marks as sent). */ +notificationsRouter.post("/:id/send", (req: AuthedRequest, res) => { + const bid = req.user!.business_id as number; + const id = Number(req.params.id); + const n = db.prepare(`SELECT * FROM notifications WHERE id = ? AND business_id = ?`).get(id, bid) as any; + if (!n) return err(res, 404, "Notificación no encontrada"); + db.prepare(`UPDATE notifications SET status='sent' WHERE id = ?`).run(id); + res.json({ ok: true, simulated: true }); +}); + +notificationsRouter.post("/:id/cancel", (req: AuthedRequest, res) => { + const bid = req.user!.business_id as number; + const id = Number(req.params.id); + const n = db.prepare(`SELECT id FROM notifications WHERE id = ? AND business_id = ?`).get(id, bid); + if (!n) return err(res, 404, "Notificación no encontrada"); + db.prepare(`UPDATE notifications SET status='canceled' WHERE id = ?`).run(id); + res.json({ ok: true }); +}); + +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; + 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'`), + upcoming_reminders: scalar( + `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')` + ), + }); +}); diff --git a/server/routes/services.ts b/server/routes/services.ts new file mode 100644 index 0000000..9d3290a --- /dev/null +++ b/server/routes/services.ts @@ -0,0 +1,113 @@ +import { Router } from "express"; +import { db } from "../db.ts"; +import type { AuthedRequest } from "../lib/auth.ts"; +import { err, ownerOnly } from "../lib/auth.ts"; + +export const servicesRouter = Router(); + +function attachEmployees(svc: any) { + const ids = db + .prepare(`SELECT employee_id FROM employee_services WHERE service_id = ?`) + .all(svc.id) + .map((r: any) => r.employee_id); + svc.employee_ids = ids; + return svc; +} + +servicesRouter.get("/", (req: AuthedRequest, res) => { + const { employee_id, active } = req.query; + let rows: any[]; + if (employee_id) { + rows = db + .prepare( + `SELECT s.* FROM services s + JOIN employee_services es ON es.service_id = s.id + WHERE s.business_id = ? AND es.employee_id = ? + ORDER BY s.category, s.name` + ) + .all(req.user!.business_id, Number(employee_id)); + } else { + rows = db + .prepare( + `SELECT * FROM services WHERE business_id = ? ORDER BY category, name` + ) + .all(req.user!.business_id); + } + if (active === "true") rows = rows.filter((r) => r.active === 1); + rows.forEach(attachEmployees); + res.json({ services: rows }); +}); + +servicesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => { + const { name, category, description, duration_min, price, color, employee_ids, active } = req.body ?? {}; + if (!name) return err(res, 400, "El nombre es obligatorio"); + const r = db + .prepare( + `INSERT INTO services (business_id, name, category, description, duration_min, price, color, active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *` + ) + .get( + req.user!.business_id, + name, + category || "General", + description || null, + Number(duration_min) || 60, + Number(price) || 0, + color || "#3b66ff", + active === false ? 0 : 1 + ) as any; + if (Array.isArray(employee_ids)) { + const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`); + for (const eid of employee_ids) ins.run(Number(eid), r.id); + } + attachEmployees(r); + res.status(201).json({ service: r }); +}); + +servicesRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => { + const id = Number(req.params.id); + const existing = db + .prepare(`SELECT * FROM services WHERE id = ? AND business_id = ?`) + .get(id, req.user!.business_id) as any; + if (!existing) return err(res, 404, "Servicio no encontrado"); + const { name, category, description, duration_min, price, color, active, employee_ids } = req.body ?? {}; + const merged = { + name: name ?? existing.name, + category: category ?? existing.category, + description: description ?? existing.description, + duration_min: duration_min === undefined ? existing.duration_min : Number(duration_min), + price: price === undefined ? existing.price : Number(price), + color: color ?? existing.color, + active: active === undefined ? existing.active : active ? 1 : 0, + }; + db.prepare( + `UPDATE services SET name = ?, category = ?, description = ?, duration_min = ?, price = ?, color = ?, active = ? WHERE id = ?` + ).run( + merged.name, + merged.category, + merged.description, + merged.duration_min, + merged.price, + merged.color, + merged.active, + id + ); + if (Array.isArray(employee_ids)) { + db.prepare(`DELETE FROM employee_services WHERE service_id = ?`).run(id); + const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`); + for (const eid of employee_ids) ins.run(Number(eid), id); + } + const updated = db.prepare(`SELECT * FROM services WHERE id = ?`).get(id) as any; + attachEmployees(updated); + res.json({ service: updated }); +}); + +servicesRouter.delete("/:id", ownerOnly, (req: AuthedRequest, res) => { + const id = Number(req.params.id); + const existing = db + .prepare(`SELECT id FROM services WHERE id = ? AND business_id = ?`) + .get(id, req.user!.business_id); + if (!existing) return err(res, 404, "Servicio no encontrado"); + db.prepare(`DELETE FROM services WHERE id = ?`).run(id); + res.json({ ok: true }); +}); diff --git a/server/routes/settings.ts b/server/routes/settings.ts new file mode 100644 index 0000000..37483b4 --- /dev/null +++ b/server/routes/settings.ts @@ -0,0 +1,57 @@ +import { Router } from "express"; +import { db } from "../db.ts"; +import type { AuthedRequest } from "../lib/auth.ts"; +import { err, ownerOnly } from "../lib/auth.ts"; + +export const settingsRouter = Router(); + +const FIELDS = [ + "name", + "industry", + "phone", + "address", + "slug", + "booking_enabled", + "cancel_window_hours", + "cancel_penalty_pct", + "require_deposit", + "deposit_pct", + "timezone", +] 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 + FROM businesses WHERE id = ?` + ) + .get(req.user!.business_id) as any; + if (!b) return err(res, 404, "Negocio no encontrado"); + res.json({ settings: b }); +}); + +settingsRouter.patch("/", ownerOnly, (req: AuthedRequest, res) => { + const existing = db.prepare(`SELECT * FROM businesses WHERE id = ?`).get(req.user!.business_id) as any; + if (!existing) return err(res, 404, "Negocio no encontrado"); + const body = req.body ?? {}; + const merged: any = {}; + for (const f of FIELDS) { + if (body[f] !== undefined) merged[f] = body[f]; + } + // slug uniqueness check + if (merged.slug !== undefined) { + merged.slug = String(merged.slug).trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); + if (!merged.slug) return err(res, 400, "El slug no puede quedar vacío"); + const clash = db.prepare(`SELECT id FROM businesses WHERE slug = ? AND id != ?`).get(merged.slug, req.user!.business_id); + if (clash) return err(res, 409, "Esa URL ya está en uso"); + } + const sets = Object.keys(merged).map((k) => `${k} = @${k}`); + if (sets.length === 0) return res.json({ settings: existing }); + 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 = ?`) + .get(req.user!.business_id); + res.json({ settings: updated }); +}); diff --git a/server/scripts/seed.ts b/server/scripts/seed.ts new file mode 100644 index 0000000..864732c --- /dev/null +++ b/server/scripts/seed.ts @@ -0,0 +1,324 @@ +import { db } from "../db.ts"; +import { pathToFileURL } from "node:url"; +import { TEMPLATES, getTemplate, DEFAULT_TEMPLATE_KEY, type TemplateDef } from "../lib/templates.ts"; + +// ---- helpers ---- +function mulberry32(seed: number) { + let a = seed; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function isoOffsetDays(days: number, hour = 10, minute = 0) { + const d = new Date(); + d.setHours(hour, minute, 0, 0); + d.setDate(d.getDate() + days); + return d.toISOString().replace(/\.\d{3}Z$/, "Z"); +} +function addMinutes(iso: string, mins: number) { + const d = new Date(iso); + d.setMinutes(d.getMinutes() + mins); + return d.toISOString().replace(/\.\d{3}Z$/, "Z"); +} + +const PAYMENT_METHODS = ["card", "cash", "transfer"]; +const REVIEW_COMMENTS = [ + "Excelente servicio, muy profesional.", + "Me encantó el resultado, volveré pronto.", + "Súper recomendable, ambiente agradable.", + "Muy puntual y atento(a).", + "Buen trabajo pero podría mejorar la puntualidad.", + "El mejor servicio que he recibido.", + "", + "", +]; + +export interface SeedBusinessOptions { + businessId: number; + template: TemplateDef; + ownerEmail?: string; + ownerName?: string; + ownerPassword?: string; + ownerAvatarColor?: string; + clearFirst?: boolean; + withHistory?: boolean; // past appointments + tickets (default true) + withUpcoming?: boolean; // upcoming appointments (default true) + seed?: number; // deterministic PRNG seed +} + +/** Clear business demo data EXCEPT user accounts (owner/employees survive resets). + * User employee_id links are nulled to avoid dangling FKs; seedBusiness re-links them. */ +export function clearBusinessData(businessId: number) { + db.exec("PRAGMA foreign_keys = OFF;"); + db.prepare(`DELETE FROM reviews WHERE appointment_id IN (SELECT id FROM appointments WHERE business_id = ?)`).run(businessId); + db.prepare(`DELETE FROM tickets WHERE business_id = ?`).run(businessId); + db.prepare(`DELETE FROM appointments WHERE business_id = ?`).run(businessId); + db.prepare( + `DELETE FROM employee_services WHERE employee_id IN (SELECT id FROM employees WHERE business_id = ?) OR service_id IN (SELECT id FROM services WHERE business_id = ?)` + ).run(businessId, businessId); + db.prepare(`DELETE FROM services WHERE business_id = ?`).run(businessId); + db.prepare(`DELETE FROM clients WHERE business_id = ?`).run(businessId); + // detach employee users from soon-to-be-deleted employees, then delete employees + db.prepare(`UPDATE users SET employee_id = NULL WHERE business_id = ? AND role = 'employee'`).run(businessId); + db.prepare(`DELETE FROM employees WHERE business_id = ?`).run(businessId); + db.exec("PRAGMA foreign_keys = ON;"); +} + +/** + * Seed a business with template demo data. Idempotent per call when clearFirst=true. + * Creates owner + employee users, services, clients, and procedural appointments/tickets/reviews. + */ +export function seedBusiness(opts: SeedBusinessOptions) { + const { + businessId, + template, + ownerEmail, + ownerName, + ownerPassword = "demo1234", + ownerAvatarColor = "#3b66ff", + clearFirst = true, + withHistory = true, + withUpcoming = true, + seed = 20260725 + businessId * 7, + } = opts; + + if (clearFirst) clearBusinessData(businessId); + + const rnd = mulberry32(seed); + const pick = (arr: T[]): T => arr[Math.floor(rnd() * arr.length)]; + const pickN = (arr: T[], n: number): T[] => { + const copy = [...arr]; + const out: T[] = []; + for (let i = 0; i < n && copy.length; i++) out.push(copy.splice(Math.floor(rnd() * copy.length), 1)[0]); + return out; + }; + const rint = (min: number, max: number) => Math.floor(rnd() * (max - min + 1)) + min; + + // stamp template + currency on the business + db.prepare(`UPDATE businesses SET template = ?, currency = ?, currency_symbol = ? WHERE id = ?`).run( + template.key, + template.currency, + template.currency_symbol, + businessId + ); + + // ---- employees ---- + const empIds: number[] = []; + for (const e of template.employees) { + const r = db + .prepare( + `INSERT INTO employees (business_id, name, role, color, phone, email, hire_date) + VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id` + ) + .get( + businessId, + e.name, + e.role, + e.color, + `+52 55 ${rint(1000, 9999)} ${rint(1000, 9999)}`, + e.name.toLowerCase().replace(/\s+/g, ".") + template.emailDomain, + isoOffsetDays(-rint(180, 900), 9) + ) as { id: number }; + empIds.push(r.id); + } + + // ---- services ---- + const svcIds: number[] = []; + for (const s of template.services) { + // derive a demo commission rate by price tier (higher-priced services → higher commission) + const commissionPct = s.price >= 1500 ? 15 : s.price >= 800 ? 12 : s.price >= 400 ? 10 : 8; + const r = db + .prepare( + `INSERT INTO services (business_id, name, description, category, duration_min, price, color, commission_pct) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id` + ) + .get(businessId, s.name, `Servicio profesional de ${s.category.toLowerCase()}.`, s.category, s.duration_min, s.price, s.color, commissionPct) as { + id: number; + }; + svcIds.push(r.id); + } + for (let i = 0; i < template.services.length; i++) { + const s = template.services[i]; + const sid = svcIds[i]; + for (const ei of s.employees) { + if (empIds[ei]) db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`).run(empIds[ei], sid); + } + } + + // ---- clients ---- + const clientIds: number[] = []; + const clientTags = ["VIP", "Frecuente", "Nuevo", "Referido"]; + for (const c of template.clients) { + const r = db + .prepare( + `INSERT INTO clients (business_id, name, email, phone, tags, notes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id` + ) + .get( + businessId, + c.name, + c.name.toLowerCase().replace(/\s+/g, ".") + "@email.com", + `+52 55 ${rint(1000, 9999)} ${rint(1000, 9999)}`, + c.tags ?? pickN(clientTags, rint(0, 2)).join(","), + c.notes ?? pick(["Prefiere horario matutino", "Alergia a cierto producto", "Cliente puntual", ""]), + isoOffsetDays(-rint(1, 540)) + ) as { id: number }; + clientIds.push(r.id); + } + + if (clientIds.length === 0) { + // blank template: create a couple of placeholder clients so appointments work if added later + for (const name of ["Cliente Demo", "Cliente Ejemplo"]) { + const r = db + .prepare(`INSERT INTO clients (business_id, name, created_at) VALUES (?, ?, ?) RETURNING id`) + .get(businessId, name, isoOffsetDays(-30)) as { id: number }; + clientIds.push(r.id); + } + } + + // ---- owner + employee users (UPSERT: preserve existing accounts so tokens survive resets) ---- + if (ownerEmail) { + db.prepare( + `INSERT INTO users (business_id, email, password, name, role, avatar_color) + VALUES (?, ?, ?, ?, 'owner', ?) + ON CONFLICT(email) DO UPDATE SET business_id = excluded.business_id, name = excluded.name, role = 'owner'` + ).run(businessId, ownerEmail.toLowerCase(), ownerPassword, ownerName ?? "Dueño", ownerAvatarColor); + } + for (let i = 0; i < template.employees.length; i++) { + const e = template.employees[i]; + const empEmail = e.name.toLowerCase().replace(/\s+/g, ".") + template.emailDomain; + // re-link existing employee user to the freshly created employee record + db.prepare( + `INSERT INTO users (business_id, email, password, name, role, employee_id, avatar_color) + VALUES (?, ?, ?, ?, 'employee', ?, ?) + ON CONFLICT(email) DO UPDATE SET business_id = excluded.business_id, name = excluded.name, role = 'employee', employee_id = excluded.employee_id, avatar_color = excluded.avatar_color` + ).run(businessId, empEmail, "demo1234", e.name, empIds[i], e.color); + } + + // ---- appointments + tickets + reviews ---- + let pastCount = 0; + let upCount = 0; + + const makeAppt = (dayOffset: number, svcIdx: number, status: string, createdBy: number | null) => { + 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 minute = pick([0, 30]); + const start = isoOffsetDays(dayOffset, hour, minute); + const end = addMinutes(start, svc.duration_min); + const appt = db + .prepare( + `INSERT INTO appointments + (business_id, service_id, employee_id, client_id, start_at, end_at, status, price, created_by_user_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id` + ) + .get(businessId, svcId, empId, clientId, start, end, status, svc.price, createdBy, start) as { id: number }; + if (status === "completed") { + const tip = rnd() < 0.4 ? pick([20, 50, 50, 100, 100, 150]) : 0; + db.prepare( + `INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run(businessId, appt.id, clientId, empId, svcId, svc.price, tip, pick(PAYMENT_METHODS), end); + if (rnd() < 0.35) { + db.prepare( + `INSERT INTO reviews (appointment_id, client_id, employee_id, rating, comment, created_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(appt.id, clientId, empId, rint(3, 5), pick(REVIEW_COMMENTS), end); + } + } + return appt.id; + }; + + // resolve owner user id for created_by + const ownerRow = ownerEmail + ? (db.prepare(`SELECT id FROM users WHERE email = ?`).get(ownerEmail.toLowerCase()) as { id: number } | undefined) + : undefined; + + if (withHistory) { + for (let dayOffset = -60; dayOffset <= -1; dayOffset++) { + const wd = new Date(); + wd.setDate(wd.getDate() + dayOffset); + if (wd.getDay() === 0) continue; // skip Sundays + const n = rint(0, 4); + for (let k = 0; k < n; k++) { + let status = "completed"; + const roll = rnd(); + if (roll < 0.08) status = "cancelled"; + else if (roll < 0.12) status = "no_show"; + if (makeAppt(dayOffset, rint(0, template.services.length - 1), status, ownerRow?.id ?? null)) pastCount++; + } + } + } + + if (withUpcoming) { + for (let dayOffset = 1; dayOffset <= 14; dayOffset++) { + const n = rint(0, 5); + for (let k = 0; k < n; k++) { + if (makeAppt(dayOffset, rint(0, template.services.length - 1), "scheduled", ownerRow?.id ?? null)) upCount++; + } + } + // a few today + for (let k = 0; k < 4; k++) { + const done = rnd() < 0.4; + if (makeAppt(0, rint(0, template.services.length - 1), done ? "completed" : "scheduled", ownerRow?.id ?? null)) upCount++; + } + } + + // recompute ratings + db.exec(` + UPDATE employees SET rating = COALESCE( + (SELECT AVG(r.rating) FROM reviews r WHERE r.employee_id = employees.id), + 4.7 + (employees.id % 4) * 0.07 + ) WHERE business_id = ${businessId}; + `); + + // backfill commission on tickets generated during seeding (services already have commission_pct) + db.exec(` + UPDATE tickets SET commission = ROUND(tickets.amount * COALESCE(s.commission_pct,0) / 100.0, 2) + FROM services s WHERE s.id = tickets.service_id + AND tickets.business_id = ${businessId} + AND (tickets.commission IS NULL OR tickets.commission = 0) + AND COALESCE(s.commission_pct,0) > 0; + `); + + return { employees: empIds.length, services: svcIds.length, clients: clientIds.length, pastCount, upCount }; +} + +export function ensurePlatformAdmin() { + const exists = db.prepare(`SELECT id FROM users WHERE role = 'admin'`).get(); + if (exists) return; + db.prepare( + `INSERT INTO users (business_id, email, password, name, role, avatar_color) + VALUES (NULL, 'admin@agendapro.demo', 'demo1234', 'Administrador', 'admin', '#0f172a')` + ).run(); +} + +// Run directly as a script: ensure admin + default demo business, preserving existing data. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + ensurePlatformAdmin(); + // Ensure default demo business exists (don't touch existing data) + let biz = db.prepare(`SELECT id FROM businesses WHERE name = ?`).get("Lumière Estética & Spa") as { id: number } | undefined; + if (!biz) { + biz = db + .prepare(`INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status) 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 }; + seedBusiness({ businessId: biz.id, template: getTemplate(DEFAULT_TEMPLATE_KEY)!, ownerEmail: "owner@agendapro.demo", ownerName: "Daniela Reyes" }); + console.log(`[seed] Created default business #${biz.id} with estetica-spa template + admin user.`); + } else { + console.log(`[seed] Default business #${biz.id} already exists — data preserved.`); + } + // expose templates info + const all = db.prepare(`SELECT id, name, industry, plan, status, template FROM businesses`).all(); + console.log(`[seed] Businesses: ${JSON.stringify(all)}`); +} + +export { TEMPLATES, getTemplate, DEFAULT_TEMPLATE_KEY }; diff --git a/shared/types.ts b/shared/types.ts new file mode 100644 index 0000000..7998658 --- /dev/null +++ b/shared/types.ts @@ -0,0 +1,171 @@ +export type Role = "admin" | "owner" | "employee"; +export type AppointmentStatus = "scheduled" | "completed" | "cancelled" | "no_show"; + +export interface Business { + id: number; + name: string; + industry: string; + currency: string; + currency_symbol: string; + phone: string | null; + address: string | null; + slug?: string | null; + plan?: string; + status?: string; + template?: string | null; + trial_ends_at?: string | null; + created_at?: string; +} + +export interface User { + id: number; + business_id: number | null; + email: string; + name: string; + role: Role; + employee_id: number | null; + avatar_color: string; +} + +export interface Employee { + id: number; + business_id: number; + name: string; + email: string | null; + phone: string | null; + color: string; + role: string; + active: 0 | 1; + rating: number; + hire_date: string | null; + service_ids?: number[]; + stats?: EmployeeStats; +} + +export interface EmployeeStats { + appointments_total: number; + appointments_completed: number; + revenue_total: number; + avg_rating: number; + utilization_pct: number; +} + +export interface Service { + id: number; + business_id: number; + name: string; + description: string | null; + category: string; + duration_min: number; + price: number; + color: string; + active: 0 | 1; + employee_ids?: number[]; +} + +export interface Client { + id: number; + business_id: number; + name: string; + email: string | null; + phone: string | null; + notes: string | null; + tags: string | null; + created_at: string; + stats?: ClientStats; +} + +export interface ClientStats { + visits: number; + total_spent: number; + last_visit: string | null; + avg_ticket: number; + no_show_count: number; +} + +export interface Appointment { + id: number; + business_id: number; + service_id: number; + employee_id: number; + client_id: number; + start_at: string; + end_at: string; + status: AppointmentStatus; + price: number; + notes: string | null; + created_by_user_id: number | null; + created_at: string; + updated_at: string; + // joined fields + service?: Service; + employee?: Pick; + client?: Pick; +} + +export interface Ticket { + id: number; + business_id: number; + appointment_id: number | null; + client_id: number; + employee_id: number; + service_id: number; + amount: number; + tip: number; + payment_method: string; + created_at: string; + service?: Pick; + employee?: Pick; + client?: Pick; +} + +export interface DashboardOverview { + revenue_today: number; + revenue_week: number; + revenue_month: number; + revenue_change_pct: number; + appts_today: number; + appts_week: number; + appts_upcoming: number; + active_clients: number; + avg_ticket: number; + cancel_rate: number; + occupancy_pct: number; +} + +export interface RankedEmployee { + employee: Pick; + appointments: number; + revenue: number; + avg_rating: number; + utilization_pct: number; +} + +export interface RankedService { + service: Pick; + count: number; + revenue: number; +} + +export interface RankedClient { + client: Pick; + visits: number; + total_spent: number; + last_visit: string | null; +} + +export interface TopTicket { + ticket: Ticket; +} + +export interface RevenuePoint { + date: string; + revenue: number; + appts: number; +} + +export interface CategorySlice { + category: string; + revenue: number; + count: number; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..ffff58d --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,84 @@ +import { Navigate, Route, Routes } from "react-router-dom"; +import { AuthProvider, useAuth } from "./lib/auth"; +import { LoginPage } from "./pages/LoginPage"; +import { AppShell } from "./components/AppShell"; +import { AdminShell } from "./components/AdminShell"; +import { DashboardPage } from "./pages/DashboardPage"; +import { CalendarPage } from "./pages/CalendarPage"; +import { EmployeesPage } from "./pages/EmployeesPage"; +import { ServicesPage } from "./pages/ServicesPage"; +import { ClientsPage } from "./pages/ClientsPage"; +import { TicketsPage } from "./pages/TicketsPage"; +import { ClientDetailPage } from "./pages/ClientDetailPage"; +import { CashPage } from "./pages/CashPage"; +import { NotificationsPage } from "./pages/NotificationsPage"; +import { SettingsPage } from "./pages/SettingsPage"; +import { AdminOverviewPage } from "./pages/admin/AdminOverviewPage"; +import { AdminBusinessesPage } from "./pages/admin/AdminBusinessesPage"; +import { AdminBusinessDetailPage } from "./pages/admin/AdminBusinessDetailPage"; +import BookingPage from "./pages/public/BookingPage"; + +function Protected() { + const { user, loading } = useAuth(); + if (loading) { + return ( +
+
+
+ Cargando AgendaPro… +
+
+ ); + } + if (!user) return ; + + const isAdmin = user.role === "admin"; + + return ( + + {/* Admin routes */} + {isAdmin && ( + }> + } /> + } /> + } /> + } /> + } /> + + )} + + {/* Business routes */} + {!isAdmin && ( + }> + } /> + {user.role === "owner" ? ( + } /> + ) : ( + } /> + )} + } /> + } /> + } /> + {user.role === "owner" && } />} + {user.role === "owner" && } />} + {user.role === "owner" && } />} + {user.role === "owner" && } />} + {user.role === "owner" && } />} + {user.role === "owner" && } />} + {/* Block business users from admin */} + } /> + } /> + + )} + + ); +} + +export default function App() { + return ( + + } /> + } /> + + ); +} diff --git a/src/components/AdminShell.tsx b/src/components/AdminShell.tsx new file mode 100644 index 0000000..632539c --- /dev/null +++ b/src/components/AdminShell.tsx @@ -0,0 +1,141 @@ +import { useState } from "react"; +import { NavLink, Outlet, useLocation } from "react-router-dom"; +import { + Building2, + LayoutDashboard, + LogOut, + Menu, + X, + ShieldCheck, + Plus, +} from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../lib/auth"; +import { initials, cn } from "../lib/format"; + +interface NavItem { + to: string; + label: string; + icon: typeof Building2; + end?: boolean; +} + +const NAV: NavItem[] = [ + { to: "/admin", label: "Resumen", icon: LayoutDashboard, end: true }, + { to: "/admin/businesses", label: "Negocios", icon: Building2 }, +]; + +export function AdminShell() { + const { user, logout } = useAuth(); + const location = useLocation(); + const navigate = useNavigate(); + const [mobileOpen, setMobileOpen] = useState(false); + + if (!user) return null; + + const SidebarContent = ( + <> +
+
+ +
+
+
AgendaPro
+
Consola Admin
+
+
+ + + +
+
+ Modo administrador de plataforma. Puedes crear y gestionar todos los negocios. +
+
+
+ {initials(user.name)} +
+
+
{user.name}
+
Admin · {user.email}
+
+ +
+
+ + ); + + return ( +
+ + + {mobileOpen && ( +
+
setMobileOpen(false)} /> + +
+ )} + +
+
+ +
+
+ +
+ Admin +
+
+
+ +
+
+
+ ); +} diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx new file mode 100644 index 0000000..549d544 --- /dev/null +++ b/src/components/AppShell.tsx @@ -0,0 +1,163 @@ +import { useState } from "react"; +import { NavLink, Outlet, useLocation } from "react-router-dom"; +import { + CalendarDays, + LayoutDashboard, + Users, + Scissors, + Ticket as TicketIcon, + UserCircle, + LogOut, + Menu, + X, + Sparkles, + Wallet, + Bell, + Settings, +} from "lucide-react"; +import { useAuth } from "../lib/auth"; +import { initials, cn } from "../lib/format"; +import { DemoSwitcher } from "./DemoSwitcher"; + +interface NavItem { + to: string; + label: string; + icon: typeof CalendarDays; + ownerOnly?: boolean; +} + +const NAV: NavItem[] = [ + { to: "/dashboard", label: "Tablero", icon: LayoutDashboard, ownerOnly: true }, + { to: "/calendar", label: "Calendario", icon: CalendarDays }, + { to: "/clients", label: "Clientes", icon: Users }, + { to: "/employees", label: "Empleados", icon: UserCircle, ownerOnly: true }, + { to: "/services", label: "Servicios", icon: Scissors, ownerOnly: true }, + { to: "/cash", label: "Caja", icon: Wallet, ownerOnly: true }, + { to: "/tickets", label: "Tickets", icon: TicketIcon, ownerOnly: true }, + { to: "/notifications", label: "Recordatorios", icon: Bell, ownerOnly: true }, + { to: "/settings", label: "Configuración", icon: Settings, ownerOnly: true }, +]; + +export function AppShell() { + const { user, business, logout } = useAuth(); + const location = useLocation(); + const [mobileOpen, setMobileOpen] = useState(false); + + if (!user) return null; + const isOwner = user.role === "owner"; + const items = NAV.filter((n) => !n.ownerOnly || isOwner); + + const SidebarContent = ( + <> +
+
+ +
+
+
AgendaPro
+
{business?.name ?? "Tu negocio"}
+
+
+ + + +
+
+ +
+
+
+ {initials(user.name)} +
+
+
{user.name}
+
+ {isOwner ? "Dueño" : "Empleado"} +
+
+ +
+
+ + ); + + return ( +
+ {/* Desktop sidebar */} + + + {/* Mobile sidebar */} + {mobileOpen && ( +
+
setMobileOpen(false)} /> + +
+ )} + + {/* Main */} +
+
+ +
+
+ +
+ AgendaPro +
+
+
+ +
+
+
+ ); +} diff --git a/src/components/AppointmentModal.tsx b/src/components/AppointmentModal.tsx new file mode 100644 index 0000000..6e5ef95 --- /dev/null +++ b/src/components/AppointmentModal.tsx @@ -0,0 +1,593 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Search, + UserPlus, + Check, + Trash2, + XCircle, + CheckCircle2, + Save, + Clock, + DollarSign, + Stethoscope, +} from "lucide-react"; +import { api } from "../lib/api"; +import { useAuth } from "../lib/auth"; +import type { Appointment, Service } from "../../shared/types"; +import { Modal } from "./Modal"; +import { Spinner, Avatar } from "./ui"; +import { formatCurrency, statusLabel, statusColor, cn } from "../lib/format"; + +interface Props { + open: boolean; + onClose: () => void; + appointment: Appointment | null; + draftSlot: { start: string; end: string } | null; +} + +const TIME_SUGGESTIONS = [ + "08:00","08:30","09:00","09:30","10:00","10:30","11:00","11:30", + "12:00","12:30","13:00","13:30","14:00","14:30","15:00","15:30", + "16:00","16:30","17:00","17:30","18:00","18:30","19:00","19:30","20:00", +]; + +function toLocalInput(iso: string) { + const d = new Date(iso); + if (isNaN(d.getTime())) return { date: "", time: "10:00" }; + const pad = (n: number) => String(n).padStart(2, "0"); + return { + date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`, + time: `${pad(d.getHours())}:${pad(d.getMinutes())}`, + }; +} + +export function AppointmentModal({ open, onClose, appointment, draftSlot }: Props) { + const { user } = useAuth(); + const qc = useQueryClient(); + const isOwner = user?.role === "owner"; + const isEditing = !!appointment; + + const [serviceId, setServiceId] = useState(null); + const [employeeId, setEmployeeId] = useState(user?.employee_id ?? null); + const [clientId, setClientId] = useState(null); + const [clientMode, setClientMode] = useState<"existing" | "new">("existing"); + const [newClient, setNewClient] = useState({ name: "", phone: "", email: "", notes: "" }); + const [search, setSearch] = useState(""); + const [dateInput, setDateInput] = useState(""); + const [timeInput, setTimeInput] = useState("10:00"); + const [duration, setDuration] = useState(60); + const [price, setPrice] = useState(0); + const [notes, setNotes] = useState(""); + const [status, setStatus] = useState("scheduled"); + const [tip, setTip] = useState(0); + const [payment, setPayment] = useState("card"); + + const { data: servicesData } = useQuery({ queryKey: ["services"], queryFn: () => api.services.list() }); + const { data: employeesData } = useQuery({ queryKey: ["employees"], queryFn: () => api.employees.list() }); + + const services = servicesData?.services ?? []; + const employees = employeesData?.employees ?? []; + + const selectedService = useMemo( + () => services.find((s) => s.id === serviceId), + [services, serviceId] + ); + + // Eligible employees for the selected service + const eligibleEmployees = useMemo(() => { + if (!selectedService) return employees; + const ids = new Set(selectedService.employee_ids ?? []); + return employees.filter((e) => ids.has(e.id) && e.active); + }, [employees, selectedService]); + + // Hydrate state when opening + useEffect(() => { + if (!open) return; + if (appointment) { + setServiceId(appointment.service_id); + setEmployeeId(appointment.employee_id); + setClientId(appointment.client_id); + setClientMode("existing"); + const d = toLocalInput(appointment.start_at); + setDateInput(d.date); + setTimeInput(d.time); + setDuration( + Math.max( + 15, + Math.round( + (new Date(appointment.end_at).getTime() - new Date(appointment.start_at).getTime()) / 60000 + ) + ) + ); + setPrice(appointment.price); + setNotes(appointment.notes ?? ""); + setStatus(appointment.status); + } else if (draftSlot) { + setServiceId(null); + setEmployeeId(user?.employee_id ?? null); + setClientId(null); + setClientMode("existing"); + setNewClient({ name: "", phone: "", email: "", notes: "" }); + setSearch(""); + const d = toLocalInput(draftSlot.start); + setDateInput(d.date); + setTimeInput(d.time); + setDuration(Math.max(15, Math.round((new Date(draftSlot.end).getTime() - new Date(draftSlot.start).getTime()) / 60000)) || 60); + setPrice(0); + setNotes(""); + setStatus("scheduled"); + } + setTip(0); + setPayment("card"); + }, [open, appointment, draftSlot, user]); + + // When service changes, sync duration/price and employee default + useEffect(() => { + if (!selectedService) return; + setDuration(selectedService.duration_min); + setPrice(selectedService.price); + if (!isEditing) { + // For employees creating: keep their employee_id if eligible + if (user?.role === "employee" && user.employee_id && (selectedService.employee_ids ?? []).includes(user.employee_id)) { + setEmployeeId(user.employee_id); + } else if (eligibleEmployees.length > 0 && !eligibleEmployees.find((e) => e.id === employeeId)) { + setEmployeeId(eligibleEmployees[0].id); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedService]); + + const { data: searchResults, isFetching: searching } = useQuery({ + queryKey: ["clients", "search", search], + queryFn: () => api.clients.list(search), + enabled: search.length > 0 && clientMode === "existing", + }); + + const startIso = useMemo(() => { + if (!dateInput) return null; + const [h, m] = timeInput.split(":").map(Number); + const d = new Date(`${dateInput}T00:00:00`); + d.setHours(h || 10, m || 0, 0, 0); + return d.toISOString(); + }, [dateInput, timeInput]); + + const createMut = useMutation({ + mutationFn: (payload: any) => api.appointments.create(payload), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["appointments"] }); + qc.invalidateQueries({ queryKey: ["clients"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + qc.invalidateQueries({ queryKey: ["employees"] }); + onClose(); + }, + }); + const updateMut = useMutation({ + mutationFn: ({ id, payload }: { id: number; payload: any }) => api.appointments.update(id, payload), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["appointments"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + qc.invalidateQueries({ queryKey: ["employees"] }); + onClose(); + }, + }); + const completeMut = useMutation({ + mutationFn: ({ id, tip, payment }: { id: number; tip: number; payment: string }) => + api.appointments.complete(id, { tip, payment_method: payment }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["appointments"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + qc.invalidateQueries({ queryKey: ["employees"] }); + qc.invalidateQueries({ queryKey: ["clients"] }); + onClose(); + }, + }); + const deleteMut = useMutation({ + mutationFn: (id: number) => api.appointments.remove(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["appointments"] }); + qc.invalidateQueries({ queryKey: ["dashboard"] }); + onClose(); + }, + }); + + const canSave = !!serviceId && !!dateInput && !!timeInput && (clientId || (clientMode === "new" && newClient.name.trim())) && !!employeeId; + + const handleSave = () => { + if (!canSave || !startIso) return; + const payload: any = { + service_id: serviceId, + employee_id: employeeId, + duration_min: duration, + price_override: price, + start_at: startIso, + notes: notes || undefined, + status, + }; + if (clientMode === "existing") payload.client_id = clientId; + else payload.new_client = newClient; + + if (isEditing && appointment) { + updateMut.mutate({ id: appointment.id, payload }); + } else { + createMut.mutate(payload); + } + }; + + const busy = createMut.isPending || updateMut.isPending || completeMut.isPending || deleteMut.isPending; + const errorMsg = (createMut.error || updateMut.error || completeMut.error || deleteMut.error) as any; + + const st = statusColor(status); + + return ( + +
+ {/* Service + Employee */} +
+
+ + + {selectedService && ( +
+ + {selectedService.category} + + {selectedService.duration_min} min · {formatCurrency(selectedService.price)} +
+ )} +
+
+ + + {!isOwner && ( +

+ La cita se asigna automáticamente a ti. +

+ )} +
+
+ + {/* Client */} +
+
+ +
+ + +
+
+ + {clientMode === "existing" ? ( +
+
+ + { + setSearch(e.target.value); + setClientId(null); + }} + /> + {searching && } +
+ {clientId && ( +
+ c.id === clientId)?.name ?? "?"} size={28} /> +
+
+ {searchResults?.clients.find((c) => c.id === clientId)?.name} +
+
+ +
+ )} + {!clientId && search && searchResults && ( +
+ {searchResults.clients.length === 0 ? ( +
+ Sin resultados.{" "} + +
+ ) : ( + searchResults.clients.slice(0, 12).map((c) => ( + + )) + )} +
+ )} + {!clientId && !search && ( +

Escribe para buscar un cliente existente.

+ )} +
+ ) : ( +
+
+ + setNewClient({ ...newClient, name: e.target.value })} + placeholder="Nombre del cliente" + /> +
+
+ + setNewClient({ ...newClient, phone: e.target.value })} + placeholder="+52 …" + /> +
+
+ + setNewClient({ ...newClient, email: e.target.value })} + placeholder="cliente@email.com" + /> +
+
+ )} +
+ + {/* Date / Time / Duration / Price */} +
+
+ + setDateInput(e.target.value)} /> +
+
+ + +
+
+ +
+ {[30, 45, 60, 75, 90, 120, 150, 180].map((d) => ( + + ))} +
+
+
+ + setPrice(Number(e.target.value))} + /> +
+
+ + {/* Notes */} +
+ +