feat: add reservation import script for Excel data and install xlsx dependency
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
254
apps/api/scripts/import-reservations.ts
Normal file
254
apps/api/scripts/import-reservations.ts
Normal file
@@ -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<Row>(wb.Sheets['LOS DRAGOS'], { header: 1, defval: null, raw: false });
|
||||
const esquinita = XLSX.utils.sheet_to_json<Row>(wb.Sheets['LA ESQUINITA'], { header: 1, defval: null, raw: false });
|
||||
const eventos = XLSX.utils.sheet_to_json<Row>(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);
|
||||
});
|
||||
@@ -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<UserProfileRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<string | null>(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 (
|
||||
<div className="max-w-4xl mx-auto p-4 md:p-6">
|
||||
<header className="mb-6 flex flex-col md:flex-row md:items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-4xl font-black text-white tracking-tight mb-1">Usuarios</h1>
|
||||
<p className="text-slate-400 text-sm">Gestiona accesos y roles del equipo.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInviteOpen(true)}
|
||||
className="inline-flex items-center gap-2 px-5 py-3 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-2xl shadow-lg shadow-emerald-900/30 transition-all"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
Invitar usuario
|
||||
</button>
|
||||
<header className="mb-6">
|
||||
<h1 className="text-3xl md:text-4xl font-black text-white tracking-tight mb-1">Usuarios</h1>
|
||||
<p className="text-slate-400 text-sm">Gestiona accesos y roles del equipo. Las altas se hacen desde Supabase.</p>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
@@ -138,13 +133,13 @@ export function UserManagement() {
|
||||
<th className="px-4 py-3 text-left font-bold">Email / Nombre</th>
|
||||
<th className="px-4 py-3 text-left font-bold">Rol</th>
|
||||
<th className="px-4 py-3 text-left font-bold hidden md:table-cell">Creado</th>
|
||||
<th className="px-4 py-3 text-right font-bold">Acciones</th>
|
||||
{SHOW_DELETE_USER && <th className="px-4 py-3 text-right font-bold">Acciones</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-12 text-center text-slate-500">
|
||||
<td colSpan={SHOW_DELETE_USER ? 4 : 3} className="px-4 py-12 text-center text-slate-500">
|
||||
No hay usuarios registrados.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -171,17 +166,19 @@ export function UserManagement() {
|
||||
<td className="px-4 py-3 text-slate-400 text-xs hidden md:table-cell">
|
||||
{new Date(user.created_at).toLocaleDateString('es-ES')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
disabled={savingId === user.id}
|
||||
onClick={() => handleDelete(user)}
|
||||
className="inline-flex p-2 text-slate-500 hover:text-red-400 transition-colors"
|
||||
title="Eliminar"
|
||||
>
|
||||
{savingId === user.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
</button>
|
||||
</td>
|
||||
{SHOW_DELETE_USER && (
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
disabled={savingId === user.id}
|
||||
onClick={() => handleDelete(user)}
|
||||
className="inline-flex p-2 text-slate-500 hover:text-red-400 transition-colors"
|
||||
title="Eliminar"
|
||||
>
|
||||
{savingId === user.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -192,10 +189,10 @@ export function UserManagement() {
|
||||
<AnimatePresence>
|
||||
{inviteOpen && (
|
||||
<InviteModal
|
||||
onClose={() => setInviteOpen(false)}
|
||||
onClose={() => _setInviteOpen(false)}
|
||||
onInvited={(newUser) => {
|
||||
setUsers(prev => [newUser, ...prev]);
|
||||
setInviteOpen(false);
|
||||
_setInviteOpen(false);
|
||||
toast.success('Invitación enviada');
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user