Clientes y facturas de cliente
This commit is contained in:
parent
60a6c908e9
commit
f7e858a0b2
@ -2,7 +2,7 @@
|
||||
"common": {
|
||||
"more_details": "More details",
|
||||
"back_to_list": "Back to the list",
|
||||
"save": "Guardar"
|
||||
"save": "Save"
|
||||
},
|
||||
"pages": {
|
||||
"title": "Customers",
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"more_details": "Más detalles"
|
||||
"more_details": "Más detalles",
|
||||
"back_to_list": "Back to the list",
|
||||
"save": "Guardar"
|
||||
},
|
||||
"pages": {
|
||||
"title": "Clientes",
|
||||
|
||||
@ -36,6 +36,8 @@ export function CustomerContactFields({ control }: { control: any }) {
|
||||
icon={
|
||||
<MailIcon className='h-[18px] w-[18px] text-muted-foreground' strokeWidth={1.5} />
|
||||
}
|
||||
typePreset='email'
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
className='lg:col-span-2'
|
||||
|
||||
@ -8,46 +8,317 @@ import {
|
||||
} from "@repo/shadcn-ui/components";
|
||||
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||
import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
|
||||
import { Control, FieldPath, FieldValues, useController, useFormState } from "react-hook-form";
|
||||
import { useTranslation } from "../../locales/i18n.ts";
|
||||
|
||||
type TextFieldProps<TFormValues extends FieldValues> = {
|
||||
/**
|
||||
*
|
||||
* // Email con normalización y contador
|
||||
* <TextField
|
||||
* name="email"
|
||||
* label="Email"
|
||||
* description="Usa tu email de trabajo"
|
||||
* typePreset="email"
|
||||
* placeholder="tú@empresa.com"
|
||||
* icon={<Mail className="h-4 w-4" />}
|
||||
* iconPosition="left"
|
||||
* maxLength={120}
|
||||
* showCounter
|
||||
* clearable
|
||||
* />
|
||||
*
|
||||
* // Teléfono con normalización (mantiene + y dígitos), prefix clicable
|
||||
* <TextField
|
||||
* name="mobile"
|
||||
* label="Móvil"
|
||||
* description="Incluye prefijo internacional"
|
||||
* typePreset="phone"
|
||||
* placeholder="+34 600 000 000"
|
||||
* prefix={<span className="text-xs">+34</span>}
|
||||
* onPrefixClick={() => {
|
||||
* // Alternar prefijo, o abrir selector de país...
|
||||
* }}
|
||||
* clearable
|
||||
* />
|
||||
*
|
||||
* // Número decimal con normalización (',' → '.'), suffix clicable para unidad
|
||||
* <TextField
|
||||
* name="price"
|
||||
* label="Precio"
|
||||
* description="Con IVA"
|
||||
* typePreset="number"
|
||||
* placeholder="0.00"
|
||||
* suffix={<span className="text-xs">EUR</span>}
|
||||
* onSuffixClick={() => {
|
||||
* // Cambiar moneda, etc.
|
||||
* }}
|
||||
* inputMode="decimal" // si quieres sobreescribir
|
||||
* icon={<Hash className="h-4 w-4" />}
|
||||
* iconPosition="left"
|
||||
* />
|
||||
*
|
||||
* // Password (sin normalizaciones)
|
||||
* <TextField
|
||||
* name="password"
|
||||
* label="Contraseña"
|
||||
* typePreset="password"
|
||||
* placeholder="••••••••"
|
||||
* autoComplete="new-password"
|
||||
* />
|
||||
*
|
||||
*/
|
||||
|
||||
/** Presets de comportamiento */
|
||||
type TextFieldTypePreset = "text" | "email" | "phone" | "number" | "password";
|
||||
|
||||
type CommonInputProps = Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"name" | "value" | "onChange" | "onBlur" | "ref" | "type"
|
||||
>;
|
||||
|
||||
type Normalizer = (value: string) => string;
|
||||
|
||||
type TextFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
|
||||
control: Control<TFormValues>;
|
||||
name: FieldPath<TFormValues>;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
|
||||
typePreset?: TextFieldTypePreset;
|
||||
|
||||
icon?: React.ReactNode; // Icono con tamaño: <MailIcon className="h-[18px] w-[18px]" />
|
||||
iconPosition?: "left" | "right"; // 'left' por defecto
|
||||
|
||||
/** Addons laterales (pueden ser clicables, a diferencia de `icon`) */
|
||||
prefix?: React.ReactNode;
|
||||
suffix?: React.ReactNode;
|
||||
onPrefixClick?: () => void;
|
||||
onSuffixClick?: () => void;
|
||||
|
||||
/** UX extra */
|
||||
clearable?: boolean;
|
||||
submitOnEnter?: boolean;
|
||||
disabledWhileSubmitting?: boolean;
|
||||
showSuccessWhenValid?: boolean;
|
||||
showValidatingSpinner?: boolean;
|
||||
|
||||
/** Transformaciones */
|
||||
transformOnBlur?: (value: string) => string;
|
||||
normalizeOnChange?: (value: string) => string;
|
||||
|
||||
/** Contador de caracteres (si usas maxLength) */
|
||||
showCounter?: boolean;
|
||||
|
||||
/** Forzar type/inputMode/autocomplete si no quieres los del preset */
|
||||
forceType?: React.HTMLInputTypeAttribute;
|
||||
forceInputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
|
||||
forceAutoComplete?: string;
|
||||
};
|
||||
|
||||
/* ---------- Helpers de presets ---------- */
|
||||
|
||||
function presetInputType(p: TextFieldTypePreset): React.HTMLInputTypeAttribute {
|
||||
switch (p) {
|
||||
case "password":
|
||||
return "password";
|
||||
case "number":
|
||||
return "text"; // usamos text + normalización para control fino (decimales, signos)
|
||||
case "email":
|
||||
return "email";
|
||||
case "phone":
|
||||
return "tel";
|
||||
case "text":
|
||||
return "text";
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
|
||||
function presetInputMode(
|
||||
p: TextFieldTypePreset
|
||||
): React.HTMLAttributes<HTMLInputElement>["inputMode"] {
|
||||
switch (p) {
|
||||
case "phone":
|
||||
return "tel";
|
||||
case "number":
|
||||
return "decimal";
|
||||
case "email":
|
||||
return "email";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function presetAutoComplete(p: TextFieldTypePreset): string | undefined {
|
||||
switch (p) {
|
||||
case "email":
|
||||
return "email";
|
||||
case "phone":
|
||||
return "tel";
|
||||
case "password":
|
||||
return "current-password";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function presetTransformOnBlur(p: TextFieldTypePreset): ((v: string) => string) | undefined {
|
||||
switch (p) {
|
||||
case "email":
|
||||
return (v) => v.trim().toLowerCase();
|
||||
case "text":
|
||||
return undefined;
|
||||
case "password":
|
||||
return undefined;
|
||||
case "phone":
|
||||
return undefined;
|
||||
case "number":
|
||||
return undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalizador “suave” para números: permite signo inicial y un solo separador decimal '.' */
|
||||
function normalizeNumber(value: string): string {
|
||||
// Sustituye comas por punto y elimina caracteres inválidos
|
||||
let v = value.replace(/,/g, ".");
|
||||
// Mantén solo dígitos, un punto y signo inicial
|
||||
v = v
|
||||
.replace(/[^\d.+-]/g, "")
|
||||
.replace(/(?!^)-/g, "") // solo un signo al inicio
|
||||
.replace(/(\..*)\./g, "$1"); // solo un punto
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Normalizador para teléfonos: mantiene dígitos y '+' inicial */
|
||||
function normalizePhone(value: string): string {
|
||||
let v = value.replace(/[^\d+]/g, "");
|
||||
v = v.replace(/(?!^)\+/g, ""); // '+' solo al inicio
|
||||
return v;
|
||||
}
|
||||
|
||||
function presetNormalizeOnChange(p: TextFieldTypePreset): Normalizer | undefined {
|
||||
switch (p) {
|
||||
case "phone":
|
||||
return normalizePhone;
|
||||
case "number":
|
||||
return normalizeNumber;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Componente ---------- */
|
||||
|
||||
export function TextField<TFormValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
placeholder,
|
||||
description,
|
||||
disabled = false,
|
||||
required = false,
|
||||
readOnly = false,
|
||||
required,
|
||||
readOnly,
|
||||
className,
|
||||
inputClassName,
|
||||
typePreset = "text",
|
||||
|
||||
icon,
|
||||
iconPosition = "left",
|
||||
|
||||
prefix,
|
||||
suffix,
|
||||
onPrefixClick,
|
||||
onSuffixClick,
|
||||
|
||||
clearable = false,
|
||||
submitOnEnter = false,
|
||||
disabledWhileSubmitting = true,
|
||||
showSuccessWhenValid = false,
|
||||
showValidatingSpinner = true,
|
||||
|
||||
transformOnBlur,
|
||||
normalizeOnChange,
|
||||
|
||||
showCounter = false,
|
||||
|
||||
forceType,
|
||||
forceInputMode,
|
||||
forceAutoComplete,
|
||||
|
||||
maxLength,
|
||||
...rest
|
||||
}: TextFieldProps<TFormValues>) {
|
||||
const { t } = useTranslation();
|
||||
const isDisabled = disabled || readOnly;
|
||||
|
||||
const { isSubmitting, isValidating } = useFormState({ control, name });
|
||||
const { field, fieldState } = useController({ control, name });
|
||||
|
||||
// Presets → defaults (permiten override por props explícitas)
|
||||
const effectiveType = forceType ?? presetInputType(typePreset);
|
||||
const effectiveInputMode = forceInputMode ?? presetInputMode(typePreset);
|
||||
const effectiveAutoComplete = forceAutoComplete ?? presetAutoComplete(typePreset);
|
||||
const effectiveTransformOnBlur = transformOnBlur ?? presetTransformOnBlur(typePreset);
|
||||
const effectiveNormalizeOnChange = normalizeOnChange ?? presetNormalizeOnChange(typePreset);
|
||||
|
||||
const hasIcon = Boolean(icon);
|
||||
const isLeft = iconPosition === "left";
|
||||
const inputPadding = hasIcon ? (isLeft ? "pl-10" : "pr-10") : "";
|
||||
const isLeftIcon = iconPosition === "left";
|
||||
const hasPrefix = prefix != null;
|
||||
const hasSuffix = suffix != null;
|
||||
|
||||
const { getFieldState } = control;
|
||||
const state = getFieldState(name);
|
||||
// padding a partir de adornos
|
||||
const inputPadding = cn(
|
||||
hasIcon && isLeftIcon && "pl-10",
|
||||
hasIcon && !isLeftIcon && "pr-10",
|
||||
hasPrefix && "pl-10",
|
||||
hasSuffix && "pr-10"
|
||||
);
|
||||
|
||||
const invalid = fieldState.invalid && (fieldState.isTouched || fieldState.isDirty);
|
||||
const valid =
|
||||
!fieldState.invalid &&
|
||||
(fieldState.isTouched || fieldState.isDirty) &&
|
||||
field.value != null &&
|
||||
String(field.value).length > 0;
|
||||
|
||||
const disabled = (disabledWhileSubmitting && isSubmitting) || rest.disabled || readOnly;
|
||||
|
||||
const describedById = description ? `${name}-desc` : undefined;
|
||||
const errorId = fieldState.error ? `${name}-err` : undefined;
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const raw = e.target.value;
|
||||
const next = effectiveNormalizeOnChange ? effectiveNormalizeOnChange(raw) : raw;
|
||||
field.onChange(next);
|
||||
}
|
||||
|
||||
function handleBlur(e: React.FocusEvent<HTMLInputElement>) {
|
||||
if (effectiveTransformOnBlur) {
|
||||
const next = effectiveTransformOnBlur(e.target.value ?? "");
|
||||
if (next !== e.target.value) {
|
||||
field.onChange(next);
|
||||
}
|
||||
}
|
||||
field.onBlur();
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (submitOnEnter && e.key === "Enter") {
|
||||
const form = (e.currentTarget as HTMLInputElement).form;
|
||||
if (form) form.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
field.onChange("");
|
||||
}
|
||||
|
||||
const valueLength = (field.value?.length ?? 0) as number;
|
||||
|
||||
return (
|
||||
<FormField
|
||||
@ -56,38 +327,142 @@ export function TextField<TFormValues extends FieldValues>({
|
||||
render={({ field }) => (
|
||||
<FormItem className={cn("space-y-0", className)}>
|
||||
{label && (
|
||||
<div className='flex justify-between items-center'>
|
||||
<FormLabel className='m-0'>{label}</FormLabel>
|
||||
{required && <span className='text-xs text-destructive'>{t("common.required")}</span>}
|
||||
<div className='mb-1 flex justify-between gap-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<FormLabel htmlFor={name} className='m-0'>
|
||||
{label}
|
||||
</FormLabel>
|
||||
{required && (
|
||||
<span className='text-xs text-destructive'>{t("common.required")}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Punto “unsaved” */}
|
||||
{fieldState.isDirty && (
|
||||
<span className='text-[10px] text-muted-foreground'>{t("common.modified")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<FormControl>
|
||||
<div className={cn("relative")}>
|
||||
<Input
|
||||
disabled={isDisabled}
|
||||
placeholder={placeholder}
|
||||
{...field}
|
||||
className={cn("placeholder:font-normal placeholder:italic", inputPadding)}
|
||||
/>
|
||||
{/* Prefix clicable (si tiene onClick) */}
|
||||
{hasPrefix && (
|
||||
<button
|
||||
type={onPrefixClick ? "button" : undefined}
|
||||
onClick={onPrefixClick}
|
||||
tabIndex={onPrefixClick ? 0 : -1}
|
||||
className={cn(
|
||||
"absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground",
|
||||
!onPrefixClick && "pointer-events-none"
|
||||
)}
|
||||
aria-label='prefix'
|
||||
>
|
||||
{prefix}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Icono decorativo */}
|
||||
{hasIcon && (
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-1/2 -translate-y-1/2",
|
||||
isLeft ? "left-3" : "right-3"
|
||||
isLeftIcon ? "left-3" : "right-3"
|
||||
)}
|
||||
>
|
||||
{icon} {/* El tamaño viene indicado en el icono */}
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Input
|
||||
id={name}
|
||||
type={effectiveType}
|
||||
inputMode={effectiveInputMode}
|
||||
autoComplete={effectiveAutoComplete}
|
||||
placeholder={rest.placeholder}
|
||||
value={field.value ?? ""}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={field.ref}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={cn(describedById, errorId)}
|
||||
aria-errormessage={errorId}
|
||||
aria-busy={(showValidatingSpinner && isValidating) || undefined}
|
||||
maxLength={maxLength}
|
||||
{...rest}
|
||||
className={cn(
|
||||
"placeholder:font-normal placeholder:italic",
|
||||
inputPadding,
|
||||
invalid && "border-destructive focus-visible:ring-destructive",
|
||||
valid && showSuccessWhenValid && "border-green-500 focus-visible:ring-green-500",
|
||||
// Si hay suffix interactivo y spinner/check, reserva padding derecho
|
||||
(hasSuffix || showValidatingSpinner || valid) && "pr-10",
|
||||
inputClassName
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Suffix clicable */}
|
||||
{hasSuffix && (
|
||||
<button
|
||||
type={onSuffixClick ? "button" : undefined}
|
||||
onClick={onSuffixClick}
|
||||
tabIndex={onSuffixClick ? 0 : -1}
|
||||
className={cn(
|
||||
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground",
|
||||
!onSuffixClick && "pointer-events-none"
|
||||
)}
|
||||
aria-label='suffix'
|
||||
>
|
||||
{suffix}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Spinner de validación */}
|
||||
{showValidatingSpinner && isValidating && (
|
||||
<span className='absolute right-2 top-1/2 -translate-y-1/2'>
|
||||
<Loader2Icon className='h-4 w-4 animate-spin' />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Check de válido */}
|
||||
{showSuccessWhenValid && valid && !isValidating && !invalid && (
|
||||
<span className='absolute right-2 top-1/2 -translate-y-1/2 text-green-600'>
|
||||
<CheckIcon className='h-4 w-4' />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Botón clear */}
|
||||
{clearable && !disabled && (field.value ?? "") !== "" && (
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Borrar'
|
||||
onClick={handleClear}
|
||||
className='absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 hover:bg-muted'
|
||||
>
|
||||
<XIcon className='h-4 w-4' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<p className={cn("text-xs text-muted-foreground", !description && "invisible")}>
|
||||
{description || "\u00A0"}
|
||||
</p>
|
||||
<FormMessage />
|
||||
<div className='mt-1 flex items-start justify-between'>
|
||||
<p
|
||||
id={describedById}
|
||||
className={cn("text-xs text-muted-foreground", !description && "invisible")}
|
||||
>
|
||||
{description || "\u00A0"}
|
||||
</p>
|
||||
|
||||
{showCounter && typeof maxLength === "number" && (
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{valueLength} / {maxLength}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormMessage id={errorId} />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
"common": {
|
||||
"actions": "Actions",
|
||||
"invalid_date": "Invalid date",
|
||||
"required": "required",
|
||||
"required": "•",
|
||||
"modified": "modified",
|
||||
"search": "Search"
|
||||
},
|
||||
"components": {
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
"common": {
|
||||
"actions": "Actions",
|
||||
"invalid_date": "Fecha incorrecta o no válida",
|
||||
"required": "obligatorio",
|
||||
"required": "•",
|
||||
"modified": "modificado",
|
||||
"search": "Buscar"
|
||||
},
|
||||
"components": {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user