Dark mode fixes: - Cards secundarias: Mis capturas, Mis clientes, Actividades, Apartados por vencer, Equipo - Botones: Exportar, Descartar, Afluencia, Agendo, Forzar reconexión, Subir archivo - Config: ModoTrabajo, Usuarios, Sistema, RolesPermisos, ConfigPage - Cotizador: inputs, tabs, placeholder, botón compartir - Alta clientes: BotonOcrIne, FileUploader - Cobranza: toggle dashboard, modal abono - Dashboards: tabs Gráficas Recharts: - CSS variables para colores chart (grid, text, tooltip) - Helper isDark() + axisTextColor() + gridStroke() + tooltipProps() - DashboardVendedor: grid, axes, tooltips adaptados - TableroGeneral: grid, axes, tooltips (BarChart, Funnel, PieChart) - MiTablero: tooltip adaptado - Perfilamiento: grid, axes, tooltip 7 apps construidas limpias.
124 lines
4.9 KiB
TypeScript
124 lines
4.9 KiB
TypeScript
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 overflow-hidden rounded-lg border border-[#E5E7EB] bg-[#F8FAFC] dark:bg-[#0F172A]"
|
|
>
|
|
{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-semibold text-[#111827] dark:text-[#E2E8F0]">PDF</span>
|
|
<span className="max-w-full truncate px-1 text-[10px] text-[#64748B] dark:text-[#94A3B8]">{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 rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
|
<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-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{etiqueta}</p>
|
|
<p className="mt-1 truncate text-sm text-[#111827] dark:text-[#E2E8F0]">
|
|
{documento.nombre} · {formatoTamano(documento.tamano)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => onQuitar(slot)}
|
|
className="shrink-0 rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-50 dark:bg-[#0F172A] dark:border-red-800 dark:text-red-400"
|
|
>
|
|
Quitar
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
{...getRootProps()}
|
|
className={`cursor-pointer rounded-xl border border-dashed border-[#D1D5DB] bg-white px- dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] 4 py-6 text-center transition-colors duration-200 ${
|
|
isDragActive ? 'border-accent bg-accent/5' : 'hover:border-accent'
|
|
}`}
|
|
>
|
|
<input {...getInputProps()} aria-label={`Seleccionar archivo para ${etiqueta}`} />
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{etiqueta}</p>
|
|
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">Arrastra el archivo aquí · PDF/JPG/PNG · máx 10 MB</p>
|
|
<button
|
|
type="button"
|
|
className="mt-4 rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
|
>
|
|
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>
|
|
)
|
|
}
|