diff --git a/ALQUILER_CASAS_2026_LIMPIO.xlsx b/ALQUILER_CASAS_2026_LIMPIO.xlsx new file mode 100644 index 0000000..f8a58b0 Binary files /dev/null and b/ALQUILER_CASAS_2026_LIMPIO.xlsx differ diff --git a/apps/api/package.json b/apps/api/package.json index cbf45b0..8ba9104 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,6 +20,7 @@ "@types/express": "^5.0.0", "@types/node": "^24.10.1", "tsx": "^4.19.2", - "typescript": "~5.9.3" + "typescript": "~5.9.3", + "xlsx": "^0.18.5" } } diff --git a/apps/api/scripts/import-reservations.ts b/apps/api/scripts/import-reservations.ts new file mode 100644 index 0000000..e28bda4 --- /dev/null +++ b/apps/api/scripts/import-reservations.ts @@ -0,0 +1,254 @@ +/** + * Importa reservas reales desde el Excel ALQUILER_CASAS_2026_LIMPIO.xlsx. + * + * - Borra TODAS las reservas existentes (cascade limpia notification_events y contracts) + * - Inserta filas de LOS DRAGOS y LA ESQUINITA como estancias (origin Teneriffa2000) + * - Inserta filas de EVENTOS como reservas tipo evento (1 noche, is_event=true) + * - Encola notificaciones futuras (24h, 10d, 48h) usando la misma lógica que runner.ts + * - NO envía emails CRUD (escribe directo a Supabase, sin pasar por /api/notifications) + * + * Uso: + * pnpm --filter @naturcalabacera/api exec tsx --env-file=.env scripts/import-reservations.ts # dry-run + * pnpm --filter @naturcalabacera/api exec tsx --env-file=.env scripts/import-reservations.ts --apply # ejecuta + */ + +// Override de DNS: traefik.me wildcards no resuelven con DNS público en algunas redes. +// Forzamos el host de Supabase a su IP conocida. +import dns from 'dns'; +const SUPABASE_HOST_IP = '72.62.155.93'; +const originalLookup = dns.lookup.bind(dns); +// @ts-expect-error monkey-patch +dns.lookup = (hostname: string, options: unknown, callback: unknown) => { + if (typeof hostname === 'string' && hostname.endsWith('.traefik.me')) { + const opts = typeof options === 'object' && options !== null ? (options as { all?: boolean }) : {}; + const cb = (typeof options === 'function' ? options : callback) as (err: NodeJS.ErrnoException | null, addressOrList: unknown, family?: number) => void; + if (opts.all) return cb(null, [{ address: SUPABASE_HOST_IP, family: 4 }]); + return cb(null, SUPABASE_HOST_IP, 4); + } + // @ts-expect-error fallback + return originalLookup(hostname, options, callback); +}; + +// Stubs para env vars no necesarias en este script (email/notif destinos). +// Se setean ANTES de importar módulos que las requieren. +for (const k of [ + 'N8N_EMAIL_WEBHOOK_URL', + 'NOTIFICATION_EMAIL_TENERIFFA', + 'NOTIFICATION_EMAIL_NATUR', + 'NOTIFICATION_EMAIL_POOL_HEATING', +]) { + if (!process.env[k]) process.env[k] = 'unused@import.local'; +} + +import { readFileSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import * as XLSX from 'xlsx'; +import type { NewReservation, Property, ReservationOrigin } from '@naturcalabacera/shared'; + +// Imports dinámicos (después de stubear env) +const { supabaseAdmin } = await import('../src/lib/supabase.js'); +const { scheduleNotificationsForReservation } = await import('../src/jobs/runner.js'); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REPO_ROOT = path.resolve(__dirname, '../../..'); +const EXCEL_PATH = path.join(REPO_ROOT, 'ALQUILER_CASAS_2026_LIMPIO.xlsx'); + +const APPLY = process.argv.includes('--apply'); + +type Row = (string | null)[]; + +function parseDateDMY(s: string | null): string | null { + if (!s) return null; + const m = s.trim().match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); + if (!m) return null; + const [, d, mo, y] = m; + return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`; +} + +function num(s: string | null): number { + if (!s) return 0; + const n = parseInt(String(s).replace(/[^0-9-]/g, ''), 10); + return Number.isFinite(n) ? n : 0; +} + +function addDays(iso: string, days: number): string { + const d = new Date(iso + 'T12:00:00Z'); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} + +function detectEventType(evento: string): { event_type: string; event_type_other?: string } { + const t = evento.toLowerCase(); + if (t.includes('boda')) return { event_type: 'Boda' }; + if (t.includes('comuni')) return { event_type: 'Comunión' }; + if (t.includes('cumple')) return { event_type: 'Cumpleaños' }; + if (t.includes('bautizo')) return { event_type: 'Otro', event_type_other: 'Bautizo' }; + return { event_type: 'Otro', event_type_other: evento }; +} + +function mapStayRow(row: Row, property: Property): NewReservation | null { + const [invoice, status, entrada, salida, huesped, adultos, ninos, , , , limpieza, , , , notas] = row; + if (!entrada || !salida || !huesped) return null; + const start = parseDateDMY(entrada); + const end = parseDateDMY(salida); + if (!start || !end) return null; + if (status && String(status).toUpperCase().includes('CANCEL')) return null; + + const notasStr = (notas ?? '').toString(); + const hasPoolHeating = /calefacci/i.test(notasStr); + const hasCleaning = !!limpieza && num(limpieza) > 0; + + return { + start_date: start, + end_date: end, + client_name: String(huesped).trim(), + origin: 'Teneriffa2000' as ReservationOrigin, + property, + invoice_number: invoice ? String(invoice).trim() : undefined, + adults_count: num(adultos), + children_count: num(ninos), + has_cleaning: hasCleaning, + has_pool_heating: hasPoolHeating, + has_flies_products: false, + observations: notasStr.trim() || undefined, + }; +} + +function mapEventRow(row: Row): NewReservation | null { + const [fecha, , casa, evento, pax, organizador, estado, notas] = row; + if (!fecha || !casa || !evento) return null; + const start = parseDateDMY(fecha); + if (!start) return null; + if (estado && /cancel/i.test(String(estado))) return null; + + const property: Property | null = + /esquinita/i.test(String(casa)) ? 'la_esquinita' : + /dragos/i.test(String(casa)) ? 'los_dragos' : null; + if (!property) return null; + + const { event_type, event_type_other } = detectEventType(String(evento)); + const obsParts: string[] = [String(evento).trim()]; + if (organizador) obsParts.push(`Organizador: ${organizador}`); + if (notas) obsParts.push(String(notas)); + + return { + start_date: start, + end_date: addDays(start, 1), + client_name: String(evento).trim(), + origin: 'Naturcalabacera' as ReservationOrigin, + property, + adults_count: 0, + children_count: 0, + has_cleaning: false, + has_pool_heating: false, + has_flies_products: false, + is_event: true, + event_type, + event_type_other, + attendees_count: pax ? num(pax) : undefined, + observations: obsParts.join(' · '), + }; +} + +function isHeaderOrTotalRow(row: Row): boolean { + if (!row || row.every((c) => c === null || c === '')) return true; + const first = (row[0] ?? '').toString().trim().toUpperCase(); + if (first === 'TOTAL' || first === 'Nº FACTURA' || first.startsWith('LOS DRAGOS') || first.startsWith('LA ESQUINITA')) return true; + if (first.startsWith('RESERVAS CON NOCHES')) return true; + return false; +} + +async function main() { + console.log(`[import] Modo: ${APPLY ? 'APPLY (escribe en BD)' : 'DRY-RUN (no escribe)'}`); + console.log(`[import] Excel: ${EXCEL_PATH}`); + + const buf = readFileSync(EXCEL_PATH); + const wb = XLSX.read(buf, { type: 'buffer' }); + + const dragos = XLSX.utils.sheet_to_json(wb.Sheets['LOS DRAGOS'], { header: 1, defval: null, raw: false }); + const esquinita = XLSX.utils.sheet_to_json(wb.Sheets['LA ESQUINITA'], { header: 1, defval: null, raw: false }); + const eventos = XLSX.utils.sheet_to_json(wb.Sheets['EVENTOS'], { header: 1, defval: null, raw: false }); + + const reservations: NewReservation[] = []; + + for (const r of dragos) { + if (isHeaderOrTotalRow(r)) continue; + const m = mapStayRow(r, 'los_dragos'); + if (m) reservations.push(m); + } + for (const r of esquinita) { + if (isHeaderOrTotalRow(r)) continue; + const m = mapStayRow(r, 'la_esquinita'); + if (m) reservations.push(m); + } + for (const r of eventos) { + if (isHeaderOrTotalRow(r)) continue; + const first = (r[0] ?? '').toString().trim().toUpperCase(); + if (first === 'FECHA' || first.startsWith('BODAS') || first.startsWith('EVENTOS PUNTUALES')) continue; + const m = mapEventRow(r); + if (m) reservations.push(m); + } + + console.log(`[import] Reservas a crear: ${reservations.length}`); + console.log(`[import] - Estancias Los Dragos: ${reservations.filter(r => r.property === 'los_dragos' && !r.is_event).length}`); + console.log(`[import] - Estancias La Esquinita: ${reservations.filter(r => r.property === 'la_esquinita' && !r.is_event).length}`); + console.log(`[import] - Eventos: ${reservations.filter(r => r.is_event).length}`); + + console.log('\n[import] Muestra de las primeras 3 filas mapeadas:'); + console.log(JSON.stringify(reservations.slice(0, 3), null, 2)); + + if (!APPLY) { + console.log('\n[import] Dry-run. Re-ejecuta con --apply para escribir en BD.'); + return; + } + + console.log('\n[import] Borrando todas las reservas existentes...'); + const { count: existingCount } = await supabaseAdmin + .from('reservations') + .select('*', { count: 'exact', head: true }); + console.log(`[import] Reservas existentes: ${existingCount ?? '?'}`); + + // Borrado total. El cascade limpia notification_events y contracts. + const { error: delErr } = await supabaseAdmin + .from('reservations') + .delete() + .not('id', 'is', null); // condición trivial requerida por supabase-js + if (delErr) { + console.error('[import] Error al borrar:', delErr.message); + process.exit(1); + } + console.log('[import] ✓ Borrado completado.'); + + console.log('\n[import] Insertando reservas...'); + const { data: inserted, error: insErr } = await supabaseAdmin + .from('reservations') + .insert(reservations) + .select('*'); + if (insErr) { + console.error('[import] Error al insertar:', insErr.message); + process.exit(1); + } + console.log(`[import] ✓ Insertadas ${inserted?.length ?? 0} reservas.`); + + console.log('\n[import] Encolando notificaciones futuras (sin envío inmediato)...'); + let scheduled = 0; + for (const res of inserted ?? []) { + await scheduleNotificationsForReservation(res as never, 'created'); + scheduled++; + } + console.log(`[import] ✓ Procesadas ${scheduled} reservas para scheduling.`); + + const { count: pending } = await supabaseAdmin + .from('notification_events') + .select('*', { count: 'exact', head: true }) + .eq('status', 'pending'); + console.log(`[import] notification_events pendientes: ${pending ?? 0}`); + console.log('\n[import] ✅ Importación completada.'); +} + +main().catch((err) => { + console.error('[import] Error fatal:', err); + process.exit(1); +}); diff --git a/apps/web/src/components/UserManagement.tsx b/apps/web/src/components/UserManagement.tsx index 7966d9a..d74a254 100644 --- a/apps/web/src/components/UserManagement.tsx +++ b/apps/web/src/components/UserManagement.tsx @@ -13,6 +13,9 @@ interface UserProfileRow { created_at: string; } +// Toggle para reactivar el borrado de usuarios desde la UI. +const SHOW_DELETE_USER = false as boolean; + const ROLE_OPTIONS: { value: UserRole; label: string; description: string; icon: typeof Shield }[] = [ { value: 'admin', label: 'Admin', description: 'Acceso total y gestión de usuarios', icon: Shield }, { value: 'internal_staff', label: 'Staff', description: 'Gestión completa de reservas', icon: UsersIcon }, @@ -34,7 +37,8 @@ export function UserManagement() { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [inviteOpen, setInviteOpen] = useState(false); + // Invitación deshabilitada por ahora (sin SMTP). El modal y endpoint se conservan. + const [inviteOpen, _setInviteOpen] = useState(false); const [savingId, setSavingId] = useState(null); const loadUsers = async () => { @@ -84,6 +88,7 @@ export function UserManagement() { } }; + // Borrado deshabilitado en UI; se conserva para reactivar. const handleDelete = async (user: UserProfileRow) => { if (!confirm(`¿Eliminar a ${user.email}? Esta acción no se puede deshacer.`)) return; setSavingId(user.id); @@ -104,19 +109,9 @@ export function UserManagement() { return (
-
-
-

Usuarios

-

Gestiona accesos y roles del equipo.

-
- +
+

Usuarios

+

Gestiona accesos y roles del equipo. Las altas se hacen desde Supabase.

{error && ( @@ -138,13 +133,13 @@ export function UserManagement() { Email / Nombre Rol Creado - Acciones + {SHOW_DELETE_USER && Acciones} {users.length === 0 ? ( - + No hay usuarios registrados. @@ -171,17 +166,19 @@ export function UserManagement() { {new Date(user.created_at).toLocaleDateString('es-ES')} - - - + {SHOW_DELETE_USER && ( + + + + )} ))} @@ -192,10 +189,10 @@ export function UserManagement() { {inviteOpen && ( setInviteOpen(false)} + onClose={() => _setInviteOpen(false)} onInvited={(newUser) => { setUsers(prev => [newUser, ...prev]); - setInviteOpen(false); + _setInviteOpen(false); toast.success('Invitación enviada'); }} /> diff --git a/package.json b/package.json index d8fdaf0..0996aab 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,9 @@ "private": true, "version": "0.0.0", "pnpm": { - "onlyBuiltDependencies": ["esbuild"] + "onlyBuiltDependencies": [ + "esbuild" + ] }, "scripts": { "dev:web": "pnpm --filter @naturcalabacera/web dev", @@ -13,5 +15,8 @@ "build": "pnpm run build:web && pnpm run build:api", "lint": "pnpm --filter @naturcalabacera/web lint", "test": "pnpm --filter @naturcalabacera/shared test" + }, + "devDependencies": { + "xlsx": "^0.18.5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1726fa..392828c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,11 @@ settings: importers: - .: {} + .: + devDependencies: + xlsx: + specifier: ^0.18.5 + version: 0.18.5 apps/api: dependencies: @@ -38,6 +42,9 @@ importers: typescript: specifier: ~5.9.3 version: 5.9.3 + xlsx: + specifier: ^0.18.5 + version: 0.18.5 apps/web: dependencies: @@ -933,6 +940,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} @@ -1030,6 +1041,10 @@ packages: caniuse-lite@1.0.30001787: resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -1050,6 +1065,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1086,6 +1105,11 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1313,6 +1337,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -1861,6 +1889,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2108,10 +2140,18 @@ packages: engines: {node: '>=8'} hasBin: true + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + ws@8.20.0: resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} @@ -2124,6 +2164,11 @@ packages: utf-8-validate: optional: true + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2844,6 +2889,8 @@ snapshots: acorn@8.16.0: {} + adler-32@1.3.1: {} + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -2945,6 +2992,11 @@ snapshots: caniuse-lite@1.0.30001787: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -2974,6 +3026,8 @@ snapshots: clsx@2.1.1: {} + codepage@1.15.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3001,6 +3055,8 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + crc-32@1.2.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3301,6 +3357,8 @@ snapshots: forwarded@0.2.0: {} + frac@1.1.2: {} + fraction.js@5.3.4: {} framer-motion@12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): @@ -3793,6 +3851,10 @@ snapshots: source-map-js@1.2.1: {} + ssf@0.11.2: + dependencies: + frac: 1.1.2 + stackback@0.0.2: {} statuses@2.0.2: {} @@ -4018,10 +4080,24 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wmf@1.0.2: {} + word-wrap@1.2.5: {} + word@0.3.0: {} + ws@8.20.0: {} + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + yallist@3.1.1: {} yocto-queue@0.1.0: {}