This commit is contained in:
David Arranz 2026-03-18 17:38:40 +01:00
parent c813081ce1
commit 9f870bbd76
31 changed files with 2063 additions and 418 deletions

View File

@ -1,12 +1,10 @@
{ {
"recommendations": [ "recommendations": [
"mfeckies.handlebars-formatter",
"bradlc.vscode-tailwindcss", "bradlc.vscode-tailwindcss",
"biomejs.biome", "biomejs.biome",
"cweijan.vscode-mysql-client2", "cweijan.vscode-mysql-client2",
"ms-vscode.vscode-json", "ms-vscode.vscode-json",
"formulahendry.auto-rename-tag", "formulahendry.auto-rename-tag",
"cweijan.dbclient-jdbc", "cweijan.dbclient-jdbc"
"nabous.handlebars-preview-plus"
] ]
} }

View File

@ -44,9 +44,8 @@
"axios": "^1.9.0", "axios": "^1.9.0",
"dinero.js": "^1.9.1", "dinero.js": "^1.9.1",
"express": "^4.18.2", "express": "^4.18.2",
"handlebars": "^4.7.8",
"http-status": "^2.1.0", "http-status": "^2.1.0",
"lucide-react": "^0.503.0", "lucide-react": "^0.577.0",
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"react-hook-form": "^7.58.1", "react-hook-form": "^7.58.1",
"react-i18next": "^15.5.1", "react-i18next": "^15.5.1",

View File

@ -1,61 +0,0 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { IRendererTemplateResolver } from "../../../application";
import { FastReportTemplateNotFoundError } from "./fastreport";
/**
* Resuelve rutas de plantillas para desarrollo y producción.
*/
export abstract class RendererTemplateResolver implements IRendererTemplateResolver {
constructor(protected readonly rootPath: string) {}
/** Une partes de ruta relativas al rootPath */
protected resolveJoin(parts: string[]): string {
return join(this.rootPath, ...parts);
}
/**
* Devuelve el directorio donde residen las plantillas de un módulo/empresa
* según el entorno (dev/prod).
*/
protected resolveTemplateDirectory(module: string, companySlug: string): string {
const isDev = process.env.NODE_ENV === "development";
if (isDev) {
// <root>/<module>/templates/<companySlug>/
return this.resolveJoin([module, "templates", companySlug]);
}
// <root>/templates/<module>/<companySlug>/
//return this.resolveJoin(["templates", module, companySlug]);
return this.resolveJoin([module]);
}
/** Resuelve una ruta de recurso relativa al directorio de plantilla */
protected resolveAssetPath(templateDir: string, relative: string): string {
return join(templateDir, relative);
}
/**
* Devuelve la ruta absoluta del fichero de plantilla.
*/
public resolveTemplatePath(module: string, companySlug: string, templateName: string): string {
const dir = this.resolveTemplateDirectory(module, companySlug);
const filePath = this.resolveAssetPath(dir, templateName);
if (!existsSync(filePath)) {
throw new FastReportTemplateNotFoundError(
`Template not found: module=${module} company=${companySlug} name=${templateName}`
);
}
return filePath;
}
/** Lee el contenido de un fichero plantilla */
protected readTemplateFile(templatePath: string): string {
return readFileSync(templatePath, "utf8");
}
}

View File

@ -1,9 +1,44 @@
import { DevTool } from '@hookform/devtools'; import { Suspense, lazy, useState } from "react";
import { useState } from "react";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
const HookFormDevTool = lazy(async () => {
const mod = await import("@hookform/devtools");
return { default: mod.DevTool };
});
type FormDebugProps = {
enabled?: boolean;
placement?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
};
export function FormDebug({ enabled = false, placement = "top-right" }: FormDebugProps) {
return enabled ? <FormDebugInner placement={placement} /> : null;
}
function FormDebugInner({
placement,
}: {
placement: "top-left" | "top-right" | "bottom-left" | "bottom-right";
}) {
const { control } = useFormContext();
return (
<Suspense fallback={null}>
<HookFormDevTool control={control} placement={placement} />
</Suspense>
);
}
// Renderiza una propiedad recursivamente con expansión // Renderiza una propiedad recursivamente con expansión
function DebugField({ label, oldValue, newValue }: { label?: string; oldValue: any; newValue: any }) { function DebugField({
label,
oldValue,
newValue,
}: {
label?: string;
oldValue: any;
newValue: any;
}) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const isObject = typeof newValue === "object" && newValue !== null; const isObject = typeof newValue === "object" && newValue !== null;
@ -12,8 +47,8 @@ function DebugField({ label, oldValue, newValue }: { label?: string; oldValue: a
return ( return (
<li className="ml-4"> <li className="ml-4">
{label && <span className="font-medium">{label}: </span>} {label && <span className="font-medium">{label}: </span>}
<span className="text-gray-500 line-through">{String(oldValue)}</span>{" "} <span className="text-gray-500 line-through">{String(oldValue)}</span> {" "}
<span className="text-green-600">{String(newValue)}</span> <span className="text-green-600">{String(newValue)}</span>
</li> </li>
); );
} }
@ -21,58 +56,19 @@ function DebugField({ label, oldValue, newValue }: { label?: string; oldValue: a
return ( return (
<li className="ml-4"> <li className="ml-4">
<button <button
type="button"
onClick={() => setOpen((v) => !v)}
className="text-left font-medium text-blue-600 hover:underline focus:outline-none" className="text-left font-medium text-blue-600 hover:underline focus:outline-none"
onClick={() => setOpen((v) => !v)}
type="button"
> >
{open ? "▼" : "▶"} {label} {open ? "▼" : "▶"} {label}
</button> </button>
{open && ( {open && (
<ul className="ml-4 border-l pl-2 mt-1 space-y-1"> <ul className="ml-4 border-l pl-2 mt-1 space-y-1">
{Object.keys(newValue).map((key) => ( {Object.keys(newValue).map((key) => (
<DebugField <DebugField key={key} label={key} newValue={newValue[key]} oldValue={oldValue?.[key]} />
key={key}
label={key}
oldValue={oldValue?.[key]}
newValue={newValue[key]}
/>
))} ))}
</ul> </ul>
)} )}
</li> </li>
); );
} }
export const FormDebug = () => {
const { control } = useFormContext();
//const { watch, formState } = useFormContext();
//const { isDirty, dirtyFields, defaultValues } = formState;
//const currentValues = watch();
return <DevTool control={control} placement="top-right" />
/*return (
<div className="absolute right-4 bottom-4 z-50 p-4 border rounded bg-red-50">
<p>
<strong>¿Formulario modificado?</strong> {isDirty ? "Sí" : "No"}
</p>
<div className="mt-2">
<strong>Campos modificados:</strong>
{Object.keys(dirtyFields).length > 0 ? (
<ul className="list-disc list-inside mt-1">
{Object.keys(dirtyFields).map((key) => (
<DebugField
key={key}
label={key}
oldValue={defaultValues?.[key]}
newValue={currentValues[key]}
/>
))}
</ul>
) : (
<p>Ninguno</p>
)}
</div>
</div>
);*/
};

View File

@ -1,7 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { FieldValues, UseFormProps, UseFormReturn, useForm } from "react-hook-form"; import { type FieldValues, type UseFormProps, type UseFormReturn, useForm } from "react-hook-form";
import * as z4 from "zod/v4/core"; import type * as z4 from "zod/v4/core";
type UseHookFormProps<TFields extends FieldValues = FieldValues, TContext = any> = UseFormProps< type UseHookFormProps<TFields extends FieldValues = FieldValues, TContext = any> = UseFormProps<
TFields, TFields,
@ -24,6 +24,9 @@ export function useHookForm<TFields extends FieldValues = FieldValues, TContext
resolver: zodResolver(resolverSchema), resolver: zodResolver(resolverSchema),
defaultValues: initialValues, defaultValues: initialValues,
disabled, disabled,
mode: "onBlur",
reValidateMode: "onBlur",
}); });
const { const {
@ -57,7 +60,9 @@ export function useHookForm<TFields extends FieldValues = FieldValues, TContext
lastAppliedRef.current = next; lastAppliedRef.current = next;
} }
}; };
void apply();
apply();
return () => { return () => {
mounted = false; mounted = false;
}; };

View File

@ -52,9 +52,8 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dinero.js": "^1.9.1", "dinero.js": "^1.9.1",
"express": "^4.18.2", "express": "^4.18.2",
"handlebars": "^4.7.8",
"libphonenumber-js": "^1.12.7", "libphonenumber-js": "^1.12.7",
"lucide-react": "^0.503.0", "lucide-react": "^0.577.0",
"pg-hstore": "^2.3.4", "pg-hstore": "^2.3.4",
"react-hook-form": "^7.58.1", "react-hook-form": "^7.58.1",
"react-i18next": "^15.5.1", "react-i18next": "^15.5.1",

View File

@ -1,5 +1,4 @@
import { PageHeader, SimpleSearchInput } from "@erp/core/components"; import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components";
import { ErrorAlert } from "@erp/customers/components";
import { AppContent, AppHeader, BackHistoryButton, LogoVerifactu } from "@repo/rdx-ui/components"; import { AppContent, AppHeader, BackHistoryButton, LogoVerifactu } from "@repo/rdx-ui/components";
import { import {
Alert, Alert,

View File

@ -43,7 +43,7 @@
"@tanstack/react-query": "^5.90.6", "@tanstack/react-query": "^5.90.6",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"express": "^4.18.2", "express": "^4.18.2",
"lucide-react": "^0.503.0", "lucide-react": "^0.577.0",
"react-data-table-component": "^7.7.0", "react-data-table-component": "^7.7.0",
"react-hook-form": "^7.58.1", "react-hook-form": "^7.58.1",
"react-i18next": "^16.2.4", "react-i18next": "^16.2.4",

View File

@ -1,9 +1,15 @@
import type { ICatalogs } from "@erp/core/api"; import type { ICatalogs } from "@erp/core/api";
import { CreateCustomerInputMapper, type ICreateCustomerInputMapper } from "../mappers"; import {
CreateCustomerInputMapper,
type ICreateCustomerInputMapper,
type IUpdateCustomerInputMapper,
UpdateCustomerInputMapper,
} from "../mappers";
export interface ICustomerInputMappers { export interface ICustomerInputMappers {
createInputMapper: ICreateCustomerInputMapper; createInputMapper: ICreateCustomerInputMapper;
updateInputMapper: IUpdateCustomerInputMapper;
} }
export const buildCustomerInputMappers = (catalogs: ICatalogs): ICustomerInputMappers => { export const buildCustomerInputMappers = (catalogs: ICatalogs): ICustomerInputMappers => {
@ -11,9 +17,10 @@ export const buildCustomerInputMappers = (catalogs: ICatalogs): ICustomerInputMa
// Mappers el DTO a las props validadas (CustomerProps) y luego construir agregado // Mappers el DTO a las props validadas (CustomerProps) y luego construir agregado
const createInputMapper = new CreateCustomerInputMapper({ taxCatalog }); const createInputMapper = new CreateCustomerInputMapper({ taxCatalog });
//const updateCustomerInputMapper = new UpdateCustomerInputMapper(); const updateInputMapper = new UpdateCustomerInputMapper();
return { return {
createInputMapper, createInputMapper,
updateInputMapper,
}; };
}; };

View File

@ -0,0 +1,12 @@
import type { ICustomerRepository } from "../repositories";
import { CustomerUpdater, type ICustomerUpdater } from "../services";
export const buildCustomerUpdater = (params: {
repository: ICustomerRepository;
}): ICustomerUpdater => {
const { repository } = params;
return new CustomerUpdater({
repository,
});
};

View File

@ -2,4 +2,5 @@ export * from "./customer-creator.di";
export * from "./customer-finder.di"; export * from "./customer-finder.di";
export * from "./customer-input-mappers.di"; export * from "./customer-input-mappers.di";
export * from "./customer-snapshot-builders.di"; export * from "./customer-snapshot-builders.di";
export * from "./customer-updater.di";
export * from "./customer-use-cases.di"; export * from "./customer-use-cases.di";

View File

@ -65,14 +65,7 @@ export class UpdateCustomerUseCase {
return Result.fail(updateResult.error); return Result.fail(updateResult.error);
} }
const customerOrError = await this.service.updateCustomerInCompany( return Result.ok(this.fullSnapshotBuilder.toOutput(updateResult.data));
companyId,
updateResult.data,
transaction
);
const customer = customerOrError.data;
const dto = presenter.toOutput(customer);
return Result.ok(dto);
} catch (error: unknown) { } catch (error: unknown) {
return Result.fail(error as Error); return Result.fail(error as Error);
} }

View File

@ -10,8 +10,10 @@ import {
buildCustomerFinder, buildCustomerFinder,
buildCustomerInputMappers, buildCustomerInputMappers,
buildCustomerSnapshotBuilders, buildCustomerSnapshotBuilders,
buildCustomerUpdater,
buildGetCustomerByIdUseCase, buildGetCustomerByIdUseCase,
buildListCustomersUseCase, buildListCustomersUseCase,
buildUpdateCustomerUseCase,
} from "../../application"; } from "../../application";
import { buildCustomerPersistenceMappers } from "./customer-persistence-mappers.di"; import { buildCustomerPersistenceMappers } from "./customer-persistence-mappers.di";
@ -49,6 +51,7 @@ export function buildCustomersDependencies(params: ModuleParams): CustomersInter
const inputMappers = buildCustomerInputMappers(catalogs); const inputMappers = buildCustomerInputMappers(catalogs);
const finder = buildCustomerFinder({ repository }); const finder = buildCustomerFinder({ repository });
const creator = buildCustomerCreator({ repository }); const creator = buildCustomerCreator({ repository });
const updater = buildCustomerUpdater({ repository });
const snapshotBuilders = buildCustomerSnapshotBuilders(); const snapshotBuilders = buildCustomerSnapshotBuilders();
//const documentGeneratorPipeline = buildCustomerDocumentService(params); //const documentGeneratorPipeline = buildCustomerDocumentService(params);
@ -80,7 +83,7 @@ export function buildCustomersDependencies(params: ModuleParams): CustomersInter
updateCustomer: () => updateCustomer: () =>
buildUpdateCustomerUseCase({ buildUpdateCustomerUseCase({
creator, updater,
dtoMapper: inputMappers.updateInputMapper, dtoMapper: inputMappers.updateInputMapper,
fullSnapshotBuilder: snapshotBuilders.full, fullSnapshotBuilder: snapshotBuilders.full,
transactionManager, transactionManager,

View File

@ -4,6 +4,7 @@ import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-
import { UpdateCustomerByIdRequestSchema } from "../../../common"; import { UpdateCustomerByIdRequestSchema } from "../../../common";
import type { Customer } from "../api"; import type { Customer } from "../api";
import type { CustomerData } from "../types";
import { toValidationErrors } from "./toValidationErrors"; import { toValidationErrors } from "./toValidationErrors";
@ -13,7 +14,7 @@ type UpdateCustomerContext = {};
type UpdateCustomerPayload = { type UpdateCustomerPayload = {
id: string; id: string;
data: Partial<CustomerFormData>; data: Partial<CustomerData>;
}; };
export const useCustomerUpdateMutation = () => { export const useCustomerUpdateMutation = () => {

View File

@ -1,2 +1,3 @@
export * from "./api"; export * from "./api";
export * from "./hooks"; export * from "./hooks";
export * from "./types";

View File

@ -0,0 +1,3 @@
import type { Customer } from "./api";
export type CustomerData = Customer;

View File

@ -1,4 +1,3 @@
import { FormDebug } from "@erp/core/components";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields"; import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields";
@ -16,8 +15,7 @@ type CustomerFormProps = {
export const CustomerEditForm = ({ formId, onSubmit, className, focusRef }: CustomerFormProps) => { export const CustomerEditForm = ({ formId, onSubmit, className, focusRef }: CustomerFormProps) => {
return ( return (
<form id={formId} noValidate onSubmit={onSubmit}> <form id={formId} noValidate onSubmit={onSubmit}>
<FormDebug /> <section className={cn("space-y-12 p-6", className)}>
<section className={cn("space-y-6 p-6", className)}>
<CustomerBasicInfoFields focusRef={focusRef} /> <CustomerBasicInfoFields focusRef={focusRef} />
<CustomerAddressFields /> <CustomerAddressFields />
<CustomerContactFields /> <CustomerContactFields />

View File

@ -45,8 +45,8 @@ export const CustomerTaxesMultiSelect = (props: CustomerTaxesMultiSelect) => {
animation={0} animation={0}
autoFilter={true} autoFilter={true}
className={cn( className={cn(
"flex w-full -mt-0.5 px-1 py-0.5 rounded-md border min-h-8 h-auto items-center justify-between bg-background hover:bg-inherit [&_svg]:pointer-events-auto", "flex w-full -mt-0.5 px-1 py-0.5 rounded-md border border-input min-h-8 h-auto items-center justify-between hover:bg-inherit [&_svg]:pointer-events-auto",
"hover:border-ring hover:ring-ring/50 hover:ring-[2px]", "hover:border-ring hover:ring-ring/50 hover:ring-[2px] font-medium bg-muted/50",
className className
)} )}
defaultValue={value} defaultValue={value}

View File

@ -1,3 +1,2 @@
export * from "./customer.api.schema"; export * from "./customer.api.schema";
export * from "./customer.form.schema"; export * from "./customer.form.schema";
export * from "./customer-resume.form.schema";

View File

@ -6,12 +6,8 @@ import { type FieldErrors, FormProvider } from "react-hook-form";
import { useCustomerGetQuery, useCustomerUpdateMutation } from "../../common"; import { useCustomerGetQuery, useCustomerUpdateMutation } from "../../common";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { import { type Customer, CustomerFormSchema, defaultCustomerFormData } from "../../schemas";
type Customer, import type { CustomerFormData } from "../types";
type CustomerFormData,
CustomerFormSchema,
defaultCustomerFormData,
} from "../../schemas";
export interface UseCustomerUpdateControllerOptions { export interface UseCustomerUpdateControllerOptions {
onUpdated?(updated: Customer): void; onUpdated?(updated: Customer): void;

View File

@ -1,3 +1,89 @@
import type { Customer } from "../api"; import { z } from "zod/v4";
export type CustomerData = Customer; export const CustomerFormSchema = z.object({
reference: z.string().optional(),
is_company: z.string().default("true"),
name: z
.string({
error: "El nombre es obligatorio",
})
.min(1, "El nombre no puede estar vacío"),
trade_name: z.string().optional(),
tin: z.string().optional(),
default_taxes: z.array(z.string()).default([]),
street: z.string().optional(),
street2: z.string().optional(),
city: z.string().optional(),
province: z.string().optional(),
postal_code: z.string().optional(),
country: z
.string({
error: "El país es obligatorio",
})
.min(1, "El país no puede estar vacío")
.toLowerCase() // asegura minúsculas
.default("es"),
email_primary: z.string().optional(),
email_secondary: z.string().optional(),
phone_primary: z.string().optional(),
phone_secondary: z.string().optional(),
mobile_primary: z.string().optional(),
mobile_secondary: z.string().optional(),
fax: z.string().optional(),
website: z.string().optional(),
legal_record: z.string().optional(),
language_code: z
.string({
error: "El idioma es obligatorio",
})
.min(1, "Debe indicar un idioma")
.toUpperCase() // asegura mayúsculas
.default("es"),
currency_code: z
.string({
error: "La moneda es obligatoria",
})
.min(1, "La moneda no puede estar vacía")
.toUpperCase() // asegura mayúsculas
.default("EUR"),
});
export type CustomerFormData = z.infer<typeof CustomerFormSchema>;
export const defaultCustomerFormData: CustomerFormData = {
reference: "",
is_company: "true",
name: "",
trade_name: "",
tin: "",
default_taxes: ["iva_21"],
street: "",
street2: "",
city: "",
province: "",
postal_code: "",
country: "es",
email_primary: "",
email_secondary: "",
phone_primary: "",
phone_secondary: "",
mobile_primary: "",
mobile_secondary: "",
fax: "",
website: "",
legal_record: "",
language_code: "es",
currency_code: "EUR",
};

View File

@ -31,6 +31,8 @@ export const CustomerUpdatePage = () => {
FormProvider, FormProvider,
} = useCustomerUpdateController(initialCustomerId, {}); } = useCustomerUpdateController(initialCustomerId, {});
console.log("venga!!!");
if (isLoading) { if (isLoading) {
return <CustomerEditorSkeleton />; return <CustomerEditorSkeleton />;
} }
@ -68,7 +70,7 @@ export const CustomerUpdatePage = () => {
); );
return ( return (
<UnsavedChangesProvider isDirty={form.formState.isDirty}> <UnsavedChangesProvider isDirty={false}>
<AppHeader> <AppHeader>
<PageHeader <PageHeader
backIcon backIcon
@ -106,7 +108,7 @@ export const CustomerUpdatePage = () => {
<FormProvider {...form}> <FormProvider {...form}>
<CustomerEditForm <CustomerEditForm
className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto mt-6" // para que el botón del header pueda hacer submit className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto mt-6 " // para que el botón del header pueda hacer submit
formId={formId} formId={formId}
onSubmit={onSubmit} onSubmit={onSubmit}
/> />

View File

@ -2,9 +2,9 @@ import { mockUser, requireAuthenticated, requireCompanyContext } from "@erp/auth
import { type ModuleParams, type RequestWithAuth, validateRequest } from "@erp/core/api"; import { type ModuleParams, type RequestWithAuth, validateRequest } from "@erp/core/api";
import type { ProformaPublicServices } from "@erp/customer-invoices/api"; import type { ProformaPublicServices } from "@erp/customer-invoices/api";
import type { CustomerPublicServices } from "@erp/customers/api"; import type { CustomerPublicServices } from "@erp/customers/api";
import { CreateProformaFromFactugesRequestSchema } from "@erp/factuges/common";
import { type NextFunction, type Request, type Response, Router } from "express"; import { type NextFunction, type Request, type Response, Router } from "express";
import { CreateProformaFromFactugesRequestSchema } from "../../../common/dto/request/create-proforma-from-factuges.request.dto";
import type { FactugesInternalDeps } from "../di/factuges.di"; import type { FactugesInternalDeps } from "../di/factuges.di";
import { CreateProformaFromFactugesController } from "./controllers"; import { CreateProformaFromFactugesController } from "./controllers";

View File

@ -25,10 +25,11 @@
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1", "@typescript-eslint/parser": "^8.56.1",
"baseline-browser-mapping": "^2.10.8",
"change-case": "^5.4.4", "change-case": "^5.4.4",
"inquirer": "^12.10.0", "inquirer": "^12.10.0",
"plop": "^4.0.4", "plop": "^4.0.5",
"rimraf": "^5.0.5", "rimraf": "^6.0.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"turbo": "^2.5.8", "turbo": "^2.5.8",
"typescript": "5.9.3" "typescript": "5.9.3"

View File

@ -1,32 +1,43 @@
import { import {
Field,
FieldDescription,
FieldError,
FieldLabel,
FormControl, FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import { Control, FieldPath, FieldValues, useController, useFormState } from "react-hook-form"; import {
type Control,
Controller,
type FieldPath,
type FieldValues,
useController,
useFormState,
} from "react-hook-form";
import { useTranslation } from "../../locales/i18n.ts"; import { useTranslation } from "../../locales/i18n.ts";
type SelectFieldProps<TFormValues extends FieldValues> = { type SelectFieldProps<TFormValues extends FieldValues> = {
control: Control<TFormValues>; control: Control<TFormValues>;
name: FieldPath<TFormValues>; name: FieldPath<TFormValues>;
items: Array<{ value: string; label: string }>;
label?: string; label?: string;
placeholder?: string;
description?: string; description?: string;
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
readOnly?: boolean; readOnly?: boolean;
placeholder?: string;
items: Array<{ value: string; label: string }>;
orientation?: "vertical" | "horizontal" | "responsive";
className?: string; className?: string;
inputClassName?: string;
}; };
export function SelectField<TFormValues extends FieldValues>({ export function SelectField<TFormValues extends FieldValues>({
@ -39,61 +50,61 @@ export function SelectField<TFormValues extends FieldValues>({
disabled = false, disabled = false,
required = false, required = false,
readOnly = false, readOnly = false,
orientation = "vertical",
className, className,
inputClassName,
}: SelectFieldProps<TFormValues>) { }: SelectFieldProps<TFormValues>) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isSubmitting, isValidating } = useFormState({ control, name }); const { isSubmitting } = useFormState({ control, name });
const { field, fieldState } = useController({ control, name }); const { field, fieldState } = useController({ control, name });
const isDisabled = disabled || readOnly; const isDisabled = disabled || readOnly;
return ( return (
<FormField <Controller
control={control} control={control}
name={name} name={name}
render={({ field }) => ( render={({ field, fieldState }) => {
<FormItem className={cn("space-y-0", className)}> return (
{label && ( <Field
<div className='mb-1 flex justify-between'> className={cn("gap-1", className)}
<div className='flex items-center gap-2'> data-invalid={fieldState.invalid}
<FormLabel htmlFor={name} className='m-0'> orientation={orientation}
{label} >
</FormLabel> {label && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
{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>
)}
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={isDisabled}>
<FormControl>
<SelectTrigger className='w-full bg-background h-8'>
<SelectValue
placeholder={placeholder}
className=' placeholder:font-normal placeholder:italic '
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{items.map((item) => (
<SelectItem key={`key-${item.value}`} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription className={cn("text-xs truncate", !description && "invisible")}> <Select defaultValue={field.value} disabled={isDisabled} onValueChange={field.onChange}>
{description || "\u00A0"} <FormControl>
</FormDescription> <SelectTrigger
<FormMessage /> className={cn(
</FormItem> "w-full h-8",
)} "font-medium bg-muted/50 hover:bg-inherit hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
inputClassName
)}
>
<SelectValue
className={"placeholder:font-normal placeholder:italic"}
placeholder={placeholder}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{items.map((item) => (
<SelectItem key={`key-${item.value}`} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldDescription>{description || "\u00A0"}</FieldDescription>
<FieldError errors={[fieldState.error]} />
</Field>
);
}}
/> />
); );
} }

View File

@ -1,5 +1,3 @@
// DatePickerField.tsx
import { import {
Field, Field,
FieldDescription, FieldDescription,
@ -7,19 +5,26 @@ import {
FieldLabel, FieldLabel,
Textarea, Textarea,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
type Control,
Controller,
type FieldPath,
type FieldValues,
useFormState,
} from "react-hook-form";
import { cn } from '@repo/shadcn-ui/lib/utils'; import type { CommonInputProps } from "./types.js";
import { Control, Controller, FieldPath, FieldValues, useFormState } from "react-hook-form";
import { CommonInputProps } from "./types.js";
type TextAreaFieldProps<TFormValues extends FieldValues> = CommonInputProps & { type TextAreaFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
control: Control<TFormValues>; control: Control<TFormValues>;
name: FieldPath<TFormValues>; name: FieldPath<TFormValues>;
label?: string; label?: string;
required?: boolean;
readOnly?: boolean;
description?: string; description?: string;
orientation?: "vertical" | "horizontal" | "responsive", orientation?: "vertical" | "horizontal" | "responsive";
inputClassName?: string; inputClassName?: string;
}; };
@ -32,14 +37,14 @@ export function TextAreaField<TFormValues extends FieldValues>({
required = false, required = false,
readOnly = false, readOnly = false,
orientation = 'vertical', orientation = "vertical",
className, className,
inputClassName, inputClassName,
...inputRest ...inputRest
}: TextAreaFieldProps<TFormValues>) { }: TextAreaFieldProps<TFormValues>) {
const { isSubmitting, isValidating } = useFormState({ control, name }); const { isSubmitting } = useFormState({ control, name });
const disabled = isSubmitting || inputRest.disabled; const disabled = isSubmitting || inputRest.disabled;
return ( return (
@ -48,25 +53,30 @@ export function TextAreaField<TFormValues extends FieldValues>({
name={name} name={name}
render={({ field, fieldState }) => { render={({ field, fieldState }) => {
return ( return (
<Field data-invalid={fieldState.invalid} orientation={orientation} className={cn("gap-1", className)}> <Field
{label && <FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={name}>{label}</FieldLabel>} className={cn("gap-1", className)}
data-invalid={fieldState.invalid}
orientation={orientation}
>
{label && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
<Textarea <Textarea
ref={field.ref}
id={name}
value={field.value ?? ""}
onChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={fieldState.invalid} aria-invalid={fieldState.invalid}
aria-busy={isValidating} id={name}
onBlur={field.onBlur}
onChange={field.onChange}
ref={field.ref}
value={field.value ?? ""}
{...inputRest} {...inputRest}
disabled={disabled}
aria-disabled={disabled} aria-disabled={disabled}
className={cn("bg-background", inputClassName)} className={cn(
"font-medium bg-muted/50 hover:bg-inherit hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
inputClassName
)}
disabled={disabled}
/> />
<FieldDescription>{description || "\u00A0"}</FieldDescription>
{false && <FieldDescription className='text-xs'>{description || "\u00A0"}</FieldDescription>}
<FieldError errors={[fieldState.error]} /> <FieldError errors={[fieldState.error]} />
</Field> </Field>
); );
@ -74,4 +84,3 @@ export function TextAreaField<TFormValues extends FieldValues>({
/> />
); );
} }

View File

@ -1,17 +1,14 @@
import { import { Field, FieldDescription, FieldError, FieldLabel, Input } from "@repo/shadcn-ui/components";
Field,
FieldDescription,
FieldError,
FieldLabel,
Input
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import { Control, Controller, FieldPath, FieldValues, useFormState } from "react-hook-form"; import {
import { CommonInputProps } from "./types.js"; type Control,
Controller,
type FieldPath,
type FieldValues,
useFormState,
} from "react-hook-form";
import type { CommonInputProps } from "./types.js";
type Normalizer = (value: string) => string;
type TextFieldProps<TFormValues extends FieldValues> = CommonInputProps & { type TextFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
control: Control<TFormValues>; control: Control<TFormValues>;
@ -20,7 +17,7 @@ type TextFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
label?: string; label?: string;
description?: string; description?: string;
orientation?: "vertical" | "horizontal" | "responsive", orientation?: "vertical" | "horizontal" | "responsive";
inputClassName?: string; inputClassName?: string;
}; };
@ -33,14 +30,14 @@ export function TextField<TFormValues extends FieldValues>({
required = false, required = false,
readOnly = false, readOnly = false,
orientation = 'vertical', orientation = "vertical",
className, className,
inputClassName, inputClassName,
...inputRest ...inputRest
}: TextFieldProps<TFormValues>) { }: TextFieldProps<TFormValues>) {
const { isSubmitting, isValidating } = useFormState({ control, name }); const { isSubmitting } = useFormState({ control, name });
const disabled = isSubmitting || inputRest.disabled; const disabled = isSubmitting || inputRest.disabled;
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) { function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
@ -56,24 +53,30 @@ export function TextField<TFormValues extends FieldValues>({
name={name} name={name}
render={({ field, fieldState }) => { render={({ field, fieldState }) => {
return ( return (
<Field data-invalid={fieldState.invalid} orientation={orientation} className={cn("gap-1", className)}> <Field
{label && <FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={name}>{label}</FieldLabel>} className={cn("gap-1", className)}
data-invalid={fieldState.invalid}
orientation={orientation}
>
{label && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
<Input <Input
ref={field.ref}
id={name}
value={field.value ?? ""}
onChange={field.onChange}
onBlur={field.onBlur}
onKeyDown={handleKeyDown}
aria-invalid={fieldState.invalid} aria-invalid={fieldState.invalid}
aria-busy={isValidating} id={name}
onBlur={field.onBlur}
onChange={field.onChange}
onKeyDown={handleKeyDown}
ref={field.ref}
value={field.value ?? ""}
{...inputRest} {...inputRest}
disabled={disabled}
aria-disabled={disabled} aria-disabled={disabled}
className={cn("bg-background", inputClassName)} className={cn(
"font-medium bg-muted/50 hover:bg-inherit hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
inputClassName
)}
disabled={disabled}
/> />
{false && <FieldDescription className='text-xs'>{description || "\u00A0"}</FieldDescription>} <FieldDescription>{description || "\u00A0"}</FieldDescription>
<FieldError errors={[fieldState.error]} /> <FieldError errors={[fieldState.error]} />
</Field> </Field>
); );

View File

@ -1,19 +1,18 @@
import * as React from "react" import { cn } from "@repo/shadcn-ui/lib/utils";
import type * as React from "react";
import { cn } from "@repo/shadcn-ui/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
<input <input
type={type}
data-slot="input"
className={cn( className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className className
)} )}
data-slot="input"
type={type}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };

View File

@ -125,12 +125,12 @@
--secondary-foreground: oklch(0.9543 0.0166 250.8425); --secondary-foreground: oklch(0.9543 0.0166 250.8425);
--muted: oklch(0.97 0 0); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0); --accent: oklch(0.6427 0.1407 253.94);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.97 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.575 0.1533 256.4357); /* oklch(0.708 0 0); */
--chart-1: oklch(0.809 0.105 251.813); --chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815); --chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881); --chart-3: oklch(0.546 0.245 262.881);

File diff suppressed because it is too large Load Diff

View File

@ -12,4 +12,5 @@ onlyBuiltDependencies:
- '@tailwindcss/oxide' - '@tailwindcss/oxide'
- bcrypt - bcrypt
- core-js-pure - core-js-pure
- msw
- sharp - sharp