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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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> <title>FactuGES 2025</title>
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />

View File

@ -42,7 +42,6 @@ export const createAxiosInstance = ({
}: AxiosFactoryConfig): AxiosInstance => { }: AxiosFactoryConfig): AxiosInstance => {
const instance = axios.create(defaultAxiosRequestConfig); const instance = axios.create(defaultAxiosRequestConfig);
instance.defaults.baseURL = baseURL; 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. * 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 getAccessToken - Función que devuelve el token JWT actual.
* @param onAuthError - Función opcional que se ejecuta ante errores de autenticación (status 401). * @param onAuthError - Función opcional que se ejecuta ante errores de autenticación (status 401).
*/ */
export const setupInterceptors = ( export const setupInterceptors = (
instance: AxiosInstance, axiosInstance: AxiosInstance,
getAccessToken: () => string | null, getAccessToken: () => string | null,
onAuthError?: () => void onAuthError?: () => void
): void => { ) => {
instance.interceptors.request.use( axiosInstance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => { (config: InternalAxiosRequestConfig) => {
const token = getAccessToken(); const token = getAccessToken();
if (token && config.headers) { if (token && config.headers) {
@ -25,13 +25,48 @@ export const setupInterceptors = (
} }
); );
instance.interceptors.response.use( axiosInstance.interceptors.response.use(
(response) => response, (response) => response,
(error: AxiosError) => { (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(); 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) { export function formatDateDTO(dateString: string) {
const result = UtcDate.createFromISO(dateString).data; const result = UtcDate.createFromISO(dateString).data;
return result.toDateString(); return result.toEuropeanString();
} }

View File

@ -1,9 +1,18 @@
import { MoneyDTO } from "@erp/core"; import { MoneyDTO } from "@erp/core";
import { MoneyValue } from "@repo/rdx-ddd"; import { MoneyValue } from "@repo/rdx-ddd";
export function formatMoneyDTO(amount: MoneyDTO, locale: string) { export type FormatMoneyOptions = {
if (amount.value === "") { locale: string;
return ""; 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({ const money = MoneyValue.create({
@ -12,5 +21,5 @@ export function formatMoneyDTO(amount: MoneyDTO, locale: string) {
scale: Number(amount.scale), scale: Number(amount.scale),
}).data; }).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"; import { Percentage } from "@repo/rdx-ddd";
export function formatPercentageDTO(Percentage_value: PercentageDTO, locale: string) { export function formatPercentageDTO(Percentage_value: PercentageDTO, locale: string) {
if (Percentage_value.value === "0" || Percentage_value.value === "") {
return null;
}
const value = Percentage.create({ const value = Percentage.create({
value: Number(Percentage_value.value), value: Number(Percentage_value.value),
scale: Number(Percentage_value.scale), scale: Number(Percentage_value.scale),

View File

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

View File

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

View File

@ -1,6 +1,11 @@
import { Presenter } from "@erp/core/api"; import { Presenter } from "@erp/core/api";
import { GetCustomerInvoiceByIdResponseDTO } from "../../../../common/dto"; import { GetCustomerInvoiceByIdResponseDTO } from "../../../../common/dto";
import { formatDateDTO, formatMoneyDTO, formatPercentageDTO } from "../../helpers"; import {
FormatMoneyOptions,
formatDateDTO,
formatMoneyDTO,
formatPercentageDTO,
} from "../../helpers";
export class CustomerInvoiceReportPresenter extends Presenter< export class CustomerInvoiceReportPresenter extends Presenter<
GetCustomerInvoiceByIdResponseDTO, GetCustomerInvoiceByIdResponseDTO,
@ -18,17 +23,23 @@ export class CustomerInvoiceReportPresenter extends Presenter<
locale, locale,
}); });
const moneyOptions: FormatMoneyOptions = {
locale,
hideZeros: true,
newScale: 2,
};
return { return {
...invoiceDTO, ...invoiceDTO,
items: itemsDTO, items: itemsDTO,
invoice_date: formatDateDTO(invoiceDTO.invoice_date), invoice_date: formatDateDTO(invoiceDTO.invoice_date),
subtotal_amount: formatMoneyDTO(invoiceDTO.subtotal_amount, locale), subtotal_amount: formatMoneyDTO(invoiceDTO.subtotal_amount, moneyOptions),
discount_percetage: formatPercentageDTO(invoiceDTO.discount_percentage, locale), discount_percentage: formatPercentageDTO(invoiceDTO.discount_percentage, locale),
discount_amount: formatMoneyDTO(invoiceDTO.discount_amount, locale), discount_amount: formatMoneyDTO(invoiceDTO.discount_amount, moneyOptions),
taxable_amount: formatMoneyDTO(invoiceDTO.taxable_amount, locale), taxable_amount: formatMoneyDTO(invoiceDTO.taxable_amount, moneyOptions),
taxes_amount: formatMoneyDTO(invoiceDTO.taxes_amount, locale), taxes_amount: formatMoneyDTO(invoiceDTO.taxes_amount, moneyOptions),
total_amount: formatMoneyDTO(invoiceDTO.total_amount, locale), 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 page.setContent(htmlData, { waitUntil: "networkidle2" });
await navigationPromise; await navigationPromise;
const reportPDF = await report.pdfPage(page, { const reportPDF = await report.pdfPage(page, {
format: "A4", format: "A4",
margin: { margin: {
@ -48,6 +49,10 @@ export class CustomerInvoiceReportPDFPresenter extends Presenter<
preferCSSPageSize: true, preferCSSPageSize: true,
omitBackground: false, omitBackground: false,
printBackground: true, 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(); await browser.close();

View File

@ -55,14 +55,14 @@
table th, table th,
table td { table td {
border: 0px solid #ccc; border: 0px solid;
padding: 3px 10px; padding: 3px 10px;
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
} }
table th { table th {
background-color: #f5f5f5; margin-bottom: 10px;
} }
.totals { .totals {
@ -81,7 +81,7 @@
footer { footer {
margin-top: 40px; margin-top: 40px;
font-size: 12px; font-size: 10px;
} }
.highlight { .highlight {
@ -119,7 +119,7 @@
<div class="p-1 "> <div class="p-1 ">
<p>Factura nº:<strong>&nbsp;{{invoice_number}}</strong></p> <p>Factura nº:<strong>&nbsp;{{invoice_number}}</strong></p>
<p><span>Fecha:<strong>&nbsp;{{invoice_date}}</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>
<div class="p-1 ml-9"> <div class="p-1 ml-9">
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2> <h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
@ -174,9 +174,10 @@
{{#each items}} {{#each items}}
<tr> <tr>
<td>{{description}}</td> <td>{{description}}</td>
<td class="text-right">{{quantity}}</td> <td class="text-right">{{#if quantity}}{{quantity}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{unit_amount}}</td> <td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{total_amount}}</td> <td class="text-right">{{#if total_amount}}{{total_amount}}{{else}}&nbsp;{{/if}}</td>
</td>
</tr> </tr>
{{/each}} {{/each}}
</tbody> </tbody>
@ -197,14 +198,14 @@
<div class="relative pt-10 grow"> <div class="relative pt-10 grow">
<table class="table-header min-w-full bg-transparent"> <table class="table-header min-w-full bg-transparent">
<tbody> <tbody>
{{#if percentage}} {{#if discount_percentage}}
<tr> <tr>
<td class="px-4 text-right">Importe&nbsp;neto</td> <td class="px-4 text-right">Importe&nbsp;neto</td>
<td class="w-5">&nbsp;</td> <td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{subtotal_amount}}</td> <td class="px-4 text-right">{{subtotal_amount}}</td>
</tr> </tr>
<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="w-5">&nbsp;</td>
<td class="px-4 text-right">{{discount_amount.value}}</td> <td class="px-4 text-right">{{discount_amount.value}}</td>
</tr> </tr>
@ -237,7 +238,8 @@
<footer id="footer" class="mt-4"> <footer id="footer" class="mt-4">
<aside> <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> Rodax Software S.L.</p>
</aside> </aside>
</footer> </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 { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { UpdateCustomerRequestDTO } from "../../../common"; import { UpdateCustomerByIdRequestDTO } from "../../../../common/dto";
import { CustomerPatchProps, CustomerService } from "../../domain"; import { CustomerPatchProps, CustomerService } from "../../../domain";
import { UpdateCustomerAssembler } from "./assembler"; import { CustomerFullPresenter } from "../../presenters";
import { mapDTOToUpdateCustomerPatchProps } from "./map-dto-to-update-customer-props"; import { mapDTOToUpdateCustomerPatchProps } from "./map-dto-to-update-customer-props";
type UpdateCustomerUseCaseInput = { type UpdateCustomerUseCaseInput = {
companyId: UniqueID; companyId: UniqueID;
customer_id: string; customer_id: string;
dto: UpdateCustomerRequestDTO; dto: UpdateCustomerByIdRequestDTO;
}; };
export class UpdateCustomerUseCase { export class UpdateCustomerUseCase {
constructor( constructor(
private readonly service: CustomerService, private readonly service: CustomerService,
private readonly transactionManager: ITransactionManager, private readonly transactionManager: ITransactionManager,
private readonly assembler: UpdateCustomerAssembler private readonly presenterRegistry: IPresenterRegistry
) {} ) {}
public execute(params: UpdateCustomerUseCaseInput) { public execute(params: UpdateCustomerUseCaseInput) {
@ -28,6 +28,10 @@ export class UpdateCustomerUseCase {
} }
const customerId = idOrError.data; const customerId = idOrError.data;
const presenter = this.presenterRegistry.getPresenter({
resource: "customer",
projection: "FULL",
}) as CustomerFullPresenter;
// Mapear DTO → props de dominio // Mapear DTO → props de dominio
const patchPropsResult = mapDTOToUpdateCustomerPatchProps(dto); const patchPropsResult = mapDTOToUpdateCustomerPatchProps(dto);
@ -50,10 +54,10 @@ export class UpdateCustomerUseCase {
return Result.fail(updatedCustomer.error); return Result.fail(updatedCustomer.error);
} }
const savedCustomer = await this.service.saveCustomer(updatedCustomer.data, transaction); const customerOrError = await this.service.saveCustomer(updatedCustomer.data, transaction);
const customer = customerOrError.data;
const getDTO = this.assembler.toDTO(savedCustomer.data); const dto = presenter.toOutput(customer);
return Result.ok(getDTO); return Result.ok(dto);
} catch (error: unknown) { } catch (error: unknown) {
return Result.fail(error as Error); return Result.fail(error as Error);
} }

View File

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

View File

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

View File

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

View File

@ -31,4 +31,4 @@ export const UpdateCustomerByIdRequestSchema = z.object({
currency_code: z.string().optional(), 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", "title": "New customer",
"description": "Create a new customer", "description": "Create a new customer",
"back_to_list": "Back to the list" "back_to_list": "Back to the list"
},
"update": {
"title": "Update customer",
"description": "Update a customer",
"back_to_list": "Back to the list"
} }
}, },
"form_fields": { "form_fields": {
@ -117,10 +122,10 @@
"placeholder": "Enter website URL", "placeholder": "Enter website URL",
"description": "The website of the customer" "description": "The website of the customer"
}, },
"default_tax": { "default_taxes": {
"label": "Default tax", "label": "Default taxes",
"placeholder": "Select default tax", "placeholder": "Select default taxes",
"description": "The default tax rate for the customer" "description": "The default tax rates for the customer"
}, },
"language_code": { "language_code": {
"label": "Language", "label": "Language",
@ -151,8 +156,8 @@
"title": "Contact information", "title": "Contact information",
"description": "Customer contact details" "description": "Customer contact details"
}, },
"additional_config": { "preferences": {
"title": "Additional settings", "title": "Preferences",
"description": "Additional customer configurations" "description": "Additional customer configurations"
} }
}, },

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { TaxesMultiSelectField } from "@erp/core/components";
import { TextField } from "@repo/rdx-ui/components"; import { TextField } from "@repo/rdx-ui/components";
import { import {
Card, Card,
@ -15,11 +16,183 @@ import {
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
export function CustomerBasicInfoFields({ control }: { control: any }) { export const CustomerBasicInfoFields = ({ control }: { control: any }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( 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> <CardHeader>
<CardTitle>{t("form_groups.basic_info.title")}</CardTitle> <CardTitle>{t("form_groups.basic_info.title")}</CardTitle>
<CardDescription>{t("form_groups.basic_info.description")}</CardDescription> <CardDescription>{t("form_groups.basic_info.description")}</CardDescription>
@ -85,4 +258,4 @@ export function CustomerBasicInfoFields({ control }: { control: any }) {
</CardContent> </CardContent>
</Card> </Card>
); );
} };

View File

@ -1,15 +1,28 @@
import { TextField } from "@repo/rdx-ui/components";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@repo/shadcn-ui/components"; } 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"; import { useTranslation } from "../../i18n";
export function CustomerContactFields({ control }: { control: any }) { export function CustomerContactFields({ control }: { control: any }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(true);
return ( return (
<Card className='shadow-none'> <Card className='shadow-none'>
@ -18,6 +31,63 @@ export function CustomerContactFields({ control }: { control: any }) {
<CardDescription>{t("form_groups.contact_info.description")}</CardDescription> <CardDescription>{t("form_groups.contact_info.description")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'> <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 <TextField
control={control} control={control}
name='email_primary' 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 { zodResolver } from "@hookform/resolvers/zod";
import { FieldErrors, useForm } from "react-hook-form"; import { FieldErrors, useForm } from "react-hook-form";
@ -13,21 +22,13 @@ import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
import { CustomerContactFields } from "./customer-contact-fields"; import { CustomerContactFields } from "./customer-contact-fields";
interface CustomerFormProps { interface CustomerFormProps {
formId: string;
defaultValues: CustomerData; // ✅ ya no recibe DTO defaultValues: CustomerData; // ✅ ya no recibe DTO
isPending?: boolean; isPending?: boolean;
onSubmit: (data: CustomerData) => void; onSubmit: (data: CustomerUpdateData) => void;
onError: (errors: FieldErrors<CustomerUpdateData>) => void; onError: (errors: FieldErrors<CustomerUpdateData>) => void;
errorMessage?: string; // ✅ prop nueva para mostrar error global
} }
export const CustomerEditForm = ({ export const CustomerEditForm = ({ defaultValues, onSubmit, isPending }: CustomerFormProps) => {
formId,
defaultValues,
onSubmit,
isPending,
errorMessage,
}: CustomerFormProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const form = useForm<CustomerUpdateData>({ const form = useForm<CustomerUpdateData>({
@ -36,14 +37,85 @@ export const CustomerEditForm = ({
disabled: isPending, disabled: isPending,
}); });
const {
watch,
formState: { isDirty, dirtyFields },
} = form;
useUnsavedChangesNotifier({ 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 ( return (
<Form {...form}> <Form {...form}>
<FormDebug 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'> <div className='w-full grid grid-cols-1 space-y-8 space-x-8 xl:grid-cols-2'>
<CustomerBasicInfoFields control={form.control} /> <CustomerBasicInfoFields control={form.control} />
<CustomerAddressFields control={form.control} /> <CustomerAddressFields control={form.control} />

View File

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

View File

@ -71,6 +71,17 @@ export class UtcDate extends ValueObject<UtcDateProps> {
return this.date.toISOString(); 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. * Compara si dos instancias de UtcDate son iguales.
*/ */

View File

@ -53,7 +53,12 @@ export function TextField<TFormValues extends FieldValues>({
</div> </div>
)} )}
<FormControl> <FormControl>
<Input disabled={isDisabled} placeholder={placeholder} {...field} /> <Input
disabled={isDisabled}
placeholder={placeholder}
{...field}
className='placeholder:font-normal placeholder:italic'
/>
</FormControl> </FormControl>
<p className={cn("text-xs text-muted-foreground", !description && "invisible")}> <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} type={type}
data-slot='input' data-slot='input'
className={cn( 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", "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]", "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", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",

View File

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