UI: reemplazar diálogos nativos del navegador por modales/avisos propios de la plataforma
Los confirm()/alert()/prompt() nativos se veían fuera de la marca ("<dominio> dice…").
Se agrega un DialogProvider (montado en el layout autenticado) con su estilo visual y se
consume via hooks useConfirm / usePrompt / useToast:
- confirmaciones -> modal propio (con variante "peligro" para eliminaciones)
- prompts (crear paquete / fase) -> modal con input
- alerts de error/éxito -> toasts
Reemplazados todos los diálogos nativos: RegistroHorasPanel, CatalogoClient,
ConfiguracionClient, CotizacionForm, ExportButtons, ListDeleteButton, DeleteButton,
CambiarEstadoButtons. Validado E2E (Playwright): al revertir una partida pagada aparece
el modal propio y NO se dispara el confirm nativo.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1aab45f29c
commit
26e6d3a9bf
@@ -17,6 +17,7 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { formatCurrency, FASES as FASES_LABELS } from "@/lib/calculators";
|
import { formatCurrency, FASES as FASES_LABELS } from "@/lib/calculators";
|
||||||
|
import { useConfirm, usePrompt, useToast } from "@/components/ui/DialogProvider";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
interface Servicio {
|
interface Servicio {
|
||||||
@@ -71,6 +72,9 @@ type Tab = "categorias" | "paquetes" | "servicios";
|
|||||||
const FASES = FASES_LABELS;
|
const FASES = FASES_LABELS;
|
||||||
|
|
||||||
export function CatalogoClient({ initialServicios, initialCategorias, initialPaquetes }: Props) {
|
export function CatalogoClient({ initialServicios, initialCategorias, initialPaquetes }: Props) {
|
||||||
|
const showConfirm = useConfirm();
|
||||||
|
const showPrompt = usePrompt();
|
||||||
|
const showToast = useToast();
|
||||||
const [tab, setTab] = useState<Tab>("servicios");
|
const [tab, setTab] = useState<Tab>("servicios");
|
||||||
const [servicios, setServicios] = useState<Servicio[]>(initialServicios);
|
const [servicios, setServicios] = useState<Servicio[]>(initialServicios);
|
||||||
const [categorias, setCategorias] = useState<Categoria[]>(initialCategorias);
|
const [categorias, setCategorias] = useState<Categoria[]>(initialCategorias);
|
||||||
@@ -106,14 +110,14 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
let msg = `${data.creados} servicio(s) importado(s).`;
|
let msg = `${data.creados} servicio(s) importado(s).`;
|
||||||
if (data.omitidos > 0) msg += ` ${data.omitidos} omitido(s).`;
|
if (data.omitidos > 0) msg += ` ${data.omitidos} omitido(s).`;
|
||||||
if (data.errores?.length) msg += `\n\nErrores:\n${data.errores.join("\n")}`;
|
showToast(msg, "success");
|
||||||
alert(msg);
|
if (data.errores?.length) showToast(`Errores:\n${data.errores.join("\n")}`, "error");
|
||||||
await refresh();
|
await refresh();
|
||||||
} else {
|
} else {
|
||||||
alert(data.error || "Error al importar");
|
showToast(data.error || "Error al importar", "error");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert("Error al importar CSV");
|
showToast("Error al importar CSV", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
@@ -123,7 +127,7 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
|
|||||||
const deleteCategoria = async (id: string) => {
|
const deleteCategoria = async (id: string) => {
|
||||||
const res = await fetch(`/api/categorias/${id}`, { method: "DELETE" });
|
const res = await fetch(`/api/categorias/${id}`, { method: "DELETE" });
|
||||||
if (res.ok) setCategorias((p) => p.filter((c) => c.id !== id));
|
if (res.ok) setCategorias((p) => p.filter((c) => c.id !== id));
|
||||||
else { const e = await res.json(); alert(e.error); }
|
else { const e = await res.json(); showToast(e.error, "error"); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleCategoriaActivo = async (cat: Categoria) => {
|
const toggleCategoriaActivo = async (cat: Categoria) => {
|
||||||
@@ -139,13 +143,24 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deletePaquete = async (id: string) => {
|
const deletePaquete = async (id: string) => {
|
||||||
if (!confirm("Eliminar este paquete y todas sus fases?")) return;
|
const ok = await showConfirm({
|
||||||
|
title: "Eliminar paquete",
|
||||||
|
message: "¿Eliminar este paquete y todas sus fases?",
|
||||||
|
confirmText: "Eliminar",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
const res = await fetch(`/api/paquetes/${id}`, { method: "DELETE" });
|
const res = await fetch(`/api/paquetes/${id}`, { method: "DELETE" });
|
||||||
if (res.ok) setPaquetes((p) => p.filter((pk) => pk.id !== id));
|
if (res.ok) setPaquetes((p) => p.filter((pk) => pk.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreatePaquete = async () => {
|
const handleCreatePaquete = async () => {
|
||||||
const nombre = prompt("Nombre del paquete:");
|
const nombre = await showPrompt({
|
||||||
|
title: "Nuevo paquete",
|
||||||
|
label: "Nombre del paquete",
|
||||||
|
placeholder: "Ej. Paquete Emprendedor",
|
||||||
|
confirmText: "Crear",
|
||||||
|
});
|
||||||
if (!nombre) return;
|
if (!nombre) return;
|
||||||
const res = await fetch("/api/paquetes", {
|
const res = await fetch("/api/paquetes", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -159,7 +174,12 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addFaseToPaquete = async (paqueteId: string) => {
|
const addFaseToPaquete = async (paqueteId: string) => {
|
||||||
const nombre = prompt("Nombre de la fase:");
|
const nombre = await showPrompt({
|
||||||
|
title: "Nueva fase",
|
||||||
|
label: "Nombre de la fase",
|
||||||
|
placeholder: "Ej. Fase 1 - Setup",
|
||||||
|
confirmText: "Agregar",
|
||||||
|
});
|
||||||
if (!nombre) return;
|
if (!nombre) return;
|
||||||
const pk = paquetes.find((p) => p.id === paqueteId);
|
const pk = paquetes.find((p) => p.id === paqueteId);
|
||||||
const orden = pk ? pk.fases.length : 0;
|
const orden = pk ? pk.fases.length : 0;
|
||||||
@@ -179,7 +199,13 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteFase = async (paqueteId: string, faseId: string) => {
|
const deleteFase = async (paqueteId: string, faseId: string) => {
|
||||||
if (!confirm("Eliminar esta fase?")) return;
|
const ok = await showConfirm({
|
||||||
|
title: "Eliminar fase",
|
||||||
|
message: "¿Eliminar esta fase?",
|
||||||
|
confirmText: "Eliminar",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
const res = await fetch(`/api/paquetes/${paqueteId}/manage`, {
|
const res = await fetch(`/api/paquetes/${paqueteId}/manage`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -343,7 +369,8 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
|
|||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={async () => {
|
<button onClick={async () => {
|
||||||
if (!confirm(`Eliminar "${s.nombre}"?`)) return;
|
const ok = await showConfirm({ title: "Eliminar servicio", message: `¿Eliminar "${s.nombre}"?`, confirmText: "Eliminar", danger: true });
|
||||||
|
if (!ok) return;
|
||||||
const res = await fetch(`/api/catalogo/${s.id}`, { method: "DELETE" });
|
const res = await fetch(`/api/catalogo/${s.id}`, { method: "DELETE" });
|
||||||
if (res.ok) { const d = await res.json(); if (d.archived) { setServicios((p) => p.map((x) => x.id === s.id ? { ...x, activo: false } : x)); } else { setServicios((p) => p.filter((x) => x.id !== s.id)); } }
|
if (res.ok) { const d = await res.json(); if (d.archived) { setServicios((p) => p.map((x) => x.id === s.id ? { ...x, activo: false } : x)); } else { setServicios((p) => p.filter((x) => x.id !== s.id)); } }
|
||||||
}} className="p-1.5 text-muted hover:text-red-500 rounded" title="Eliminar">
|
}} className="p-1.5 text-muted hover:text-red-500 rounded" title="Eliminar">
|
||||||
@@ -529,6 +556,7 @@ function CreateCategoriaInline({ onCreated }: { onCreated: () => void }) {
|
|||||||
const [nombre, setNombre] = useState("");
|
const [nombre, setNombre] = useState("");
|
||||||
const [color, setColor] = useState("#6b7280");
|
const [color, setColor] = useState("#6b7280");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const showToast = useToast();
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!nombre.trim()) return;
|
if (!nombre.trim()) return;
|
||||||
@@ -540,7 +568,7 @@ function CreateCategoriaInline({ onCreated }: { onCreated: () => void }) {
|
|||||||
body: JSON.stringify({ nombre: nombre.trim(), color }),
|
body: JSON.stringify({ nombre: nombre.trim(), color }),
|
||||||
});
|
});
|
||||||
if (res.ok) { setNombre(""); onCreated(); }
|
if (res.ok) { setNombre(""); onCreated(); }
|
||||||
else { const e = await res.json(); alert(e.error); }
|
else { const e = await res.json(); showToast(e.error, "error"); }
|
||||||
} finally { setSaving(false); }
|
} finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { Save, Upload, Trash2, Loader2, Image as ImageIcon } from "lucide-react";
|
import { Save, Upload, Trash2, Loader2, Image as ImageIcon } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/DialogProvider";
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{
|
{
|
||||||
@@ -63,6 +64,7 @@ export function ConfiguracionClient({ initial }: { initial: Record<string, strin
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const showToast = useToast();
|
||||||
|
|
||||||
const handleChange = (key: string, value: string) => {
|
const handleChange = (key: string, value: string) => {
|
||||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -73,7 +75,7 @@ export function ConfiguracionClient({ initial }: { initial: Record<string, strin
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (file.size > 500 * 1024) {
|
if (file.size > 500 * 1024) {
|
||||||
alert("El logo debe ser menor a 500KB");
|
showToast("El logo debe ser menor a 500KB", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -102,10 +104,10 @@ export function ConfiguracionClient({ initial }: { initial: Record<string, strin
|
|||||||
setSaved(true);
|
setSaved(true);
|
||||||
setTimeout(() => setSaved(false), 2000);
|
setTimeout(() => setSaved(false), 2000);
|
||||||
} else {
|
} else {
|
||||||
alert("Error al guardar");
|
showToast("Error al guardar", "error");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert("Error al guardar");
|
showToast("Error al guardar", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,29 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useConfirm, useToast } from "@/components/ui/DialogProvider";
|
||||||
|
|
||||||
export function ListDeleteButton({ id }: { id: string }) {
|
export function ListDeleteButton({ id }: { id: string }) {
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const showConfirm = useConfirm();
|
||||||
|
const showToast = useToast();
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!confirm("Eliminar esta cotizacion?")) return;
|
const ok = await showConfirm({
|
||||||
|
title: "Eliminar cotización",
|
||||||
|
message: "¿Eliminar esta cotización?",
|
||||||
|
confirmText: "Eliminar",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/cotizaciones/${id}`, { method: "DELETE" });
|
const res = await fetch(`/api/cotizaciones/${id}`, { method: "DELETE" });
|
||||||
if (res.ok) router.refresh();
|
if (res.ok) router.refresh();
|
||||||
else alert("Error al eliminar");
|
else showToast("Error al eliminar", "error");
|
||||||
} catch {
|
} catch {
|
||||||
alert("Error al eliminar");
|
showToast("Error al eliminar", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useToast } from "@/components/ui/DialogProvider";
|
||||||
|
|
||||||
const OPCIONES = [
|
const OPCIONES = [
|
||||||
{ value: "borrador", label: "Borrador" },
|
{ value: "borrador", label: "Borrador" },
|
||||||
@@ -13,6 +14,7 @@ const OPCIONES = [
|
|||||||
export function CambiarEstadoButtons({ cotizacionId, estado }: { cotizacionId: string; estado: string }) {
|
export function CambiarEstadoButtons({ cotizacionId, estado }: { cotizacionId: string; estado: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const showToast = useToast();
|
||||||
|
|
||||||
const handleChange = async (nuevoEstado: string) => {
|
const handleChange = async (nuevoEstado: string) => {
|
||||||
if (nuevoEstado === estado) return;
|
if (nuevoEstado === estado) return;
|
||||||
@@ -24,7 +26,7 @@ export function CambiarEstadoButtons({ cotizacionId, estado }: { cotizacionId: s
|
|||||||
body: JSON.stringify({ estado: nuevoEstado }),
|
body: JSON.stringify({ estado: nuevoEstado }),
|
||||||
});
|
});
|
||||||
if (res.ok) router.refresh();
|
if (res.ok) router.refresh();
|
||||||
else alert("Error al cambiar estado");
|
else showToast("Error al cambiar estado", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useToast } from "@/components/ui/DialogProvider";
|
||||||
|
|
||||||
export function DeleteCotizacionButton({
|
export function DeleteCotizacionButton({
|
||||||
cotizacionId,
|
cotizacionId,
|
||||||
@@ -14,6 +15,7 @@ export function DeleteCotizacionButton({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const showToast = useToast();
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
@@ -24,11 +26,11 @@ export function DeleteCotizacionButton({
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
router.push("/cotizaciones");
|
router.push("/cotizaciones");
|
||||||
} else {
|
} else {
|
||||||
alert("Error al eliminar");
|
showToast("Error al eliminar", "error");
|
||||||
setConfirming(false);
|
setConfirming(false);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert("Error al eliminar");
|
showToast("Error al eliminar", "error");
|
||||||
setConfirming(false);
|
setConfirming(false);
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { Clock, Plus, Trash2, Pencil, Download, Eye, X, CheckCircle2, CircleDashed, RotateCcw } from "lucide-react";
|
import { Clock, Plus, Trash2, Pencil, Download, Eye, X, CheckCircle2, CircleDashed, RotateCcw } from "lucide-react";
|
||||||
|
import { useConfirm, useToast } from "@/components/ui/DialogProvider";
|
||||||
import {
|
import {
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
formatFechaRegistro,
|
formatFechaRegistro,
|
||||||
@@ -87,6 +88,8 @@ export function RegistroHorasPanel({
|
|||||||
branding,
|
branding,
|
||||||
registrosIniciales,
|
registrosIniciales,
|
||||||
}: RegistroHorasPanelProps) {
|
}: RegistroHorasPanelProps) {
|
||||||
|
const showConfirm = useConfirm();
|
||||||
|
const showToast = useToast();
|
||||||
const [registros, setRegistros] = useState<Registro[]>(registrosIniciales);
|
const [registros, setRegistros] = useState<Registro[]>(registrosIniciales);
|
||||||
const [form, setForm] = useState(formVacio(tarifaSugerida));
|
const [form, setForm] = useState(formVacio(tarifaSugerida));
|
||||||
const [editId, setEditId] = useState<string | null>(null);
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
@@ -216,7 +219,13 @@ export function RegistroHorasPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!confirm("¿Eliminar este registro de horas?")) return;
|
const ok = await showConfirm({
|
||||||
|
title: "Eliminar registro",
|
||||||
|
message: "¿Eliminar este registro de horas?",
|
||||||
|
confirmText: "Eliminar",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
const prev = registros;
|
const prev = registros;
|
||||||
setRegistros((rs) => rs.filter((r) => r.id !== id));
|
setRegistros((rs) => rs.filter((r) => r.id !== id));
|
||||||
if (editId === id) resetForm();
|
if (editId === id) resetForm();
|
||||||
@@ -255,8 +264,13 @@ export function RegistroHorasPanel({
|
|||||||
|
|
||||||
// Regresa una partida pagada por error a "Por pagar". Pide confirmación porque se
|
// Regresa una partida pagada por error a "Por pagar". Pide confirmación porque se
|
||||||
// pierde la marca/fecha de pago y la partida vuelve a entrar en la nota por cobrar.
|
// pierde la marca/fecha de pago y la partida vuelve a entrar en la nota por cobrar.
|
||||||
const regresarPorPagar = (r: Registro) => {
|
const regresarPorPagar = async (r: Registro) => {
|
||||||
if (!confirm('¿Regresar esta partida a "Por pagar"? Se quitará la marca de pagada.')) return;
|
const ok = await showConfirm({
|
||||||
|
title: "Regresar a Por pagar",
|
||||||
|
message: 'Se quitará la marca de pagada y esta partida volverá a contar como pendiente de cobro.',
|
||||||
|
confirmText: "Regresar a Por pagar",
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
cambiarEstado(r, "por_pagar");
|
cambiarEstado(r, "por_pagar");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,7 +289,7 @@ export function RegistroHorasPanel({
|
|||||||
body: JSON.stringify({ estado: estadoFiltro, modo, from, to }),
|
body: JSON.stringify({ estado: estadoFiltro, modo, from, to }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
alert("No se pudo generar el PDF");
|
showToast("No se pudo generar el PDF", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
@@ -295,7 +309,7 @@ export function RegistroHorasPanel({
|
|||||||
a.remove();
|
a.remove();
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||||
} catch {
|
} catch {
|
||||||
alert("Error de conexion al generar el PDF");
|
showToast("Error de conexión al generar el PDF", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setDescargando(false);
|
setDescargando(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Sidebar from "@/components/layout/Sidebar";
|
import Sidebar from "@/components/layout/Sidebar";
|
||||||
|
import { DialogProvider } from "@/components/ui/DialogProvider";
|
||||||
|
|
||||||
export default function AppLayout({
|
export default function AppLayout({
|
||||||
children,
|
children,
|
||||||
@@ -6,11 +7,11 @@ export default function AppLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<DialogProvider>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 lg:ml-64">
|
<main className="flex-1 lg:ml-64">
|
||||||
<div className="p-4 lg:p-8 pt-16 lg:pt-8">{children}</div>
|
<div className="p-4 lg:p-8 pt-16 lg:pt-8">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</DialogProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
useCotizacionStore,
|
useCotizacionStore,
|
||||||
ServicioSeleccionado,
|
ServicioSeleccionado,
|
||||||
} from "@/lib/store";
|
} from "@/lib/store";
|
||||||
|
import { useToast } from "@/components/ui/DialogProvider";
|
||||||
import {
|
import {
|
||||||
ExportPDFButtonDraft,
|
ExportPDFButtonDraft,
|
||||||
ExportExcelButtonDraft,
|
ExportExcelButtonDraft,
|
||||||
@@ -136,6 +137,7 @@ export function CotizacionForm({
|
|||||||
}: CotizacionFormProps) {
|
}: CotizacionFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const store = useCotizacionStore();
|
const store = useCotizacionStore();
|
||||||
|
const showToast = useToast();
|
||||||
|
|
||||||
const [expandedFases, setExpandedFases] = useState<Set<number>>(
|
const [expandedFases, setExpandedFases] = useState<Set<number>>(
|
||||||
new Set([0, 1, 2, 3])
|
new Set([0, 1, 2, 3])
|
||||||
@@ -369,10 +371,10 @@ export function CotizacionForm({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
alert("Error: " + (err.error || "No se pudo guardar"));
|
showToast("Error: " + (err.error || "No se pudo guardar"), "error");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert("Error al guardar la cotizacion");
|
showToast("Error al guardar la cotización", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Eye, FileDown, FileSpreadsheet, Loader2, X } from "lucide-react";
|
import { Eye, FileDown, FileSpreadsheet, Loader2, X } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useToast } from "@/components/ui/DialogProvider";
|
||||||
|
|
||||||
function getFilenameFromHeaders(headers: Headers, fallback: string): string {
|
function getFilenameFromHeaders(headers: Headers, fallback: string): string {
|
||||||
const cd = headers.get("Content-Disposition");
|
const cd = headers.get("Content-Disposition");
|
||||||
@@ -14,6 +15,7 @@ function getFilenameFromHeaders(headers: Headers, fallback: string): string {
|
|||||||
|
|
||||||
function useFileExport() {
|
function useFileExport() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const showToast = useToast();
|
||||||
|
|
||||||
const download = async (url: string, fallback: string) => {
|
const download = async (url: string, fallback: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -29,10 +31,10 @@ function useFileExport() {
|
|||||||
a.click();
|
a.click();
|
||||||
window.URL.revokeObjectURL(blobUrl);
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
} else {
|
} else {
|
||||||
alert("Error al exportar");
|
showToast("Error al exportar", "error");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert("Error al exportar");
|
showToast("Error al exportar", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -56,10 +58,10 @@ function useFileExport() {
|
|||||||
a.click();
|
a.click();
|
||||||
window.URL.revokeObjectURL(blobUrl);
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
} else {
|
} else {
|
||||||
alert("Error al exportar");
|
showToast("Error al exportar", "error");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert("Error al exportar");
|
showToast("Error al exportar", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -175,6 +177,7 @@ function PDFPreviewModal({
|
|||||||
function usePDFPreview() {
|
function usePDFPreview() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [preview, setPreview] = useState<{ url: string; filename: string } | null>(null);
|
const [preview, setPreview] = useState<{ url: string; filename: string } | null>(null);
|
||||||
|
const showToast = useToast();
|
||||||
|
|
||||||
const open = async (fetcher: () => Promise<Response>, fallback: string) => {
|
const open = async (fetcher: () => Promise<Response>, fallback: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -186,10 +189,10 @@ function usePDFPreview() {
|
|||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
setPreview({ url, filename });
|
setPreview({ url, filename });
|
||||||
} else {
|
} else {
|
||||||
alert("Error al generar la previsualización");
|
showToast("Error al generar la previsualización", "error");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert("Error al generar la previsualización");
|
showToast("Error al generar la previsualización", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { AlertTriangle, CheckCircle2, Info, XCircle, X } from "lucide-react";
|
||||||
|
|
||||||
|
// Sistema de ventanas emergentes PROPIO de la plataforma (con su estilo visual),
|
||||||
|
// para reemplazar los diálogos nativos del navegador (confirm/alert/prompt), que
|
||||||
|
// se ven fuera de la marca ("<dominio> dice…"). Se monta una sola vez en el layout
|
||||||
|
// y se consume con los hooks useConfirm / usePrompt / useToast.
|
||||||
|
|
||||||
|
type ConfirmOptions = {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PromptOptions = {
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
label?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToastKind = "success" | "error" | "info";
|
||||||
|
type ToastItem = { id: number; message: string; kind: ToastKind };
|
||||||
|
|
||||||
|
type DialogContextValue = {
|
||||||
|
confirm: (opts: ConfirmOptions | string) => Promise<boolean>;
|
||||||
|
prompt: (opts: PromptOptions | string) => Promise<string | null>;
|
||||||
|
toast: (message: string, kind?: ToastKind) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DialogContext = createContext<DialogContextValue | null>(null);
|
||||||
|
|
||||||
|
type ActiveConfirm = { kind: "confirm"; opts: ConfirmOptions; resolve: (v: boolean) => void };
|
||||||
|
type ActivePrompt = { kind: "prompt"; opts: PromptOptions; resolve: (v: string | null) => void };
|
||||||
|
type Active = ActiveConfirm | ActivePrompt | null;
|
||||||
|
|
||||||
|
export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [active, setActive] = useState<Active>(null);
|
||||||
|
const [promptValue, setPromptValue] = useState("");
|
||||||
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||||
|
const toastSeq = useRef(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const confirmBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const confirm = useCallback((o: ConfirmOptions | string) => {
|
||||||
|
const opts = typeof o === "string" ? { message: o } : o;
|
||||||
|
return new Promise<boolean>((resolve) => setActive({ kind: "confirm", opts, resolve }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const prompt = useCallback((o: PromptOptions | string) => {
|
||||||
|
const opts = typeof o === "string" ? { message: o } : o;
|
||||||
|
setPromptValue(opts.defaultValue ?? "");
|
||||||
|
return new Promise<string | null>((resolve) => setActive({ kind: "prompt", opts, resolve }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toast = useCallback((message: string, kind: ToastKind = "info") => {
|
||||||
|
const id = ++toastSeq.current;
|
||||||
|
setToasts((t) => [...t, { id, message, kind }]);
|
||||||
|
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Resuelven la promesa del diálogo activo y lo cierran. Se ejecutan en manejadores
|
||||||
|
// de eventos (post-render), capturando el `active` vigente: nada de efectos dentro
|
||||||
|
// del updater de setState (evita dobles resoluciones en StrictMode).
|
||||||
|
const finishConfirm = (v: boolean) => {
|
||||||
|
if (active?.kind === "confirm") active.resolve(v);
|
||||||
|
setActive(null);
|
||||||
|
};
|
||||||
|
const finishPrompt = (v: string | null) => {
|
||||||
|
if (active?.kind === "prompt") active.resolve(v);
|
||||||
|
setActive(null);
|
||||||
|
};
|
||||||
|
const dismiss = () => {
|
||||||
|
if (active?.kind === "confirm") active.resolve(false);
|
||||||
|
else if (active?.kind === "prompt") active.resolve(null);
|
||||||
|
setActive(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Foco automático al abrir + cerrar con Escape.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
if (active.kind === "prompt") inputRef.current?.focus();
|
||||||
|
else confirmBtnRef.current?.focus();
|
||||||
|
}, 30);
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
if (active.kind === "confirm") active.resolve(false);
|
||||||
|
else active.resolve(null);
|
||||||
|
setActive(null);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ confirm, prompt, toast }), [confirm, prompt, toast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{active && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4"
|
||||||
|
onClick={dismiss}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-xl bg-card-bg border border-border shadow-2xl overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
{active.kind === "confirm" ? (
|
||||||
|
<ConfirmBody
|
||||||
|
opts={active.opts}
|
||||||
|
confirmBtnRef={confirmBtnRef}
|
||||||
|
onCancel={() => finishConfirm(false)}
|
||||||
|
onConfirm={() => finishConfirm(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PromptBody
|
||||||
|
opts={active.opts}
|
||||||
|
value={promptValue}
|
||||||
|
setValue={setPromptValue}
|
||||||
|
inputRef={inputRef}
|
||||||
|
onCancel={() => finishPrompt(null)}
|
||||||
|
onSubmit={() => finishPrompt(promptValue)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Avisos (toasts) */}
|
||||||
|
<div className="fixed bottom-4 right-4 z-[110] flex flex-col gap-2 max-w-[calc(100vw-2rem)]">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<ToastCard key={t.id} item={t} onClose={() => setToasts((ts) => ts.filter((x) => x.id !== t.id))} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmBody({
|
||||||
|
opts,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
confirmBtnRef,
|
||||||
|
}: {
|
||||||
|
opts: ConfirmOptions;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
confirmBtnRef: React.RefObject<HTMLButtonElement | null>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={`mt-0.5 shrink-0 rounded-full p-2 ${
|
||||||
|
opts.danger ? "bg-red-100 text-red-600" : "bg-primary-light text-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opts.danger ? <AlertTriangle className="w-5 h-5" /> : <Info className="w-5 h-5" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{opts.title && <h3 className="font-semibold text-base mb-1">{opts.title}</h3>}
|
||||||
|
<p className="text-sm text-foreground/80 whitespace-pre-line">{opts.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 rounded-lg border border-border text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{opts.cancelText || "Cancelar"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
ref={confirmBtnRef}
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm text-white hover:opacity-90 ${
|
||||||
|
opts.danger ? "bg-red-600" : "bg-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opts.confirmText || "Aceptar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptBody({
|
||||||
|
opts,
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
inputRef,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
opts: PromptOptions;
|
||||||
|
value: string;
|
||||||
|
setValue: (v: string) => void;
|
||||||
|
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="p-5"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opts.title && <h3 className="font-semibold text-base mb-1">{opts.title}</h3>}
|
||||||
|
{opts.message && <p className="text-sm text-foreground/80 mb-3">{opts.message}</p>}
|
||||||
|
{opts.label && <label className="block text-xs text-muted mb-1">{opts.label}</label>}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder={opts.placeholder}
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 rounded-lg border border-border text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{opts.cancelText || "Cancelar"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!value.trim()}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm text-white bg-primary hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{opts.confirmText || "Aceptar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOAST_STYLE: Record<ToastKind, { cls: string; icon: React.ReactNode }> = {
|
||||||
|
success: { cls: "border-green-200 bg-green-50 text-green-800", icon: <CheckCircle2 className="w-4 h-4" /> },
|
||||||
|
error: { cls: "border-red-200 bg-red-50 text-red-800", icon: <XCircle className="w-4 h-4" /> },
|
||||||
|
info: { cls: "border-blue-200 bg-blue-50 text-blue-800", icon: <Info className="w-4 h-4" /> },
|
||||||
|
};
|
||||||
|
|
||||||
|
function ToastCard({ item, onClose }: { item: ToastItem; onClose: () => void }) {
|
||||||
|
const s = TOAST_STYLE[item.kind];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-start gap-2 rounded-lg border px-3 py-2 shadow-lg text-sm ${s.cls}`}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 shrink-0">{s.icon}</span>
|
||||||
|
<span className="min-w-0 break-words">{item.message}</span>
|
||||||
|
<button onClick={onClose} className="ml-1 shrink-0 opacity-60 hover:opacity-100" aria-label="Cerrar aviso">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDialogs(): DialogContextValue {
|
||||||
|
const ctx = useContext(DialogContext);
|
||||||
|
if (!ctx) throw new Error("useDialogs debe usarse dentro de <DialogProvider>");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConfirm = () => useDialogs().confirm;
|
||||||
|
export const usePrompt = () => useDialogs().prompt;
|
||||||
|
export const useToast = () => useDialogs().toast;
|
||||||
Reference in New Issue
Block a user