feat(mejoras): 25 mejoras UX en todos los módulos + purga Monday
Backend: mi-dia siempre local, vocabulario canónico, AplicacionPago con usuario Mi día: modal español real, cita desde modal, score falso eliminado Cotizador: barra sticky, persistencia local con folio Alta Clientes: redirect eliminado, foto conectada, asesor desde sesión, refs dinámicas Cobranza: contrato Mora arreglado, link a Plan, abono próximo pago, WhatsApp Landing: contactos locales, ModalContacto eliminado, ModoTrabajo eliminado Dashboards: MiTablero unificado, funnel con descartados, drill-down, rango fechas Chat FRASA: corpus 5 desarrollos, 6 herramientas, historial conversaciones 69 archivos, 7 apps construidas
This commit is contained in:
@@ -107,12 +107,15 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
|
||||
>
|
||||
Retomar
|
||||
</button>
|
||||
<a
|
||||
href={`/client/?cotizacion=${c.id}`}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Iniciar alta
|
||||
</a>
|
||||
{/* El alta prellenada requiere la cotización en el servidor; las locales (CTE-LOCAL-*) no la tienen */}
|
||||
{!c.id.startsWith('local-') && (
|
||||
<a
|
||||
href={`/client/?cotizacion=${c.id}`}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Iniciar alta
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -55,6 +55,30 @@ describe('crearDocumentoPdf', () => {
|
||||
})
|
||||
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('imprime el folio local CTE-LOCAL-* cuando el servidor no respondió', () => {
|
||||
const unidad = UNIDADES_SEED[0]
|
||||
const propuestas = calcularPropuestas({
|
||||
unidad,
|
||||
acabados: true,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: { ...DESCUENTOS_DEFAULT },
|
||||
numMeses: 14,
|
||||
})
|
||||
const { doc } = crearDocumentoPdf({
|
||||
unidad,
|
||||
acabados: true,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
precioLista: unidad.precioConAcabados,
|
||||
propuestas,
|
||||
cliente: { nombre: 'Juan Pérez' },
|
||||
folio: 'CTE-LOCAL-1757568000000',
|
||||
})
|
||||
// El folio local viaja en el contenido del PDF aunque no se haya guardado en el API
|
||||
const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer'))
|
||||
expect(pdf).toContain('CTE-LOCAL-1757568000000')
|
||||
expect(pdf).toContain('FOLIO')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cuota de mantenimiento', () => {
|
||||
|
||||
91
apps/cotizador/src/pages/CotizadorPage.smoke.test.tsx
Normal file
91
apps/cotizador/src/pages/CotizadorPage.smoke.test.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import CotizadorPage from './CotizadorPage'
|
||||
import { INVENTARIO_SEED } from '../data/unidades'
|
||||
|
||||
/**
|
||||
* Smoke del cierre de la cotización:
|
||||
* - barra sticky con propuesta elegida + Compartir/Guardar/Generar PDF
|
||||
* - preselección por defecto de la propuesta de menor total
|
||||
* - sin servidor: la cotización se guarda localmente con folio CTE-LOCAL-*
|
||||
*/
|
||||
const unidad = INVENTARIO_SEED.find((u) => u.status === 'DISPONIBLE' && u.desarrollo === 'DIAGONAL')!
|
||||
|
||||
const respuesta = (data: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
// El entorno de test no trae localStorage: mock en memoria para el fallback local
|
||||
const almacenamiento = new Map<string, string>()
|
||||
beforeEach(() => {
|
||||
almacenamiento.clear()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => almacenamiento.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void almacenamiento.set(k, v),
|
||||
removeItem: (k: string) => void almacenamiento.delete(k),
|
||||
clear: () => almacenamiento.clear(),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('CotizadorPage — barra sticky y guardado local', () => {
|
||||
it('preselecciona la propuesta más barata y guarda local con folio CTE-LOCAL cuando el API no responde', async () => {
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
const metodo = init?.method ?? 'GET'
|
||||
if (url.includes('/unidades')) return Promise.resolve(respuesta([unidad]))
|
||||
if (url.includes('/cotizaciones/mias')) return Promise.resolve(respuesta([]))
|
||||
if (url.includes('/cotizaciones') && metodo === 'POST') {
|
||||
return Promise.reject(new TypeError('servidor caído'))
|
||||
}
|
||||
return Promise.reject(new TypeError(`fetch inesperado: ${metodo} ${url}`))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CotizadorPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
// Barra sticky siempre presente, con las tres acciones
|
||||
expect(await screen.findByRole('button', { name: 'Generar PDF' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Guardar' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Compartir' })).toBeTruthy()
|
||||
expect(screen.getByText(/Selecciona una unidad para elegir propuesta/)).toBeTruthy()
|
||||
|
||||
// Elegir una unidad del inventario
|
||||
const inputUnidad = screen.getByLabelText('Unidad') as HTMLInputElement
|
||||
fireEvent.focus(inputUnidad)
|
||||
fireEvent.change(inputUnidad, { target: { value: unidad.codigo } })
|
||||
fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidad.codigo) }))
|
||||
|
||||
// Preselección por defecto: la propuesta de menor total pagado
|
||||
const selectPropuesta = (await screen.findByLabelText(
|
||||
'Cambiar propuesta elegida',
|
||||
)) as HTMLSelectElement
|
||||
await waitFor(() => expect(selectPropuesta.value).toBe('2')) // Contado es la más barata
|
||||
expect(screen.getByText(/Propuesta elegida: #3/)).toBeTruthy()
|
||||
|
||||
// Guardar con el servidor caído → folio local persistido en el dispositivo
|
||||
fireEvent.change(screen.getByLabelText(/Nombre del cliente/), {
|
||||
target: { value: 'Cliente Prueba Local' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Guardar' }))
|
||||
|
||||
await waitFor(() => {
|
||||
const locales = JSON.parse(
|
||||
almacenamiento.get('frasa-cotizador:cotizaciones-locales') ?? '[]',
|
||||
) as { folio?: string; clienteNombre?: string }[]
|
||||
expect(locales).toHaveLength(1)
|
||||
expect(locales[0]?.folio ?? '').toMatch(/^CTE-LOCAL-\d+$/)
|
||||
expect(locales[0]?.clienteNombre).toBe('Cliente Prueba Local')
|
||||
})
|
||||
expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
@@ -21,6 +21,7 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
|
||||
import { calcularVigencia } from '../lib/vigencia'
|
||||
import { folioCotizacion } from '../lib/folio'
|
||||
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
|
||||
import { indiceMenorTotal, totalPagado } from '../lib/calculations'
|
||||
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
|
||||
import {
|
||||
getMisCotizaciones,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
TIPO_DE_API,
|
||||
type CotizacionGuardada,
|
||||
} from '../services/api'
|
||||
import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db'
|
||||
import type { Cliente } from '../types'
|
||||
|
||||
const esquemaCliente = z.object({
|
||||
@@ -87,6 +89,8 @@ export default function CotizadorPage() {
|
||||
const [errorMias, setErrorMias] = useState<string | null>(null)
|
||||
const [ultimaGuardada, setUltimaGuardada] = useState<CotizacionGuardada | null>(null)
|
||||
const [modalClienteAbierto, setModalClienteAbierto] = useState(false)
|
||||
// true cuando el modal de cliente se abrió desde "Guardar" (sin generar PDF)
|
||||
const [guardarSinPdf, setGuardarSinPdf] = useState(false)
|
||||
const [guardando, setGuardando] = useState(false)
|
||||
const [aviso, setAviso] = useState<string | null>(null)
|
||||
// Prefill desde Mi día (botón "Cotizar" de una oportunidad): el id queda
|
||||
@@ -96,9 +100,22 @@ export default function CotizadorPage() {
|
||||
|
||||
useEffect(() => {
|
||||
let vivo = true
|
||||
/** Combina servidor + locales, sin duplicados, más recientes primero */
|
||||
const mezclar = (principal: CotizacionGuardada[], extra: CotizacionGuardada[]) => {
|
||||
const ids = new Set(principal.map((c) => c.id))
|
||||
const combinadas = [...principal, ...extra.filter((c) => !ids.has(c.id))]
|
||||
return combinadas
|
||||
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
|
||||
.slice(0, 50)
|
||||
}
|
||||
// Cotizaciones guardadas localmente cuando el API no respondió
|
||||
void cargarCotizacionesLocales().then((locales) => {
|
||||
if (!vivo || locales.length === 0) return
|
||||
setMisCotizaciones((prev) => mezclar(prev, locales as CotizacionGuardada[]))
|
||||
})
|
||||
getMisCotizaciones()
|
||||
.then((lista) => {
|
||||
if (vivo) setMisCotizaciones(lista)
|
||||
if (vivo) setMisCotizaciones((prev) => mezclar(lista, prev))
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[api] No se pudo cargar el historial de cotizaciones.', err)
|
||||
@@ -162,9 +179,63 @@ export default function CotizadorPage() {
|
||||
|
||||
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo
|
||||
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
|
||||
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer
|
||||
const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null
|
||||
const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !errorEnganche && !guardando
|
||||
|
||||
/** Persiste la cotización en el API; null si no hay conexión (el PDF sale igual, sin folio/QR) */
|
||||
// ---------- Preselección por defecto: la propuesta de menor total pagado ----------
|
||||
// Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta
|
||||
// más barata; la elección manual del vendedor se respeta hasta el siguiente
|
||||
// cambio de unidad. handleRetomar marca saltarAuto: manda SU propuesta guardada.
|
||||
const unidadPrevia = useRef<string | null | undefined>(undefined)
|
||||
const saltarAuto = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const codigo = unidad?.codigo ?? null
|
||||
const cambioUnidad = unidadPrevia.current !== undefined && unidadPrevia.current !== codigo
|
||||
unidadPrevia.current = codigo
|
||||
if (!cambioUnidad || saltarAuto.current) {
|
||||
saltarAuto.current = false
|
||||
return
|
||||
}
|
||||
const totales = propuestas.map((p) => totalPagado(p, apartadoEfectivo))
|
||||
const indiceMenor = indiceMenorTotal(totales.map((t) => t ?? Number.POSITIVE_INFINITY))
|
||||
if (indiceMenor >= 0) setPropuestaIndex(indiceMenor)
|
||||
}, [unidad?.codigo, propuestas, apartadoEfectivo])
|
||||
|
||||
/** true cuando la cotización vive solo en este dispositivo (el API no respondió) */
|
||||
const esCotizacionLocal = (c: CotizacionGuardada): boolean => c.id.startsWith('local-')
|
||||
|
||||
/** Instantánea local de la cotización con folio CTE-LOCAL-<timestamp> */
|
||||
const cotizacionLocal = (cliente: Cliente): CotizacionGuardada => {
|
||||
const timestamp = Date.now()
|
||||
return {
|
||||
id: `local-${timestamp}`,
|
||||
folio: `CTE-LOCAL-${timestamp}`,
|
||||
unidadCodigo: unidad!.codigo,
|
||||
desarrollo,
|
||||
clienteNombre: cliente.nombre,
|
||||
clienteTelefono: cliente.telefono ?? null,
|
||||
clienteEmail: cliente.email ?? null,
|
||||
tipoPropuesta: TIPO_A_API[propuestaActiva.tipo],
|
||||
acabados,
|
||||
apartado: apartadoEfectivo,
|
||||
descuento: propuestaActiva.descuento,
|
||||
precioLista,
|
||||
precioFinal: propuestaActiva.precioFinal,
|
||||
enganche: propuestaActiva.enganche,
|
||||
numMensualidades: propuestaActiva.numMensualidades ?? 0,
|
||||
mensualidad: propuestaActiva.mensualidad,
|
||||
pagoFinal: propuestaActiva.pagoFinal,
|
||||
createdAt: new Date(timestamp).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste la cotización en el API. Si no responde, la guarda localmente
|
||||
* (IndexedDB/localStorage) con folio CTE-LOCAL-* y la devuelve igual: el PDF
|
||||
* siempre sale con folio (sin QR, que solo existe para cotizaciones del servidor).
|
||||
*/
|
||||
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
|
||||
if (!unidad || !propuestaActiva) return null
|
||||
try {
|
||||
@@ -186,9 +257,14 @@ export default function CotizadorPage() {
|
||||
setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50))
|
||||
return conFolio
|
||||
} catch (err) {
|
||||
toast.error('No se pudo guardar la cotización'); console.warn('[api] No se pudo guardar la cotización; el PDF sale sin folio/QR.', err)
|
||||
setAviso('Sin conexión al servidor: la cotización no se guardó (PDF sin folio/QR).')
|
||||
return null
|
||||
console.warn('[api] No se pudo guardar en el servidor; se guarda localmente.', err)
|
||||
const local = cotizacionLocal(cliente)
|
||||
void guardarCotizacionLocal(local)
|
||||
setUltimaGuardada(local)
|
||||
setMisCotizaciones((prev) => [local, ...prev].slice(0, 50))
|
||||
toast('Cotización guardada en este dispositivo (sin conexión al servidor)')
|
||||
setAviso(`Sin conexión al servidor: cotización guardada localmente con folio ${local.folio}.`)
|
||||
return local
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,10 +277,13 @@ export default function CotizadorPage() {
|
||||
let qrDataUrl: string | undefined
|
||||
if (guardada) {
|
||||
folio = guardada.folio ?? folioCotizacion(guardada.id)
|
||||
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
|
||||
margin: 1,
|
||||
width: 200,
|
||||
})
|
||||
// El QR apunta al resumen público en el servidor; una cotización local no lo tiene
|
||||
if (!esCotizacionLocal(guardada)) {
|
||||
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
|
||||
margin: 1,
|
||||
width: 200,
|
||||
})
|
||||
}
|
||||
}
|
||||
generarPdf({
|
||||
unidad,
|
||||
@@ -227,6 +306,7 @@ export default function CotizadorPage() {
|
||||
if (!unidad) return
|
||||
// Sin nombre capturado, pide los datos del cliente en el modal
|
||||
if (nombreCliente.trim().length < 3) {
|
||||
setGuardarSinPdf(false)
|
||||
setModalClienteAbierto(true)
|
||||
return
|
||||
}
|
||||
@@ -237,12 +317,42 @@ export default function CotizadorPage() {
|
||||
})
|
||||
}
|
||||
|
||||
/** Guarda solo (sin PDF); con el flujo de modal si falta el nombre del cliente */
|
||||
const guardarSolo = async (cliente: Cliente) => {
|
||||
if (!unidad) return
|
||||
setGuardando(true)
|
||||
try {
|
||||
await guardarEnApi(cliente)
|
||||
} finally {
|
||||
setGuardando(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGuardar = () => {
|
||||
if (!unidad) return
|
||||
if (nombreCliente.trim().length < 3) {
|
||||
setGuardarSinPdf(true)
|
||||
setModalClienteAbierto(true)
|
||||
return
|
||||
}
|
||||
void guardarSolo({
|
||||
nombre: nombreCliente.trim(),
|
||||
email: watch('email') || undefined,
|
||||
telefono: watch('telefono') || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const handleModalAceptar = (cliente: Cliente) => {
|
||||
setModalClienteAbierto(false)
|
||||
setValue('nombre', cliente.nombre, { shouldValidate: true })
|
||||
if (cliente.telefono) setValue('telefono', cliente.telefono, { shouldValidate: true })
|
||||
if (cliente.email) setValue('email', cliente.email, { shouldValidate: true })
|
||||
void generarPdfConCliente(cliente)
|
||||
if (guardarSinPdf) {
|
||||
setGuardarSinPdf(false)
|
||||
void guardarSolo(cliente)
|
||||
} else {
|
||||
void generarPdfConCliente(cliente)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCompartir = async () => {
|
||||
@@ -256,7 +366,10 @@ export default function CotizadorPage() {
|
||||
...(ultimaGuardada
|
||||
? {
|
||||
folio: ultimaGuardada.folio ?? folioCotizacion(ultimaGuardada.id),
|
||||
urlPublica: urlPublicaCotizacion(ultimaGuardada.id),
|
||||
// La URL pública solo existe si la cotización vive en el servidor
|
||||
...(esCotizacionLocal(ultimaGuardada)
|
||||
? {}
|
||||
: { urlPublica: urlPublicaCotizacion(ultimaGuardada.id) }),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
@@ -281,6 +394,7 @@ export default function CotizadorPage() {
|
||||
}
|
||||
const tipo = TIPO_DE_API[c.tipoPropuesta]
|
||||
const idx = obtenerDesarrollo(c.desarrollo).propuestas.findIndex((p) => p.tipo === tipo)
|
||||
saltarAuto.current = true // no re-preseleccionar la más barata: manda la guardada
|
||||
restaurar({
|
||||
desarrollo: c.desarrollo,
|
||||
unidad: u,
|
||||
@@ -306,7 +420,7 @@ export default function CotizadorPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<main className="px-4 py-8 sm:px-6 lg:px-8 lg:py-10">
|
||||
<main className="px-4 py-8 pb-28 sm:px-6 lg:px-8 lg:py-10 lg:pb-28">
|
||||
{/* Encabezado de página */}
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
@@ -599,49 +713,6 @@ export default function CotizadorPage() {
|
||||
propuestas={propuestas}
|
||||
titulos={propuestas.map((p) => p.titulo)}
|
||||
/>
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-end sm:justify-end">
|
||||
<div>
|
||||
<label htmlFor="propuesta-elegida" className={etiquetaCampo}>
|
||||
Propuesta elegida
|
||||
</label>
|
||||
<select
|
||||
id="propuesta-elegida"
|
||||
value={Math.min(propuestaIndex, propuestas.length - 1)}
|
||||
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
|
||||
>
|
||||
{propuestas.map((p, i) => (
|
||||
<option key={p.tipo} value={i}>
|
||||
#{i + 1} {p.titulo}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCompartir}
|
||||
disabled={!puedeGenerarPdf}
|
||||
className="rounded-lg border border-accent bg-white px-5 py-2.5 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40 dark:bg-[#0F172A] dark:border-accent dark:text-accent"
|
||||
>
|
||||
Compartir
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerarPdf}
|
||||
disabled={!puedeGenerarPdf}
|
||||
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{guardando ? 'Guardando…' : 'Guardar y generar PDF'}
|
||||
</button>
|
||||
</div>
|
||||
{aviso && <p className="mt-2 text-right text-xs text-[#64748B] dark:text-[#94A3B8]">{aviso}</p>}
|
||||
{!puedeGenerarPdf && !guardando && (
|
||||
<p className="mt-2 text-right text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
Selecciona una unidad para guardar y generar el PDF.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -664,6 +735,71 @@ export default function CotizadorPage() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Barra fija de cierre: propuesta elegida + acciones, siempre visibles */}
|
||||
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[#E5E7EB] bg-white/95 shadow-[0_-4px_16px_rgba(15,23,42,0.08)] backdrop-blur dark:border-[#1E293B] dark:bg-[#0F172A]/95">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{propuestaActiva ? (
|
||||
<>
|
||||
<span className="inline-flex max-w-full items-center rounded-full bg-accent/10 px-3 py-1.5 text-xs font-semibold text-accent">
|
||||
<span className="truncate">
|
||||
Propuesta elegida: #{propuestas.indexOf(propuestaActiva) + 1} {propuestaActiva.titulo}
|
||||
{totalActivo !== null ? ` · Total ${formatoMoneda(totalActivo)}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<select
|
||||
aria-label="Cambiar propuesta elegida"
|
||||
value={Math.min(propuestaIndex, propuestas.length - 1)}
|
||||
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
|
||||
>
|
||||
{propuestas.map((p, i) => (
|
||||
<option key={p.tipo} value={i}>
|
||||
#{i + 1} {p.titulo}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
Selecciona una unidad para elegir propuesta, guardar y generar el PDF.
|
||||
</p>
|
||||
)}
|
||||
{aviso && (
|
||||
<p role="status" className="w-full text-xs text-[#64748B] dark:text-[#94A3B8] sm:w-auto sm:max-w-xs sm:truncate">
|
||||
{aviso}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCompartir}
|
||||
disabled={!puedeGenerarPdf}
|
||||
className="rounded-lg border border-accent bg-white px-4 py-2 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40 dark:bg-[#0F172A] dark:border-accent dark:text-accent"
|
||||
>
|
||||
Compartir
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGuardar}
|
||||
disabled={!puedeGenerarPdf}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:cursor-not-allowed disabled:opacity-40 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] dark:hover:bg-[#1E293B]"
|
||||
>
|
||||
Guardar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerarPdf}
|
||||
disabled={!puedeGenerarPdf}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{guardando ? 'Guardando…' : 'Generar PDF'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalCliente
|
||||
abierto={modalClienteAbierto}
|
||||
nombreInicial={watch('nombre')}
|
||||
|
||||
@@ -12,22 +12,82 @@ async function getDB(): Promise<IDBPDatabase> {
|
||||
})
|
||||
}
|
||||
|
||||
// Todas las operaciones son tolerantes a fallos: sin IndexedDB (navegación
|
||||
// privada, tests) no lanzan, para no romper el flujo que las usa.
|
||||
export async function guardarUnidades(unidades: unknown[]) {
|
||||
const db = await getDB()
|
||||
await db.put('unidades', unidades, 'actual')
|
||||
try {
|
||||
const db = await getDB()
|
||||
await db.put('unidades', unidades, 'actual')
|
||||
} catch {
|
||||
// Sin almacenamiento: el inventario se recarga del API en la próxima sesión
|
||||
}
|
||||
}
|
||||
|
||||
export async function cargarUnidadesLocal() {
|
||||
const db = await getDB()
|
||||
return db.get('unidades', 'actual')
|
||||
export async function cargarUnidadesLocal(): Promise<unknown> {
|
||||
try {
|
||||
const db = await getDB()
|
||||
return db.get('unidades', 'actual')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function guardarCotizaciones(cotizaciones: unknown[]) {
|
||||
const db = await getDB()
|
||||
await db.put('cotizaciones', cotizaciones, 'historial')
|
||||
try {
|
||||
const db = await getDB()
|
||||
await db.put('cotizaciones', cotizaciones, 'historial')
|
||||
} catch {
|
||||
// Sin almacenamiento
|
||||
}
|
||||
}
|
||||
|
||||
export async function cargarCotizacionesLocal() {
|
||||
const db = await getDB()
|
||||
return db.get('cotizaciones', 'historial')
|
||||
export async function cargarCotizacionesLocal(): Promise<unknown> {
|
||||
try {
|
||||
const db = await getDB()
|
||||
return db.get('cotizaciones', 'historial')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Cotizaciones guardadas localmente (fallback sin servidor) ----------
|
||||
|
||||
const COTIZACIONES_LS = 'frasa-cotizador:cotizaciones-locales'
|
||||
const MAX_LOCALES = 50
|
||||
|
||||
/**
|
||||
* Guarda una cotización local (id con prefijo 'local-') en IndexedDB.
|
||||
* Si IDB no está disponible (p. ej. navegación privada) cae a localStorage;
|
||||
* si no hay almacenamiento alguno, no lanza: la sesión sigue.
|
||||
*/
|
||||
export async function guardarCotizacionLocal(cotizacion: unknown): Promise<void> {
|
||||
try {
|
||||
const db = await getDB()
|
||||
await db.put('cotizaciones', cotizacion, `local:${(cotizacion as { id: string }).id}`)
|
||||
return
|
||||
} catch {
|
||||
// IndexedDB no disponible: intenta localStorage
|
||||
}
|
||||
try {
|
||||
const prev = JSON.parse(localStorage.getItem(COTIZACIONES_LS) ?? '[]') as unknown[]
|
||||
localStorage.setItem(COTIZACIONES_LS, JSON.stringify([cotizacion, ...prev].slice(0, MAX_LOCALES)))
|
||||
} catch {
|
||||
// Sin almacenamiento disponible
|
||||
}
|
||||
}
|
||||
|
||||
/** Lee las cotizaciones guardadas localmente (IndexedDB o localStorage) */
|
||||
export async function cargarCotizacionesLocales(): Promise<unknown[]> {
|
||||
try {
|
||||
const db = await getDB()
|
||||
const claves = (await db.getAllKeys('cotizaciones')).filter((k) => String(k).startsWith('local:'))
|
||||
return Promise.all(claves.map((k) => db.get('cotizaciones', k)))
|
||||
} catch {
|
||||
// IndexedDB no disponible: intenta localStorage
|
||||
}
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(COTIZACIONES_LS) ?? '[]') as unknown[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user