feat: ecosistema FRASA — apps Cotizador, Alta Clientes, Cobranza y Landing
- Cotizador (/quote): 4 propuestas con formulas validadas del Excel, comparador, combobox de unidades, vigencia, PDF ANEXO A - Alta Clientes (/client): wizard 8 pasos, validacion CURP RENAPO, OCR de INE, checklist de expediente, firma digital, 5 PDFs legales - Cobranza (/pay): planes de enganche, mora, vista cobranza del dia, recibos PDF, bitacora, dashboard con filtros - Landing (/): entrada al ecosistema - Design system StudioBlank (monocromo, acento #1B4332) en las 4 apps - 123 tests vitest
This commit is contained in:
123
apps/alta-clientes/src/components/FileUploader.tsx
Normal file
123
apps/alta-clientes/src/components/FileUploader.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import type { DocumentoCargado, SlotDocumento } from '../types'
|
||||
import { TAMANO_MAX_DOCUMENTO } from '../data/constantes'
|
||||
|
||||
interface Props {
|
||||
slot: SlotDocumento
|
||||
etiqueta: string
|
||||
documento?: DocumentoCargado
|
||||
onAgregar: (doc: DocumentoCargado) => void
|
||||
onQuitar: (slot: SlotDocumento) => void
|
||||
}
|
||||
|
||||
function formatoTamano(bytes: number): string {
|
||||
return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`
|
||||
}
|
||||
|
||||
/** Miniatura del documento: imagen con object-cover o recuadro plano para PDF */
|
||||
function Miniatura({ documento, url }: { documento: DocumentoCargado; url: string }) {
|
||||
const esImagen = documento.tipoMime.startsWith('image/')
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={`Abrir ${documento.nombre} en una pestaña nueva`}
|
||||
aria-label={`Abrir ${documento.nombre} en una pestaña nueva`}
|
||||
className="block h-20 w-20 shrink-0 border border-neutral-200 bg-neutral-50"
|
||||
>
|
||||
{esImagen ? (
|
||||
<img src={url} alt={`Vista previa de ${documento.nombre}`} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="flex h-full w-full flex-col items-center justify-center gap-1">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-neutral-900">PDF</span>
|
||||
<span className="max-w-full truncate px-1 text-[10px] font-light text-neutral-500">{documento.nombre}</span>
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/** Slot de carga de un documento (PDF/JPG/PNG, máx 10 MB) con vista previa */
|
||||
export default function FileUploader({ slot, etiqueta, documento, onAgregar, onQuitar }: Props) {
|
||||
const [urlObjeto, setUrlObjeto] = useState('')
|
||||
|
||||
// URL de objeto para la miniatura y para abrir en pestaña nueva; se revoca al quitar/desmontar
|
||||
useEffect(() => {
|
||||
if (!documento) {
|
||||
setUrlObjeto('')
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(documento.archivo)
|
||||
setUrlObjeto(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [documento])
|
||||
|
||||
const onDrop = useCallback(
|
||||
(aceptados: File[]) => {
|
||||
const archivo = aceptados[0]
|
||||
if (!archivo) return
|
||||
onAgregar({ slot, nombre: archivo.name, tamano: archivo.size, tipoMime: archivo.type, archivo })
|
||||
},
|
||||
[slot, onAgregar],
|
||||
)
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
|
||||
onDrop,
|
||||
multiple: false,
|
||||
maxSize: TAMANO_MAX_DOCUMENTO,
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
'image/jpeg': ['.jpg', '.jpeg'],
|
||||
'image/png': ['.png'],
|
||||
},
|
||||
})
|
||||
|
||||
if (documento) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 border border-neutral-200 bg-white px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
{urlObjeto && <Miniatura documento={documento} url={urlObjeto} />}
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">{etiqueta}</p>
|
||||
<p className="mt-1 truncate text-sm text-neutral-900">
|
||||
{documento.nombre} · {formatoTamano(documento.tamano)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onQuitar(slot)}
|
||||
className="shrink-0 border border-red-300 bg-white px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-red-700 transition-colors duration-150 hover:bg-red-50"
|
||||
>
|
||||
Quitar
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`cursor-pointer border border-dashed border-neutral-300 bg-white px-4 py-6 text-center transition-colors duration-150 ${
|
||||
isDragActive ? 'border-neutral-900 bg-neutral-50' : 'hover:border-neutral-500'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} aria-label={`Seleccionar archivo para ${etiqueta}`} />
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">{etiqueta}</p>
|
||||
<p className="mt-2 text-xs font-light text-neutral-500">Arrastra el archivo aquí · PDF/JPG/PNG · máx 10 MB</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||
>
|
||||
Seleccionar archivo
|
||||
</button>
|
||||
</div>
|
||||
{fileRejections.length > 0 && (
|
||||
<p className="mt-1 text-xs text-red-700">Archivo rechazado: formato no permitido o mayor a 10 MB</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user