From f7e858a0b264a730537dedd9823b73bf28ae2e68 Mon Sep 17 00:00:00 2001 From: david Date: Sat, 20 Sep 2025 13:25:07 +0200 Subject: [PATCH] Clientes y facturas de cliente --- modules/customers/src/common/locales/en.json | 2 +- modules/customers/src/common/locales/es.json | 4 +- .../pages/update/customer-contact-fields.tsx | 2 + .../rdx-ui/src/components/form/TextField.tsx | 429 ++++++++++++++++-- packages/rdx-ui/src/locales/en.json | 3 +- packages/rdx-ui/src/locales/es.json | 3 +- 6 files changed, 412 insertions(+), 31 deletions(-) diff --git a/modules/customers/src/common/locales/en.json b/modules/customers/src/common/locales/en.json index bc8b0426..56497247 100644 --- a/modules/customers/src/common/locales/en.json +++ b/modules/customers/src/common/locales/en.json @@ -2,7 +2,7 @@ "common": { "more_details": "More details", "back_to_list": "Back to the list", - "save": "Guardar" + "save": "Save" }, "pages": { "title": "Customers", diff --git a/modules/customers/src/common/locales/es.json b/modules/customers/src/common/locales/es.json index edf4ddc1..987d397f 100644 --- a/modules/customers/src/common/locales/es.json +++ b/modules/customers/src/common/locales/es.json @@ -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", diff --git a/modules/customers/src/web/pages/update/customer-contact-fields.tsx b/modules/customers/src/web/pages/update/customer-contact-fields.tsx index 02f3f531..b8263013 100644 --- a/modules/customers/src/web/pages/update/customer-contact-fields.tsx +++ b/modules/customers/src/web/pages/update/customer-contact-fields.tsx @@ -36,6 +36,8 @@ export function CustomerContactFields({ control }: { control: any }) { icon={ } + typePreset='email' + required /> = { +/** + * + * // Email con normalización y contador + * } + * iconPosition="left" + * maxLength={120} + * showCounter + * clearable + * /> + * + * // Teléfono con normalización (mantiene + y dígitos), prefix clicable + * +34} + * onPrefixClick={() => { + * // Alternar prefijo, o abrir selector de país... + * }} + * clearable + * /> + * + * // Número decimal con normalización (',' → '.'), suffix clicable para unidad + * EUR} + * onSuffixClick={() => { + * // Cambiar moneda, etc. + * }} + * inputMode="decimal" // si quieres sobreescribir + * icon={} + * iconPosition="left" + * /> + * + * // Password (sin normalizaciones) + * + * + */ + +/** Presets de comportamiento */ +type TextFieldTypePreset = "text" | "email" | "phone" | "number" | "password"; + +type CommonInputProps = Omit< + React.InputHTMLAttributes, + "name" | "value" | "onChange" | "onBlur" | "ref" | "type" +>; + +type Normalizer = (value: string) => string; + +type TextFieldProps = CommonInputProps & { control: Control; name: FieldPath; 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: 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["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["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({ 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) { 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) { + const raw = e.target.value; + const next = effectiveNormalizeOnChange ? effectiveNormalizeOnChange(raw) : raw; + field.onChange(next); + } + + function handleBlur(e: React.FocusEvent) { + if (effectiveTransformOnBlur) { + const next = effectiveTransformOnBlur(e.target.value ?? ""); + if (next !== e.target.value) { + field.onChange(next); + } + } + field.onBlur(); + } + + function handleKeyDown(e: React.KeyboardEvent) { + 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 ( ({ render={({ field }) => ( {label && ( -
- {label} - {required && {t("common.required")}} +
+
+ + {label} + + {required && ( + {t("common.required")} + )} +
+ {/* Punto “unsaved” */} + {fieldState.isDirty && ( + {t("common.modified")} + )}
)}
- + {/* Prefix clicable (si tiene onClick) */} + {hasPrefix && ( + + )} + {/* Icono decorativo */} {hasIcon && ( )} + + + + {/* Suffix clicable */} + {hasSuffix && ( + + )} + + {/* Spinner de validación */} + {showValidatingSpinner && isValidating && ( + + + + )} + + {/* Check de válido */} + {showSuccessWhenValid && valid && !isValidating && !invalid && ( + + + + )} + + {/* Botón clear */} + {clearable && !disabled && (field.value ?? "") !== "" && ( + + )}
-

- {description || "\u00A0"} -

- +
+

+ {description || "\u00A0"} +

+ + {showCounter && typeof maxLength === "number" && ( +

+ {valueLength} / {maxLength} +

+ )} +
+ + )} /> diff --git a/packages/rdx-ui/src/locales/en.json b/packages/rdx-ui/src/locales/en.json index 71953281..369f0711 100644 --- a/packages/rdx-ui/src/locales/en.json +++ b/packages/rdx-ui/src/locales/en.json @@ -2,7 +2,8 @@ "common": { "actions": "Actions", "invalid_date": "Invalid date", - "required": "required", + "required": "•", + "modified": "modified", "search": "Search" }, "components": { diff --git a/packages/rdx-ui/src/locales/es.json b/packages/rdx-ui/src/locales/es.json index 3e3b687b..c7a02b5d 100644 --- a/packages/rdx-ui/src/locales/es.json +++ b/packages/rdx-ui/src/locales/es.json @@ -2,7 +2,8 @@ "common": { "actions": "Actions", "invalid_date": "Fecha incorrecta o no válida", - "required": "obligatorio", + "required": "•", + "modified": "modificado", "search": "Buscar" }, "components": {