Clientes y facturas de cliente

This commit is contained in:
David Arranz 2025-09-19 18:55:30 +02:00
parent 11402bccc1
commit 9ef847d54b
29 changed files with 535 additions and 111 deletions

View File

@ -10,6 +10,12 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Roboto:ital,wdth,wght@0,75..100,100..900;1,75..100,100..900&display=swap"
rel="stylesheet">
<link
href="https://fonts.googleapis.com/css2?family=Domine:wght@400..700&family=Roboto:ital,wdth,wght@0,75..100,100..900;1,75..100,100..900&display=swap"
rel="stylesheet">
<title>FactuGES 2025</title>
<link rel="icon" type="image/png" href="/favicon.png" />

View File

@ -42,7 +42,6 @@ export const createAxiosInstance = ({
}: AxiosFactoryConfig): AxiosInstance => {
const instance = axios.create(defaultAxiosRequestConfig);
instance.defaults.baseURL = baseURL;
setupInterceptors(instance, getAccessToken, onAuthError);
return instance;
return setupInterceptors(instance, getAccessToken, onAuthError);
};

View File

@ -3,16 +3,16 @@ import { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios";
/**
* Configura interceptores para una instancia de Axios.
*
* @param instance - Instancia de Axios que será modificada.
* @param axiosInstance - Instancia de Axios que será modificada.
* @param getAccessToken - Función que devuelve el token JWT actual.
* @param onAuthError - Función opcional que se ejecuta ante errores de autenticación (status 401).
*/
export const setupInterceptors = (
instance: AxiosInstance,
axiosInstance: AxiosInstance,
getAccessToken: () => string | null,
onAuthError?: () => void
): void => {
instance.interceptors.request.use(
) => {
axiosInstance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = getAccessToken();
if (token && config.headers) {
@ -25,13 +25,48 @@ export const setupInterceptors = (
}
);
instance.interceptors.response.use(
axiosInstance.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401 && onAuthError) {
// 🔴 Transformamos SIEMPRE el error antes de propagarlo
const normalized = normalizeAxiosError(error);
return Promise.reject(normalized);
/*if (error.response?.status === 401 && onAuthError) {
onAuthError();
}
return Promise.reject(error);
} */
}
);
return axiosInstance;
};
/**
* Normaliza errores de Axios en un objeto estándar de Error
* con propiedades extra opcionales (status, raw).
*/
function normalizeAxiosError(error: AxiosError): Error {
let normalizedError: Error;
if (error.response?.data) {
const data: any = error.response.data;
// Intentamos localizar mensaje en campos comunes
const msg =
data.message ??
(Array.isArray(data.errors) && data.errors[0]?.msg) ??
error.message ??
"Unknown server error";
normalizedError = new Error(msg);
// Añadimos metadatos útiles
(normalizedError as any).status = error.response.status;
(normalizedError as any).raw = data;
} else {
normalizedError = new Error(error.message || "Unknown network error");
(normalizedError as any).status = error.response?.status ?? 0;
}
return normalizedError;
}

View File

@ -3,5 +3,5 @@ import { UtcDate } from "@repo/rdx-ddd";
export function formatDateDTO(dateString: string) {
const result = UtcDate.createFromISO(dateString).data;
return result.toDateString();
return result.toEuropeanString();
}

View File

@ -1,9 +1,18 @@
import { MoneyDTO } from "@erp/core";
import { MoneyValue } from "@repo/rdx-ddd";
export function formatMoneyDTO(amount: MoneyDTO, locale: string) {
if (amount.value === "") {
return "";
export type FormatMoneyOptions = {
locale: string;
hideZeros?: boolean;
newScale?: number;
};
export function formatMoneyDTO(
amount: MoneyDTO,
{ locale, hideZeros = false, newScale = 2 }: FormatMoneyOptions
) {
if (hideZeros && (amount.value === "0" || amount.value === "")) {
return null;
}
const money = MoneyValue.create({
@ -12,5 +21,5 @@ export function formatMoneyDTO(amount: MoneyDTO, locale: string) {
scale: Number(amount.scale),
}).data;
return money.format(locale);
return money.convertScale(newScale).format(locale);
}

View File

@ -2,6 +2,10 @@ import { PercentageDTO } from "@erp/core";
import { Percentage } from "@repo/rdx-ddd";
export function formatPercentageDTO(Percentage_value: PercentageDTO, locale: string) {
if (Percentage_value.value === "0" || Percentage_value.value === "") {
return null;
}
const value = Percentage.create({
value: Number(Percentage_value.value),
scale: Number(Percentage_value.scale),

View File

@ -2,8 +2,8 @@ import { QuantityDTO } from "@erp/core";
import { Quantity } from "@repo/rdx-ddd";
export function formatQuantityDTO(quantity_value: QuantityDTO) {
if (quantity_value.value === "") {
return "";
if (quantity_value.value === "0" || quantity_value.value === "") {
return null;
}
const value = Quantity.create({

View File

@ -1,7 +1,7 @@
import { IPresenterOutputParams, Presenter } from "@erp/core/api";
import { GetCustomerInvoiceByIdResponseDTO } from "@erp/customer-invoices/common";
import { ArrayElement } from "@repo/rdx-utils";
import { formatMoneyDTO, formatQuantityDTO } from "../../helpers";
import { FormatMoneyOptions, formatMoneyDTO, formatQuantityDTO } from "../../helpers";
type CustomerInvoiceItemsDTO = GetCustomerInvoiceByIdResponseDTO["items"];
type CustomerInvoiceItemDTO = ArrayElement<CustomerInvoiceItemsDTO>;
@ -13,18 +13,23 @@ export class CustomerInvoiceItemsReportPersenter extends Presenter<
private _locale!: string;
private _mapItem(invoiceItem: CustomerInvoiceItemDTO, index: number) {
const moneyOptions: FormatMoneyOptions = {
locale: this._locale,
hideZeros: true,
newScale: 2,
};
return {
...invoiceItem,
quantity: formatQuantityDTO(invoiceItem.quantity),
unit_amount: formatMoneyDTO(invoiceItem.unit_amount, this._locale),
subtotal_amount: formatMoneyDTO(invoiceItem.subtotal_amount, this._locale),
unit_amount: formatMoneyDTO(invoiceItem.unit_amount, moneyOptions),
subtotal_amount: formatMoneyDTO(invoiceItem.subtotal_amount, moneyOptions),
// discount_percetage: formatPercentageDTO(invoiceItem.discount_percentage, this._locale),
discount_amount: formatMoneyDTO(invoiceItem.discount_amount, this._locale),
taxable_amount: formatMoneyDTO(invoiceItem.taxable_amount, this._locale),
taxes_amount: formatMoneyDTO(invoiceItem.taxes_amount, this._locale),
total_amount: formatMoneyDTO(invoiceItem.total_amount, this._locale),
discount_amount: formatMoneyDTO(invoiceItem.discount_amount, moneyOptions),
taxable_amount: formatMoneyDTO(invoiceItem.taxable_amount, moneyOptions),
taxes_amount: formatMoneyDTO(invoiceItem.taxes_amount, moneyOptions),
total_amount: formatMoneyDTO(invoiceItem.total_amount, moneyOptions),
};
}

View File

@ -1,6 +1,11 @@
import { Presenter } from "@erp/core/api";
import { GetCustomerInvoiceByIdResponseDTO } from "../../../../common/dto";
import { formatDateDTO, formatMoneyDTO, formatPercentageDTO } from "../../helpers";
import {
FormatMoneyOptions,
formatDateDTO,
formatMoneyDTO,
formatPercentageDTO,
} from "../../helpers";
export class CustomerInvoiceReportPresenter extends Presenter<
GetCustomerInvoiceByIdResponseDTO,
@ -18,17 +23,23 @@ export class CustomerInvoiceReportPresenter extends Presenter<
locale,
});
const moneyOptions: FormatMoneyOptions = {
locale,
hideZeros: true,
newScale: 2,
};
return {
...invoiceDTO,
items: itemsDTO,
invoice_date: formatDateDTO(invoiceDTO.invoice_date),
subtotal_amount: formatMoneyDTO(invoiceDTO.subtotal_amount, locale),
discount_percetage: formatPercentageDTO(invoiceDTO.discount_percentage, locale),
discount_amount: formatMoneyDTO(invoiceDTO.discount_amount, locale),
taxable_amount: formatMoneyDTO(invoiceDTO.taxable_amount, locale),
taxes_amount: formatMoneyDTO(invoiceDTO.taxes_amount, locale),
total_amount: formatMoneyDTO(invoiceDTO.total_amount, locale),
subtotal_amount: formatMoneyDTO(invoiceDTO.subtotal_amount, moneyOptions),
discount_percentage: formatPercentageDTO(invoiceDTO.discount_percentage, locale),
discount_amount: formatMoneyDTO(invoiceDTO.discount_amount, moneyOptions),
taxable_amount: formatMoneyDTO(invoiceDTO.taxable_amount, moneyOptions),
taxes_amount: formatMoneyDTO(invoiceDTO.taxes_amount, moneyOptions),
total_amount: formatMoneyDTO(invoiceDTO.total_amount, moneyOptions),
};
}
}

View File

@ -36,6 +36,7 @@ export class CustomerInvoiceReportPDFPresenter extends Presenter<
await page.setContent(htmlData, { waitUntil: "networkidle2" });
await navigationPromise;
const reportPDF = await report.pdfPage(page, {
format: "A4",
margin: {
@ -48,6 +49,10 @@ export class CustomerInvoiceReportPDFPresenter extends Presenter<
preferCSSPageSize: true,
omitBackground: false,
printBackground: true,
displayHeaderFooter: true,
headerTemplate: "<div />",
footerTemplate:
'<div style="text-align: center;width: 297mm;font-size: 10px;">Página <span style="margin-right: 1cm"><span class="pageNumber"></span> de <span class="totalPages"></span></span></div>',
});
await browser.close();

View File

@ -55,14 +55,14 @@
table th,
table td {
border: 0px solid #ccc;
border: 0px solid;
padding: 3px 10px;
text-align: left;
vertical-align: top;
}
table th {
background-color: #f5f5f5;
margin-bottom: 10px;
}
.totals {
@ -81,7 +81,7 @@
footer {
margin-top: 40px;
font-size: 12px;
font-size: 10px;
}
.highlight {
@ -119,7 +119,7 @@
<div class="p-1 ">
<p>Factura nº:<strong>&nbsp;{{invoice_number}}</strong></p>
<p><span>Fecha:<strong>&nbsp;{{invoice_date}}</strong></p>
<p><span>Página:</span>1 / 1</p>
<p><span>Página:</span><span class="pageNumber"></span> / <span class="totalPages"></span></p>
</div>
<div class="p-1 ml-9">
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
@ -174,9 +174,10 @@
{{#each items}}
<tr>
<td>{{description}}</td>
<td class="text-right">{{quantity}}</td>
<td class="text-right">{{unit_amount}}</td>
<td class="text-right">{{total_amount}}</td>
<td class="text-right">{{#if quantity}}{{quantity}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if total_amount}}{{total_amount}}{{else}}&nbsp;{{/if}}</td>
</td>
</tr>
{{/each}}
</tbody>
@ -197,14 +198,14 @@
<div class="relative pt-10 grow">
<table class="table-header min-w-full bg-transparent">
<tbody>
{{#if percentage}}
{{#if discount_percentage}}
<tr>
<td class="px-4 text-right">Importe&nbsp;neto</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{subtotal_amount}}</td>
</tr>
<tr>
<td class="px-4 text-right">Descuento&nbsp;0%</td>
<td class="px-4 text-right">Descuento&nbsp;{{discount_percentage}}</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{discount_amount.value}}</td>
</tr>
@ -237,7 +238,8 @@
<footer id="footer" class="mt-4">
<aside>
<p>Insc. en el Reg. Merc. de Madrid, Tomo 20.073, Libro 0, Folio 141, Sección 8, Hoja M-354212 | CIF: B83999441 -
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 20.073, Libro 0, Folio 141, Sección 8, Hoja M-354212
| CIF: B83999441 -
Rodax Software S.L.</p>
</aside>
</footer>

View File

@ -1,22 +1,22 @@
import { ITransactionManager } from "@erp/core/api";
import { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { UpdateCustomerRequestDTO } from "../../../common";
import { CustomerPatchProps, CustomerService } from "../../domain";
import { UpdateCustomerAssembler } from "./assembler";
import { UpdateCustomerByIdRequestDTO } from "../../../../common/dto";
import { CustomerPatchProps, CustomerService } from "../../../domain";
import { CustomerFullPresenter } from "../../presenters";
import { mapDTOToUpdateCustomerPatchProps } from "./map-dto-to-update-customer-props";
type UpdateCustomerUseCaseInput = {
companyId: UniqueID;
customer_id: string;
dto: UpdateCustomerRequestDTO;
dto: UpdateCustomerByIdRequestDTO;
};
export class UpdateCustomerUseCase {
constructor(
private readonly service: CustomerService,
private readonly transactionManager: ITransactionManager,
private readonly assembler: UpdateCustomerAssembler
private readonly presenterRegistry: IPresenterRegistry
) {}
public execute(params: UpdateCustomerUseCaseInput) {
@ -28,6 +28,10 @@ export class UpdateCustomerUseCase {
}
const customerId = idOrError.data;
const presenter = this.presenterRegistry.getPresenter({
resource: "customer",
projection: "FULL",
}) as CustomerFullPresenter;
// Mapear DTO → props de dominio
const patchPropsResult = mapDTOToUpdateCustomerPatchProps(dto);
@ -50,10 +54,10 @@ export class UpdateCustomerUseCase {
return Result.fail(updatedCustomer.error);
}
const savedCustomer = await this.service.saveCustomer(updatedCustomer.data, transaction);
const getDTO = this.assembler.toDTO(savedCustomer.data);
return Result.ok(getDTO);
const customerOrError = await this.service.saveCustomer(updatedCustomer.data, transaction);
const customer = customerOrError.data;
const dto = presenter.toOutput(customer);
return Result.ok(dto);
} catch (error: unknown) {
return Result.fail(error as Error);
}

View File

@ -9,6 +9,7 @@ import {
CustomerFullPresenter,
ListCustomersPresenter,
ListCustomersUseCase,
UpdateCustomerUseCase,
} from "../application";
import { GetCustomerUseCase } from "../application/use-cases/get-customer.use-case";
import { CustomerService } from "../domain";
@ -25,8 +26,8 @@ export type CustomerDeps = {
list: () => ListCustomersUseCase;
get: () => GetCustomerUseCase;
create: () => CreateCustomerUseCase;
/*update: () => UpdateCustomerUseCase;
delete: () => DeleteCustomerUseCase;*/
update: () => UpdateCustomerUseCase;
//delete: () => DeleteCustomerUseCase;
};
};
@ -67,8 +68,8 @@ export function buildCustomerDependencies(params: ModuleParams): CustomerDeps {
list: () => new ListCustomersUseCase(service, transactionManager, presenterRegistry),
get: () => new GetCustomerUseCase(service, transactionManager, presenterRegistry),
create: () => new CreateCustomerUseCase(service, transactionManager, presenterRegistry),
/*update: () => new UpdateCustomerUseCase(_service!, transactionManager!, presenterRegistry!),
delete: () => new DeleteCustomerUseCase(_service!, transactionManager!),*/
update: () => new UpdateCustomerUseCase(service, transactionManager, presenterRegistry),
//delete: () => new DeleteCustomerUseCase(_service!, transactionManager!),
},
};
}

View File

@ -2,4 +2,4 @@ export * from "./create-customer.controller";
export * from "./delete-customer.controller";
export * from "./get-customer.controller";
export * from "./list-customers.controller";
///export * from "./update-customer.controller";
export * from "./update-customer.controller";

View File

@ -6,12 +6,15 @@ import {
CreateCustomerRequestSchema,
CustomerListRequestSchema,
GetCustomerByIdRequestSchema,
UpdateCustomerByIdParamsRequestSchema,
UpdateCustomerByIdRequestSchema,
} from "../../../common/dto";
import { buildCustomerDependencies } from "../dependencies";
import {
CreateCustomerController,
GetCustomerController,
ListCustomersController,
UpdateCustomerController,
} from "./controllers";
export const customersRouter = (params: ModuleParams) => {
@ -81,9 +84,10 @@ export const customersRouter = (params: ModuleParams) => {
}
);
/*router.put(
router.put(
"/:customer_id",
//checkTabContext,
validateRequest(UpdateCustomerByIdParamsRequestSchema, "params"),
validateRequest(UpdateCustomerByIdRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
@ -93,7 +97,7 @@ export const customersRouter = (params: ModuleParams) => {
}
);
router.delete(
/*router.delete(
"/:customer_id",
//checkTabContext,
@ -103,7 +107,7 @@ export const customersRouter = (params: ModuleParams) => {
const controller = new DeleteCustomerController(useCase);
return controller.execute(req, res, next);
}
); */
);*/
app.use(`${baseRoutePath}/customers`, router);
};

View File

@ -31,4 +31,4 @@ export const UpdateCustomerByIdRequestSchema = z.object({
currency_code: z.string().optional(),
});
export type UpdateCustomerByIdRequestDTO = z.infer<typeof UpdateCustomerByIdRequestSchema>;
export type UpdateCustomerByIdRequestDTO = Partial<z.infer<typeof UpdateCustomerByIdRequestSchema>>;

View File

@ -21,6 +21,11 @@
"title": "New customer",
"description": "Create a new customer",
"back_to_list": "Back to the list"
},
"update": {
"title": "Update customer",
"description": "Update a customer",
"back_to_list": "Back to the list"
}
},
"form_fields": {
@ -117,10 +122,10 @@
"placeholder": "Enter website URL",
"description": "The website of the customer"
},
"default_tax": {
"label": "Default tax",
"placeholder": "Select default tax",
"description": "The default tax rate for the customer"
"default_taxes": {
"label": "Default taxes",
"placeholder": "Select default taxes",
"description": "The default tax rates for the customer"
},
"language_code": {
"label": "Language",
@ -151,8 +156,8 @@
"title": "Contact information",
"description": "Customer contact details"
},
"additional_config": {
"title": "Additional settings",
"preferences": {
"title": "Preferences",
"description": "Additional customer configurations"
}
},

View File

@ -21,6 +21,11 @@
"title": "Nuevo cliente",
"description": "Crear un nuevo cliente",
"back_to_list": "Volver a la lista"
},
"update": {
"title": "Modificación de cliente",
"description": "Modificar los datos de un cliente",
"back_to_list": "Back to the list"
}
},
"form_fields": {
@ -119,7 +124,7 @@
"placeholder": "Ingrese la URL del sitio web",
"description": "El sitio web del cliente"
},
"default_tax": {
"default_taxes": {
"label": "Impuesto por defecto",
"placeholder": "Seleccione el impuesto por defecto",
"description": "La tasa de impuesto por defecto para el cliente"
@ -153,8 +158,8 @@
"title": "Información de contacto",
"description": "Detalles de contacto del cliente"
},
"additional_config": {
"title": "Configuración adicional",
"preferences": {
"title": "Preferencias",
"description": "Configuraciones adicionales del cliente"
}
},

View File

@ -9,7 +9,7 @@ export const FormDebug = ({ form }: { form: UseFormReturn }) => {
const currentValues = watch();
return (
<div className='mt-6 p-4 border rounded bg-gray-50'>
<div className='p-4 border rounded bg-red-50 mb-6'>
<p>
<strong>¿Formulario modificado?</strong> {isDirty ? "Sí" : "No"}
</p>

View File

@ -53,8 +53,8 @@ export const CustomerCreate = () => {
<>
<AppBreadcrumb />
<AppContent>
<div className='flex items-center justify-between space-y-2'>
<div>
<div className='flex items-center justify-between space-y-4'>
<div className='space-y-2'>
<h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'>
{t("pages.create.title")}
</h2>

View File

@ -7,17 +7,19 @@ import {
CardHeader,
CardTitle,
} from "@repo/shadcn-ui/components";
import { useForm } from "react-hook-form";
import { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../constants";
import { useTranslation } from "../../i18n";
export function CustomerAdditionalConfigFields({ control }: { control: any }) {
export const CustomerAdditionalConfigFields = () => {
const { t } = useTranslation();
const { control } = useForm();
return (
<Card className='shadow-none'>
<Card className='border-0 shadow-none bg-sidebar'>
<CardHeader>
<CardTitle>{t("form_groups.additional_config.title")}</CardTitle>
<CardDescription>{t("form_groups.additional_config.description")}</CardDescription>
<CardTitle>{t("form_groups.preferences.title")}</CardTitle>
<CardDescription>{t("form_groups.preferences.description")}</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'>
<TaxesMultiSelectField
@ -57,4 +59,4 @@ export function CustomerAdditionalConfigFields({ control }: { control: any }) {
</CardContent>
</Card>
);
}
};

View File

@ -1,3 +1,4 @@
import { TaxesMultiSelectField } from "@erp/core/components";
import { TextField } from "@repo/rdx-ui/components";
import {
Card,
@ -15,11 +16,183 @@ import {
} from "@repo/shadcn-ui/components";
import { useTranslation } from "../../i18n";
export function CustomerBasicInfoFields({ control }: { control: any }) {
export const CustomerBasicInfoFields = ({ control }: { control: any }) => {
const { t } = useTranslation();
return (
<Card className='shadow-none'>
<Card>
<CardHeader>
<CardTitle>Identificación</CardTitle>
</CardHeader>
<CardContent>
<div className='grid grid-cols-1 gap-6 md:grid-cols-4'>
<div className='sm:col-span-full'>
<FormField
control={control}
name='is_company'
render={({ field }) => (
<FormItem className='space-y-3'>
<FormLabel>{t("form_fields.customer_type.label")}</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value ? "1" : "0"}
className='flex gap-6'
>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem value='1' />
</FormControl>
<FormLabel className='font-normal'>
{t("form_fields.customer_type.company")}
</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem value='0' />
</FormControl>
<FormLabel className='font-normal'>
{t("form_fields.customer_type.individual")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='sm:col-span-2'>
<TextField
control={control}
name='name'
required
label={t("form_fields.name.label")}
placeholder={t("form_fields.name.placeholder")}
description={t("form_fields.name.description")}
/>
</div>
<div className='sm:col-span-2'>
<TextField
control={control}
name='trade_name'
label={t("form_fields.trade_name.label")}
placeholder={t("form_fields.trade_name.placeholder")}
description={t("form_fields.trade_name.description")}
/>
</div>
<div className='sm:col-span-2'>
<TaxesMultiSelectField
control={control}
name='default_taxes'
required
label={t("form_fields.default_taxes.label")}
placeholder={t("form_fields.default_taxes.placeholder")}
description={t("form_fields.default_taxes.description")}
/>
</div>
<div className='col-auto'>
<TextField
control={control}
name='reference'
label={t("form_fields.reference.label")}
placeholder={t("form_fields.reference.placeholder")}
description={t("form_fields.reference.description")}
/>
</div>
</div>
</CardContent>
</Card>
);
return (
<div className='space-y-12'>
<div className='border-b border-gray-900/10 pb-12 dark:border-white/10'>
<h2 className='text-base/7 font-semibold text-gray-900 dark:text-white'>
{t("form_groups.basic_info.title")}
</h2>
<p className='mt-1 text-sm/6 text-gray-600 dark:text-gray-400'>
{t("form_groups.basic_info.description")}
</p>
<div className='mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6'>
<div className='sm:col-span-6'>
<FormField
control={control}
name='is_company'
render={({ field }) => (
<FormItem className='space-y-3'>
<FormLabel>{t("form_fields.customer_type.label")}</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value ? "1" : "0"}
className='flex gap-6'
>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem value='1' />
</FormControl>
<FormLabel className='font-normal'>
{t("form_fields.customer_type.company")}
</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem value='0' />
</FormControl>
<FormLabel className='font-normal'>
{t("form_fields.customer_type.individual")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='sm:col-span-2'>
<TextField
control={control}
name='name'
required
label={t("form_fields.name.label")}
placeholder={t("form_fields.name.placeholder")}
description={t("form_fields.name.description")}
/>
</div>
<div className='sm:col-span-2'>
<TextField
control={control}
name='trade_name'
label={t("form_fields.trade_name.label")}
placeholder={t("form_fields.trade_name.placeholder")}
description={t("form_fields.trade_name.description")}
/>
</div>
<div className='col-span-full'>
<TextField
control={control}
name='reference'
label={t("form_fields.reference.label")}
placeholder={t("form_fields.reference.placeholder")}
description={t("form_fields.reference.description")}
/>
</div>
</div>
</div>
</div>
);
return (
<Card className='shadow-sm bg-gray-50/50'>
<CardHeader>
<CardTitle>{t("form_groups.basic_info.title")}</CardTitle>
<CardDescription>{t("form_groups.basic_info.description")}</CardDescription>
@ -85,4 +258,4 @@ export function CustomerBasicInfoFields({ control }: { control: any }) {
</CardContent>
</Card>
);
}
};

View File

@ -1,15 +1,28 @@
import { TextField } from "@repo/rdx-ui/components";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@repo/shadcn-ui/components";
import { TextField } from "@repo/rdx-ui/components";
import { Input } from "@repo/shadcn-ui/components";
import { ChevronDown, Phone } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "../../i18n";
export function CustomerContactFields({ control }: { control: any }) {
const { t } = useTranslation();
const [open, setOpen] = useState(true);
return (
<Card className='shadow-none'>
@ -18,6 +31,63 @@ export function CustomerContactFields({ control }: { control: any }) {
<CardDescription>{t("form_groups.contact_info.description")}</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'>
<Collapsible open={open} onOpenChange={setOpen} className='space-y-4'>
<CollapsibleTrigger className='inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline'>
Más detalles{" "}
<ChevronDown className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`} />
</CollapsibleTrigger>
<CollapsibleContent>
<div className='grid grid-cols-1 gap-6 md:grid-cols-2'>
<FormField
control={control}
name='phone2'
render={({ field }) => (
<FormItem>
<FormLabel>Teléfono secundario</FormLabel>
<FormControl>
<Input
icon={<Phone className='h-4 w-4 text-muted-foreground' />}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name='mobile2'
render={({ field }) => (
<FormItem>
<FormLabel>Móvil secundario</FormLabel>
<FormControl>
<Input
placeholder='+34 600 00 000'
icon={<Phone className='h-4 w-4 text-muted-foreground' />}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name='fax'
render={({ field }) => (
<FormItem className='md:col-span-2'>
<FormLabel>Fax</FormLabel>
<FormControl>
<Input placeholder='' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<TextField
control={control}
name='email_primary'

View File

@ -1,3 +1,12 @@
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@repo/shadcn-ui/components";
import { zodResolver } from "@hookform/resolvers/zod";
import { FieldErrors, useForm } from "react-hook-form";
@ -13,21 +22,13 @@ import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
import { CustomerContactFields } from "./customer-contact-fields";
interface CustomerFormProps {
formId: string;
defaultValues: CustomerData; // ✅ ya no recibe DTO
isPending?: boolean;
onSubmit: (data: CustomerData) => void;
onSubmit: (data: CustomerUpdateData) => void;
onError: (errors: FieldErrors<CustomerUpdateData>) => void;
errorMessage?: string; // ✅ prop nueva para mostrar error global
}
export const CustomerEditForm = ({
formId,
defaultValues,
onSubmit,
isPending,
errorMessage,
}: CustomerFormProps) => {
export const CustomerEditForm = ({ defaultValues, onSubmit, isPending }: CustomerFormProps) => {
const { t } = useTranslation();
const form = useForm<CustomerUpdateData>({
@ -36,14 +37,85 @@ export const CustomerEditForm = ({
disabled: isPending,
});
const {
watch,
formState: { isDirty, dirtyFields },
} = form;
useUnsavedChangesNotifier({
isDirty: form.formState.isDirty,
isDirty,
});
const currentValues = watch();
const handleSubmit = (data: CustomerUpdateData) => {
console.log("Datos del formulario:", data);
const changedData: Record<string, string> = {};
Object.keys(dirtyFields).forEach((field) => {
const value = String(currentValues[field as keyof CustomerUpdateData]);
changedData[field] = value;
});
console.log(changedData);
onSubmit(changedData);
};
const handleError = (errors: FieldErrors<CustomerUpdateData>) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
const handleCancel = () => {
form.reset(defaultValues);
};
return (
<Form {...form}>
<FormDebug form={form} />
<form id={formId} onSubmit={form.handleSubmit(onSubmit, oError)}>
<form onSubmit={form.handleSubmit(handleSubmit, handleError)}>
<div className='flex gap-6'>
<div className='w-full xl:w-2/3 space-y-12'>
<CustomerBasicInfoFields control={form.control} />
<CustomerContactFields control={form.control} />
</div>
</div>
</form>
</Form>
);
return (
<Form {...form}>
<FormDebug form={form} />
<form id={formId} onSubmit={form.handleSubmit(handleSubmit, handleError)}>
<div className='grid grid-cols-3 gap-12'>
<div className='col-span-2'>
<Card className='border-0 shadow-none bg-background'>
<CardHeader className='px-0'>
<CardTitle>{t("form_groups.basic_info.title")}</CardTitle>
<CardDescription>{t("form_groups.basic_info.description")}</CardDescription>
</CardHeader>
<CardContent className='px-0'>
<CustomerBasicInfoFields />
</CardContent>
<CardFooter>
<p>&nbsp;</p>
</CardFooter>
</Card>
</div>
<div>
<CustomerAdditionalConfigFields />
</div>
</div>
</form>
</Form>
);
return (
<Form {...form}>
<FormDebug form={form} />
<form id={formId} onSubmit={form.handleSubmit(handleSubmit, handleError)}>
<div className='w-full grid grid-cols-1 space-y-8 space-x-8 xl:grid-cols-2'>
<CustomerBasicInfoFields control={form.control} />
<CustomerAddressFields control={form.control} />

View File

@ -94,8 +94,8 @@ export const CustomerUpdate = () => {
<>
<AppBreadcrumb />
<AppContent>
<div className='flex items-center justify-between space-y-2'>
<div>
<div className='flex items-center justify-between space-y-4'>
<div className='space-y-2'>
<h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'>
{t("pages.update.title")}
</h2>
@ -132,11 +132,10 @@ export const CustomerUpdate = () => {
<div className='flex flex-1 flex-col gap-4 p-4'>
{/* Importante: proveemos un formId para que el botón del header pueda hacer submit */}
<CustomerEditForm
formId='customer-edit-form'
defaultValues={defaultValues}
defaultValues={customerData}
onSubmit={handleSubmit}
onError={handleError}
isPending={isUpdating}
errorMessage={isUpdateError ? getErrorMessage(updateError) : undefined}
/>
</div>
</AppContent>

View File

@ -71,6 +71,17 @@ export class UtcDate extends ValueObject<UtcDateProps> {
return this.date.toISOString();
}
/**
* Devuelve la fecha en formato dd/mm/yyyy (formato europeo).
*/
toEuropeanString(): string {
const day = String(this.date.getUTCDate()).padStart(2, "0");
const month = String(this.date.getUTCMonth() + 1).padStart(2, "0"); // Los meses en JS empiezan en 0
const year = this.date.getUTCFullYear();
return `${day}/${month}/${year}`;
}
/**
* Compara si dos instancias de UtcDate son iguales.
*/

View File

@ -53,7 +53,12 @@ export function TextField<TFormValues extends FieldValues>({
</div>
)}
<FormControl>
<Input disabled={isDisabled} placeholder={placeholder} {...field} />
<Input
disabled={isDisabled}
placeholder={placeholder}
{...field}
className='placeholder:font-normal placeholder:italic'
/>
</FormControl>
<p className={cn("text-xs text-muted-foreground", !description && "invisible")}>

View File

@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot='input'
className={cn(
"bg-background text-foreground",
"bg-input text-foreground",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",

View File

@ -50,9 +50,9 @@
}
@theme inline {
/*--font-sans: Geist, sans-serif;
--font-serif: Merriweather, serif;
--font-mono: "Geist Mono", monospace;*/
--font-sans: Roboto, sans-serif;
--font-serif: Domine, serif;
--font-mono: "Roboto Mono", monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
@ -92,10 +92,10 @@
}
:root {
--radius: 0.5rem;
--radius: 0.3rem;
--background: oklch(1.0 0.0 0);
--foreground: oklch(0.143 0.003 271.9282674829111);
--card: oklch(1.0 0.0 0);
--card: oklch(0.977 0.007 272.5840410480741);
--card-foreground: oklch(0.143 0.003 271.9282674829111);
--popover: oklch(1.0 0.0 0);
--popover-foreground: oklch(0.143 0.003 271.9282674829111);
@ -165,16 +165,13 @@
@apply border-border outline-ring/50;
@apply transition-colors duration-300; /* Added transition for smooth color changes */
}
body {
@apply bg-background text-foreground;
}
input {
@apply font-semibold;
}
label {
@apply font-light;
@apply bg-input;
}
}