Files
FRASA-CAS/apps/alta-clientes/src/components/Campos.tsx

167 lines
5.9 KiB
TypeScript

import { useFormContext, get } from 'react-hook-form'
export interface CampoBaseProps {
nombre: string
etiqueta: string
requerido?: boolean
className?: string
}
const inputClase =
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm uppercase transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
function Etiqueta({ etiqueta, requerido }: { etiqueta: string; requerido?: boolean }) {
return (
<span className={labelClase}>
{etiqueta}
{requerido && <span className="text-red-700"> *</span>}
</span>
)
}
/** Clase del input según haya error de validación */
function claseInput(nombre: string, errors: unknown): string {
return get(errors, nombre) ? `${inputClase} border-red-400` : inputClase
}
export function MensajeError({ nombre }: { nombre: string }) {
const {
formState: { errors },
} = useFormContext()
const error = get(errors, nombre)
if (!error) return null
return <p className="mt-1 text-xs text-red-700">{String(error.message)}</p>
}
interface CampoTextoProps extends CampoBaseProps {
tipo?: string
placeholder?: string
soloLectura?: boolean
}
/** Input de texto conectado a react-hook-form (soporta rutas anidadas con punto) */
export function CampoTexto({ nombre, etiqueta, tipo = 'text', placeholder, requerido, soloLectura, className }: CampoTextoProps) {
const { register, formState: { errors } } = useFormContext()
return (
<label htmlFor={nombre} className={`block ${className ?? ''}`}>
<Etiqueta etiqueta={etiqueta} requerido={requerido} />
<input
id={nombre}
type={tipo}
placeholder={placeholder}
readOnly={soloLectura}
className={`${claseInput(nombre, errors)} ${soloLectura ? 'bg-[#F8FAFC]' : ''}`}
{...register(nombre)}
/>
<MensajeError nombre={nombre} />
</label>
)
}
interface CampoNumeroProps extends CampoBaseProps {
paso?: string
}
/** Input numérico; cadena vacía se guarda como 0 */
export function CampoNumero({ nombre, etiqueta, requerido, paso = '0.01', className }: CampoNumeroProps) {
const { register, formState: { errors } } = useFormContext()
return (
<label htmlFor={nombre} className={`block ${className ?? ''}`}>
<Etiqueta etiqueta={etiqueta} requerido={requerido} />
<input
id={nombre}
type="number"
step={paso}
min="0"
className={claseInput(nombre, errors)}
{...register(nombre, {
setValueAs: (v: string) => (v === '' ? 0 : Number(v)),
})}
/>
<MensajeError nombre={nombre} />
</label>
)
}
interface CampoSelectProps extends CampoBaseProps {
opciones: { valor: string; etiqueta: string }[]
}
/** CampoSelect (dropdown) conectado a react-hook-form */
export function CampoSelect({ nombre, etiqueta, opciones, requerido, className }: CampoSelectProps) {
const { register, formState: { errors } } = useFormContext()
return (
<label htmlFor={nombre} className={`block ${className ?? ''}`}>
<Etiqueta etiqueta={etiqueta} requerido={requerido} />
<select id={nombre} className={claseInput(nombre, errors)} {...register(nombre)}>
<option value="">— Selecciona —</option>
{opciones.map((o) => (
<option key={o.valor} value={o.valor}>
{o.etiqueta}
</option>
))}
</select>
<MensajeError nombre={nombre} />
</label>
)
}
/**
* Teléfono con selector de lada (México +52 / USA +1). El campo del formulario
* guarda el valor compuesto "<prefijo> <número>" (p. ej. "+52 6641234567");
* el dedup del API compara por los últimos 10 dígitos, así que no afecta.
*/
export function CampoTelefono({ nombre, etiqueta, requerido, className }: CampoBaseProps) {
const { watch, setValue, formState: { errors } } = useFormContext()
const valor = ((watch(nombre) as string) ?? '').trim()
const lada = valor.startsWith('+1') ? '+1' : '+52'
// Normaliza lo que se muestra: dígitos, últimos 10 (limpia ladas duplicadas
// o caracteres invisibles heredados de registros viejos)
const numero = (() => {
const crudo = valor.startsWith('+1') ? valor.slice(2).trim() : valor.startsWith('+52') ? valor.slice(3).trim() : valor
const d = crudo.replace(/\D/g, '')
return d.length > 10 ? d.slice(-10) : d
})()
const actualizar = (nuevaLada: string, nuevoNumero: string) => {
// Sólo dígitos y máximo 10 (la lada va en el selector)
const limpio = nuevoNumero.replace(/\D/g, '').slice(0, 10)
// Vacío se guarda vacío (sin prefijo huérfano); con número, compuesto
setValue(nombre, limpio === '' ? '' : `${nuevaLada} ${limpio}`, { shouldValidate: true })
}
return (
<div className={className ?? ''}>
<label htmlFor={nombre}>
<Etiqueta etiqueta={etiqueta} requerido={requerido} />
</label>
<div className="flex gap-2">
<select
id={`${nombre}-lada`}
aria-label="Lada"
value={lada}
onChange={(e) => actualizar(e.target.value, numero)}
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
>
<option value="+52">🇲🇽 +52</option>
<option value="+1">🇺🇸 +1</option>
</select>
<input
id={nombre}
type="tel"
inputMode="numeric"
maxLength={10}
placeholder="6641234567"
value={numero}
onChange={(e) => actualizar(lada, e.target.value)}
className={claseInput(nombre, errors)}
/>
</div>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">10 dígitos, sin lada</p>
<MensajeError nombre={nombre} />
</div>
)
}