Clientes y facturas de cliente

This commit is contained in:
David Arranz 2025-09-17 19:37:41 +02:00
parent 4d3430cc91
commit 096abdccb2
31 changed files with 573 additions and 370 deletions

View File

@ -62,7 +62,7 @@ export const App = () => {
</Suspense> </Suspense>
</UnsavedWarnProvider> </UnsavedWarnProvider>
</TooltipProvider> </TooltipProvider>
<Toaster /> <Toaster position='top-right' />
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />} {import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</AuthProvider> </AuthProvider>
</DataSourceProvider> </DataSourceProvider>

View File

@ -17,8 +17,8 @@ export interface IDataSource {
getList<T, R>(resource: string, params?: Record<string, unknown>): Promise<R>; getList<T, R>(resource: string, params?: Record<string, unknown>): Promise<R>;
getOne<T>(resource: string, id: string | number): Promise<T>; getOne<T>(resource: string, id: string | number): Promise<T>;
getMany<T, R>(resource: string, ids: Array<string | number>): Promise<R>; getMany<T, R>(resource: string, ids: Array<string | number>): Promise<R>;
createOne<T>(resource: string, data: Partial<T>): Promise<T>; createOne<T, R>(resource: string, data: Partial<T>): Promise<R>;
updateOne<T>(resource: string, id: string | number, data: Partial<T>): Promise<T>; updateOne<T, R>(resource: string, id: string | number, data: Partial<T>): Promise<R>;
deleteOne<T>(resource: string, id: string | number): Promise<void>; deleteOne<T>(resource: string, id: string | number): Promise<void>;
custom: <R>(customParams: ICustomParams) => Promise<R>; custom: <R>(customParams: ICustomParams) => Promise<R>;

View File

@ -30,8 +30,6 @@ export class CustomerInvoiceItemsFullPresenter extends Presenter {
() => ({ value: "", scale: "", currency_code: "" }) () => ({ value: "", scale: "", currency_code: "" })
), ),
taxes: invoiceItem.taxes.getCodesToString(),
subtotal_amount: allAmounts.subtotalAmount.toObjectString(), subtotal_amount: allAmounts.subtotalAmount.toObjectString(),
discount_percentage: invoiceItem.discountPercentage.match( discount_percentage: invoiceItem.discountPercentage.match(
@ -40,8 +38,11 @@ export class CustomerInvoiceItemsFullPresenter extends Presenter {
), ),
discount_amount: allAmounts.discountAmount.toObjectString(), discount_amount: allAmounts.discountAmount.toObjectString(),
taxable_amount: allAmounts.taxableAmount.toObjectString(), taxable_amount: allAmounts.taxableAmount.toObjectString(),
taxes: invoiceItem.taxes.getCodesToString(),
taxes_amount: allAmounts.taxesAmount.toObjectString(), taxes_amount: allAmounts.taxesAmount.toObjectString(),
total_amount: allAmounts.totalAmount.toObjectString(), total_amount: allAmounts.totalAmount.toObjectString(),
}; };
} }

View File

@ -3,6 +3,7 @@ import { toEmptyString } from "@repo/rdx-ddd";
import { GetCustomerInvoiceByIdResponseDTO } from "../../../../common/dto"; import { GetCustomerInvoiceByIdResponseDTO } from "../../../../common/dto";
import { CustomerInvoice } from "../../../domain"; import { CustomerInvoice } from "../../../domain";
import { CustomerInvoiceItemsFullPresenter } from "./customer-invoice-items.full.presenter"; import { CustomerInvoiceItemsFullPresenter } from "./customer-invoice-items.full.presenter";
import { RecipientInvoiceFullPresenter } from "./recipient-invoice.full.representer";
export class CustomerInvoiceFullPresenter extends Presenter< export class CustomerInvoiceFullPresenter extends Presenter<
CustomerInvoice, CustomerInvoice,
@ -14,6 +15,12 @@ export class CustomerInvoiceFullPresenter extends Presenter<
projection: "FULL", projection: "FULL",
}) as CustomerInvoiceItemsFullPresenter; }) as CustomerInvoiceItemsFullPresenter;
const recipientPresenter = this.presenterRegistry.getPresenter({
resource: "recipient-invoice",
projection: "FULL",
}) as RecipientInvoiceFullPresenter;
const recipient = recipientPresenter.toOutput(invoice);
const items = itemsPresenter.toOutput(invoice.items); const items = itemsPresenter.toOutput(invoice.items);
const allAmounts = invoice.getAllAmounts(); const allAmounts = invoice.getAllAmounts();
@ -33,6 +40,9 @@ export class CustomerInvoiceFullPresenter extends Presenter<
language_code: invoice.languageCode.toString(), language_code: invoice.languageCode.toString(),
currency_code: invoice.currencyCode.toString(), currency_code: invoice.currencyCode.toString(),
customer_id: invoice.customerId.toString(),
recipient,
taxes: invoice.taxes.getCodesToString(), taxes: invoice.taxes.getCodesToString(),
subtotal_amount: allAmounts.subtotalAmount.toObjectString(), subtotal_amount: allAmounts.subtotalAmount.toObjectString(),

View File

@ -1,2 +1,3 @@
export * from "./customer-invoice-items.full.presenter"; export * from "./customer-invoice-items.full.presenter";
export * from "./customer-invoice.full.presenter"; export * from "./customer-invoice.full.presenter";
export * from "./recipient-invoice.full.representer";

View File

@ -0,0 +1,45 @@
import { Presenter } from "@erp/core/api";
import { DomainValidationError, toEmptyString } from "@repo/rdx-ddd";
import { GetCustomerInvoiceByIdResponseDTO } from "../../../../common/dto";
import { CustomerInvoice, InvoiceRecipient } from "../../../domain";
type GetRecipientInvoiceByInvoiceIdResponseDTO = GetCustomerInvoiceByIdResponseDTO["recipient"];
export class RecipientInvoiceFullPresenter extends Presenter {
toOutput(invoice: CustomerInvoice): GetRecipientInvoiceByInvoiceIdResponseDTO {
if (!invoice.recipient) {
throw DomainValidationError.requiredValue("recipient", {
cause: invoice,
});
}
return invoice.recipient.match(
(recipient: InvoiceRecipient) => {
return {
id: invoice.customerId.toString(),
name: recipient.name.toString(),
tin: recipient.tin.toString(),
street: toEmptyString(recipient.street, (value) => value.toString()),
street2: toEmptyString(recipient.street2, (value) => value.toString()),
city: toEmptyString(recipient.city, (value) => value.toString()),
province: toEmptyString(recipient.province, (value) => value.toString()),
postal_code: toEmptyString(recipient.postalCode, (value) => value.toString()),
country: toEmptyString(recipient.country, (value) => value.toString()),
};
},
() => {
return {
id: "",
name: "",
tin: "",
street: "",
street2: "",
city: "",
province: "",
postal_code: "",
country: "",
};
}
);
}
}

View File

@ -45,12 +45,13 @@
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin-top: 25px; margin-top: 0px;
margin-bottom: 15px;
} }
table th, table th,
table td { table td {
border: 1px solid #ccc; border: 0px solid #ccc;
padding: 10px; padding: 10px;
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
@ -98,22 +99,21 @@
<body> <body>
<header> <header>
<aside class="flex items-start mb-4 w-full"> <aside class="flex items-start mb-4 w-full">
<!-- Bloque IZQUIERDO: imagen arriba + texto abajo, alineado a la izquierda --> <!-- Bloque IZQUIERDO: imagen arriba + texto abajo, alineado a la izquierda -->
<div class="w-[70%] flex flex-col items-start text-left"> <div class="w-[70%] flex flex-col items-start text-left">
<img src="https://rodax-software.com/images/logo1.jpg" alt="Logo Rodax" class="block h-14 w-auto mb-1" /> <img src="https://rodax-software.com/images/logo1.jpg" alt="Logo Rodax" class="block h-14 w-auto mb-1" />
<div class="flex w-full"> <div class="flex w-full">
<div class="p-1 "> <div class="p-1 ">
<p><span>Factura nº:</span>xxxxxxxx</p> <p>Factura nº:<strong>&nbsp;{{invoice_number}}</strong></p>
<p><span>Fecha:</span>12/12/2024</p> <p><span>Fecha:<strong>&nbsp;{{operation_date}}</strong></p>
<p><span>Página:</span>1 / 1</p>
</div> </div>
<div class="p-1 ml-9"> <div class="p-1 ml-9">
<h2 class="font-semibold uppercase mb-1">{{customer.name}}</h2> <h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
<p>AAAA</p> <p>{{recipient.tin}}</p>
<p>BBBBBBsdfsfsdf sfsdf sf sdfs fsdfsd fsdf sdfsd fds </p> <p>{{recipient.street}}</p>
<p>CCCCC</p> <p>{{recipient.postal_code}}&nbsp;&nbsp;{{recipient.city}}&nbsp;&nbsp;{{recipient.province}}</p>
<p>DDDDD</p>
</div> </div>
</div> </div>
</div> </div>
@ -130,166 +130,90 @@
</div> </div>
</div> </div>
</aside> </aside>
</header> </header>
<div class="relative bg-blue-400"> <main id="main">
<!-- Badge TOTAL superpuesto --> <section id="details">
<div class="absolute -top-7 right-0">
<div class="relative bg-[#f08119] text-white text-sm font-semibold px-3 py-1 shadow"> <div class="relative pt-0 border-b border-black">
TOTAL: 960,56 € <!-- Badge TOTAL superpuesto -->
<!-- Triángulo izquierdo --> <div class="absolute -top-7 right-0">
<span aria-hidden="true" class="absolute -left-3 top-0 bottom-0 my-auto h-0 w-0 <div class="relative bg-[#f08119] text-white text-sm font-semibold px-3 py-1 shadow">
border-y-[14px] border-y-transparent TOTAL: {{total_amount.value}}
border-r-[14px] border-r-amber-500"></span> </div>
</div>
</div> </div>
</div>
<!-- Tu tabla --> <!-- Tu tabla -->
<table class="w-full border-t border-black"> <table class="table-header">
<thead> <thead>
<tr class="text-left"> <tr class="text-left">
<th class="py-2">Concepto</th> <th class="py-2">Concepto</th>
<th class="py-2">Cantidad</th> <th class="py-2">Cantidad</th>
<th class="py-2">Precio unidad</th> <th class="py-2">Precio&nbsp;unidad</th>
<th class="py-2">Importe total</th> <th class="py-2">Importe&nbsp;total</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> {{#each items}}
<td>Mantenimiento de sistemas informáticos - Agosto (1 Equipo Servidor, 30 Ordenadores, 2 Impresoras, Disco <tr>
copias de seguridad)</td> <td>{{description}}</td>
<td>1</td> <td class="text-right">{{quantity.value}}</td>
<td>0,14 €</td> <td class="text-right">{{unit_amount.value}} €</td>
<td>0,14 €</td> <td class="text-right">{{total_amount.value}} €</td>
</tr> </tr>
<tr> {{/each}}
<td>Mantenimiento del programa FactuGES</td> </tbody>
<td>1</td> </table>
<td>40,00 €</td> </section>
<td>40,00 €</td>
</tr>
<tr>
<td>Control VPN para portátil Rubén</td>
<td>1</td>
<td>6,00 €</td>
<td>6,00 €</td>
</tr>
<tr>
<td>Control VPN para portátil Míriam</td>
<td>1</td>
<td>6,00 €</td>
<td>6,00 €</td>
</tr>
<tr>
<td>Control VPN para portátil Fernando</td>
<td>1</td>
<td>6,00 €</td>
<td>6,00 €</td>
</tr>
<tr>
<td>Control VPN para portátil Elena</td>
<td>1</td>
<td>6,00 €</td>
<td>6,00 €</td>
</tr>
<tr>
<td>Control VPN para portátil Miguel</td>
<td>1</td>
<td>6,00 €</td>
<td>6,00 €</td>
</tr>
<tr>
<td>Control VPN para portátil Adrian</td>
<td>1</td>
<td>6,00 €</td>
<td>6,00 €</td>
</tr>
<tr>
<td>Control VPN para portátil David Lablanca</td>
<td>1</td>
<td>6,00 €</td>
<td>6,00 €</td>
</tr>
<tr>
<td>Control VPN para portátil Noemí</td>
<td>1</td>
<td>6,00 €</td>
<td>6,00 €</td>
</tr>
<tr>
<td>Control VPN para portátil John</td>
<td>1</td>
<td>6,00 €</td>
<td>6,00 €</td>
</tr>
<tr>
<td>Control VPN para portátil Eva</td>
<td>1</td>
<td>6,00 €</td>
<td>6,00 €</td>
</tr>
<tr>
<td>Control VPN para portátil Alberto</td>
<td>1</td>
<td>6,00 €</td>
<td>6,00 €</td>
</tr>
<tr>
<td>Mantenimiento mensual copia de seguridad remota y VPN (Uecko Madrid)</td>
<td>1</td>
<td>40,00 €</td>
<td>40,00 €</td>
</tr>
<tr>
<td>50% dto fidelización servicios contratados</td>
<td>-1</td>
<td>20,00 €</td>
<td>-20,00 €</td>
</tr>
<tr>
<td>Mantenimiento de presupuestador web para distribuidores (Agosto)</td>
<td>1</td>
<td>375,00 €</td>
<td>375,00 €</td>
</tr>
<tr>
<td>Informe de compras de artículos (Presupuesto 22/04/25)</td>
<td>1</td>
<td>260,00 €</td>
<td>260,00 €</td>
</tr>
<tr>
<td>Informe presupuestos cliente (Gunni Tentrino) modificación funcionalidad visible. Sin cargo.</td>
<td>1</td>
<td>0,00 €</td>
<td>0,00 €</td>
</tr>
</tbody>
</table>
<table class="totals"> <section id="resume" class="flex items-center justify-between pb-4 mb-4">
<tr>
<td class="label">Base imponible:</td>
<td>761,14 €</td>
</tr>
<tr>
<td class="label">IVA (21%):</td>
<td>159,84 €</td>
</tr>
<tr>
<td class="label"><strong>Total factura:</strong></td>
<td><strong>960,56 €</strong></td>
</tr>
</table>
<footer> <div class="grow">
<div class="pt-4">
<p class="text-sm"><strong>Forma de pago:</strong> {{payment_method}}</p>
</div>
<div class="pt-4">
<p class="text-sm"><strong>Notas:</strong> {{notes}} </p>
</div>
</div>
<div class="grow">
<table class="table-header min-w-full bg-transparent">
<tbody>
<tr>
<td class="px-4 py-2 text-right">Importe&nbsp;neto</td>
<td class="px-4 py-2 text-right">761,14 €</td>
</tr>
<tr>
<td class="px-4 py-2 text-right">Descuento&nbsp;0%</td>
<td class="px-4 py-2 text-right">0</td>
</tr>
<tr>
<td class="px-4 py-2 text-right">Base&nbsp;imponible</td>
<td class="px-4 py-2 text-right">765,14€</td>
</tr>
<tr>
<td class="px-4 py-2 text-right">IVA&nbsp;21%</td>
<td class="px-4 py-2 text-right">159,84 €</td>
</tr>
<tr class="bg-[#f08119] text-white font-semibold">
<td class="px-4 py-2 text-right bg-amber-700">Total&nbsp;factura</td>
<td class="px-4 py-2 text-right">960,56 €</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
<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>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>
<p><strong>Forma de pago:</strong> Domiciliación bancaria</p> </aside>
</footer> </footer>
</body> </body>

View File

@ -1,7 +1,7 @@
import { DomainValidationError } from "@erp/core/api";
import { import {
AggregateRoot, AggregateRoot,
CurrencyCode, CurrencyCode,
DomainValidationError,
LanguageCode, LanguageCode,
Percentage, Percentage,
TextValue, TextValue,
@ -168,8 +168,27 @@ export class CustomerInvoice
return this.recipient.isSome(); return this.recipient.isSome();
} }
private _getDiscountAmount(subtotalAmount: InvoiceAmount): InvoiceAmount {
return subtotalAmount.percentage(this.discountPercentage);
}
private _getTaxableAmount(
subtotalAmount: InvoiceAmount,
discountAmount: InvoiceAmount
): InvoiceAmount {
return subtotalAmount.subtract(discountAmount);
}
private _getTaxesAmount(taxableAmount: InvoiceAmount): InvoiceAmount {
return this._taxes.getTaxesAmount(taxableAmount);
}
private _getTotalAmount(taxableAmount: InvoiceAmount, taxesAmount: InvoiceAmount): InvoiceAmount {
return taxableAmount.add(taxesAmount);
}
public getSubtotalAmount(): InvoiceAmount { public getSubtotalAmount(): InvoiceAmount {
const itemsSubtotal = this.items.getTotalAmount().convertScale(2); const itemsSubtotal = this.items.getSubtotalAmount().convertScale(2);
return InvoiceAmount.create({ return InvoiceAmount.create({
value: itemsSubtotal.value, value: itemsSubtotal.value,
@ -178,11 +197,11 @@ export class CustomerInvoice
} }
public getDiscountAmount(): InvoiceAmount { public getDiscountAmount(): InvoiceAmount {
return this.getSubtotalAmount().percentage(this.discountPercentage) as InvoiceAmount; return this._getDiscountAmount(this.getSubtotalAmount());
} }
public getTaxableAmount(): InvoiceAmount { public getTaxableAmount(): InvoiceAmount {
return this.getSubtotalAmount().subtract(this.getDiscountAmount()) as InvoiceAmount; return this._getTaxableAmount(this.getSubtotalAmount(), this.getDiscountAmount());
} }
public getTaxesAmount(): InvoiceAmount { public getTaxesAmount(): InvoiceAmount {
@ -191,20 +210,24 @@ export class CustomerInvoice
public getTotalAmount(): InvoiceAmount { public getTotalAmount(): InvoiceAmount {
const taxableAmount = this.getTaxableAmount(); const taxableAmount = this.getTaxableAmount();
return taxableAmount.add(this._getTaxesAmount(taxableAmount)) as InvoiceAmount; const taxesAmount = this._getTaxesAmount(taxableAmount);
return this._getTotalAmount(taxableAmount, taxesAmount);
} }
public getAllAmounts() { public getAllAmounts() {
const subtotalAmount = this.getSubtotalAmount();
const discountAmount = this._getDiscountAmount(subtotalAmount);
const taxableAmount = this._getTaxableAmount(subtotalAmount, discountAmount);
const taxesAmount = this._getTaxesAmount(taxableAmount);
const totalAmount = this._getTotalAmount(taxableAmount, taxesAmount);
return { return {
subtotalAmount: this.getSubtotalAmount(), subtotalAmount,
discountAmount: this.getDiscountAmount(), discountAmount,
taxableAmount: this.getTaxableAmount(), taxableAmount,
taxesAmount: this.getTaxesAmount(), taxesAmount,
totalAmount: this.getTotalAmount(), totalAmount,
}; };
} }
private _getTaxesAmount(_taxableAmount: InvoiceAmount): InvoiceAmount {
return this._taxes.getTaxesAmount(_taxableAmount);
}
} }

View File

@ -95,6 +95,26 @@ export class CustomerInvoiceItem
return this.getProps(); return this.getProps();
} }
private _getDiscountAmount(subtotalAmount: ItemAmount): ItemAmount {
const discount = this.discountPercentage.match(
(percentage) => percentage,
() => ItemDiscount.zero()
);
return subtotalAmount.percentage(discount);
}
private _getTaxableAmount(subtotalAmount: ItemAmount, discountAmount: ItemAmount): ItemAmount {
return subtotalAmount.subtract(discountAmount);
}
private _getTaxesAmount(taxableAmount: ItemAmount): ItemAmount {
return this.props.taxes.getTaxesAmount(taxableAmount);
}
private _getTotalAmount(taxableAmount: ItemAmount, taxesAmount: ItemAmount): ItemAmount {
return taxableAmount.add(taxesAmount);
}
public getSubtotalAmount(): ItemAmount { public getSubtotalAmount(): ItemAmount {
const curCode = this.currencyCode.code; const curCode = this.currencyCode.code;
const quantity = this.quantity.match( const quantity = this.quantity.match(
@ -110,15 +130,11 @@ export class CustomerInvoiceItem
} }
public getDiscountAmount(): ItemAmount { public getDiscountAmount(): ItemAmount {
const discount = this.discountPercentage.match( return this._getDiscountAmount(this.getSubtotalAmount());
(percentage) => percentage,
() => ItemDiscount.zero()
);
return this.getSubtotalAmount().percentage(discount);
} }
public getTaxableAmount(): ItemAmount { public getTaxableAmount(): ItemAmount {
return this.getSubtotalAmount().subtract(this.getDiscountAmount()); return this._getTaxableAmount(this.getSubtotalAmount(), this.getDiscountAmount());
} }
public getTaxesAmount(): ItemAmount { public getTaxesAmount(): ItemAmount {
@ -127,20 +143,24 @@ export class CustomerInvoiceItem
public getTotalAmount(): ItemAmount { public getTotalAmount(): ItemAmount {
const taxableAmount = this.getTaxableAmount(); const taxableAmount = this.getTaxableAmount();
return taxableAmount.add(this._getTaxesAmount(taxableAmount)); const taxesAmount = this._getTaxesAmount(taxableAmount);
return this._getTotalAmount(taxableAmount, taxesAmount);
} }
public getAllAmounts() { public getAllAmounts() {
const subtotalAmount = this.getSubtotalAmount();
const discountAmount = this._getDiscountAmount(subtotalAmount);
const taxableAmount = this._getTaxableAmount(subtotalAmount, discountAmount);
const taxesAmount = this._getTaxesAmount(taxableAmount);
const totalAmount = this._getTotalAmount(taxableAmount, taxesAmount);
return { return {
subtotalAmount: this.getSubtotalAmount(), subtotalAmount,
discountAmount: this.getDiscountAmount(), discountAmount,
taxableAmount: this.getTaxableAmount(), taxableAmount,
taxesAmount: this.getTaxesAmount(), taxesAmount,
totalAmount: this.getTotalAmount(), totalAmount,
}; };
} }
private _getTaxesAmount(_taxableAmount: ItemAmount): ItemAmount {
return this.props.taxes.getTaxesAmount(_taxableAmount);
}
} }

View File

@ -36,9 +36,37 @@ export class CustomerInvoiceItems extends Collection<CustomerInvoiceItem> {
return super.add(item); return super.add(item);
} }
public getSubtotalAmount(): ItemAmount {
return this.getAll().reduce(
(total, tax) => total.add(tax.getSubtotalAmount()),
ItemAmount.zero(this._currencyCode.code)
);
}
public getDiscountAmount(): ItemAmount {
return this.getAll().reduce(
(total, item) => total.add(item.getDiscountAmount()),
ItemAmount.zero(this._currencyCode.code)
);
}
public getTaxableAmount(): ItemAmount {
return this.getAll().reduce(
(total, item) => total.add(item.getTaxableAmount()),
ItemAmount.zero(this._currencyCode.code)
);
}
public getTaxesAmount(): ItemAmount {
return this.getAll().reduce(
(total, item) => total.add(item.getTaxesAmount()),
ItemAmount.zero(this._currencyCode.code)
);
}
public getTotalAmount(): ItemAmount { public getTotalAmount(): ItemAmount {
return this.getAll().reduce( return this.getAll().reduce(
(total, tax) => total.add(tax.getTotalAmount()), (total, item) => total.add(item.getTotalAmount()),
ItemAmount.zero(this._currencyCode.code) ItemAmount.zero(this._currencyCode.code)
); );
} }

View File

@ -35,36 +35,60 @@ export class InvoiceAmount extends MoneyValue {
convertScale(newScale: number) { convertScale(newScale: number) {
const mv = super.convertScale(newScale); const mv = super.convertScale(newScale);
const p = mv.toPrimitive(); const p = mv.toPrimitive();
return new InvoiceAmount({ value: p.value, currency_code: p.currency_code }); return new InvoiceAmount({
value: p.value,
currency_code: p.currency_code,
scale: InvoiceAmount.DEFAULT_SCALE,
});
} }
add(addend: MoneyValue) { add(addend: MoneyValue) {
const mv = super.add(addend); const mv = super.add(addend);
const p = mv.toPrimitive(); const p = mv.toPrimitive();
return new InvoiceAmount({ value: p.value, currency_code: p.currency_code }); return new InvoiceAmount({
value: p.value,
currency_code: p.currency_code,
scale: InvoiceAmount.DEFAULT_SCALE,
});
} }
subtract(subtrahend: MoneyValue) { subtract(subtrahend: MoneyValue) {
const mv = super.subtract(subtrahend); const mv = super.subtract(subtrahend);
const p = mv.toPrimitive(); const p = mv.toPrimitive();
return new InvoiceAmount({ value: p.value, currency_code: p.currency_code }); return new InvoiceAmount({
value: p.value,
currency_code: p.currency_code,
scale: InvoiceAmount.DEFAULT_SCALE,
});
} }
multiply(multiplier: number | Quantity) { multiply(multiplier: number | Quantity) {
const mv = super.multiply(multiplier); const mv = super.multiply(multiplier);
const p = mv.toPrimitive(); const p = mv.toPrimitive();
return new InvoiceAmount({ value: p.value, currency_code: p.currency_code }); return new InvoiceAmount({
value: p.value,
currency_code: p.currency_code,
scale: InvoiceAmount.DEFAULT_SCALE,
});
} }
divide(divisor: number | Quantity) { divide(divisor: number | Quantity) {
const mv = super.divide(divisor); const mv = super.divide(divisor);
const p = mv.toPrimitive(); const p = mv.toPrimitive();
return new InvoiceAmount({ value: p.value, currency_code: p.currency_code }); return new InvoiceAmount({
value: p.value,
currency_code: p.currency_code,
scale: InvoiceAmount.DEFAULT_SCALE,
});
} }
percentage(percentage: number | Percentage) { percentage(percentage: number | Percentage) {
const mv = super.percentage(percentage); const mv = super.percentage(percentage);
const p = mv.toPrimitive(); const p = mv.toPrimitive();
return new InvoiceAmount({ value: p.value, currency_code: p.currency_code }); return new InvoiceAmount({
value: p.value,
currency_code: p.currency_code,
scale: InvoiceAmount.DEFAULT_SCALE,
});
} }
} }

View File

@ -35,36 +35,63 @@ export class ItemAmount extends MoneyValue {
convertScale(newScale: number) { convertScale(newScale: number) {
const mv = super.convertScale(newScale); const mv = super.convertScale(newScale);
const p = mv.toPrimitive(); const p = mv.toPrimitive();
return new ItemAmount({ value: p.value, currency_code: p.currency_code }); return new ItemAmount({
value: p.value,
currency_code: p.currency_code,
scale: ItemAmount.DEFAULT_SCALE,
});
} }
add(addend: MoneyValue) { add(addend: MoneyValue) {
const mv = super.add(addend); const mv = super.add(addend);
const p = mv.toPrimitive(); const p = mv.toPrimitive();
return new ItemAmount({ value: p.value, currency_code: p.currency_code }); return new ItemAmount({
value: p.value,
currency_code: p.currency_code,
scale: ItemAmount.DEFAULT_SCALE,
});
} }
subtract(subtrahend: MoneyValue) { subtract(subtrahend: MoneyValue) {
const mv = super.subtract(subtrahend); const mv = super.subtract(subtrahend);
const p = mv.toPrimitive(); const p = mv.toPrimitive();
return new ItemAmount({ value: p.value, currency_code: p.currency_code }); return new ItemAmount({
value: p.value,
currency_code: p.currency_code,
scale: ItemAmount.DEFAULT_SCALE,
});
} }
multiply(multiplier: number | Quantity) { multiply(multiplier: number | Quantity) {
const mv = super.multiply(multiplier); const mv = super.multiply(multiplier);
const p = mv.toPrimitive(); const p = mv.toPrimitive();
return new ItemAmount({ value: p.value, currency_code: p.currency_code });
const result = new ItemAmount({
value: p.value,
currency_code: p.currency_code,
scale: ItemAmount.DEFAULT_SCALE,
});
return result;
} }
divide(divisor: number | Quantity) { divide(divisor: number | Quantity) {
const mv = super.divide(divisor); const mv = super.divide(divisor);
const p = mv.toPrimitive(); const p = mv.toPrimitive();
return new ItemAmount({ value: p.value, currency_code: p.currency_code }); return new ItemAmount({
value: p.value,
currency_code: p.currency_code,
scale: ItemAmount.DEFAULT_SCALE,
});
} }
percentage(percentage: number | Percentage) { percentage(percentage: number | Percentage) {
const mv = super.percentage(percentage); const mv = super.percentage(percentage);
const p = mv.toPrimitive(); const p = mv.toPrimitive();
return new ItemAmount({ value: p.value, currency_code: p.currency_code }); return new ItemAmount({
value: p.value,
currency_code: p.currency_code,
scale: ItemAmount.DEFAULT_SCALE,
});
} }
} }

View File

@ -17,6 +17,7 @@ import {
GetCustomerInvoiceUseCase, GetCustomerInvoiceUseCase,
ListCustomerInvoicesPresenter, ListCustomerInvoicesPresenter,
ListCustomerInvoicesUseCase, ListCustomerInvoicesUseCase,
RecipientInvoiceFullPresenter,
ReportCustomerInvoiceUseCase, ReportCustomerInvoiceUseCase,
} from "../application"; } from "../application";
@ -77,6 +78,13 @@ export function buildCustomerInvoiceDependencies(params: ModuleParams): Customer
}, },
presenter: new CustomerInvoiceItemsFullPresenter(presenterRegistry), presenter: new CustomerInvoiceItemsFullPresenter(presenterRegistry),
}, },
{
key: {
resource: "recipient-invoice",
projection: "FULL",
},
presenter: new RecipientInvoiceFullPresenter(presenterRegistry),
},
{ {
key: { key: {
resource: "customer-invoice", resource: "customer-invoice",

View File

@ -130,6 +130,7 @@ export class CustomerInvoiceRepository
const row = await CustomerInvoiceModel.findOne({ const row = await CustomerInvoiceModel.findOne({
where: { id: id.toString(), company_id: companyId.toString() }, where: { id: id.toString(), company_id: companyId.toString() },
order: [[{ model: CustomerInvoiceItemModel, as: "items" }, "position", "ASC"]],
include: [ include: [
{ {
model: CustomerModel, model: CustomerModel,

View File

@ -217,7 +217,9 @@ export default (database: Sequelize) => {
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
defaultScope: {}, defaultScope: {
order: [["position", "ASC"]],
},
scopes: {}, scopes: {},
} }

View File

@ -17,6 +17,19 @@ export const GetCustomerInvoiceByIdResponseSchema = z.object({
language_code: z.string(), language_code: z.string(),
currency_code: z.string(), currency_code: z.string(),
customer_id: z.string(),
recipient: z.object({
id: z.string(),
name: z.string(),
tin: z.string(),
street: z.string(),
street2: z.string(),
city: z.string(),
province: z.string(),
postal_code: z.string(),
country: z.string(),
}),
taxes: z.string(), taxes: z.string(),
subtotal_amount: MoneySchema, subtotal_amount: MoneySchema,

View File

@ -0,0 +1,33 @@
// components/CustomerSkeleton.tsx
import { AppBreadcrumb, AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useTranslation } from "../i18n";
export const CustomerEditorSkeleton = () => {
const { t } = useTranslation();
return (
<>
<AppBreadcrumb />
<AppContent>
<div className='flex items-center justify-between'>
<div className='space-y-2' aria-hidden='true'>
<div className='h-7 w-64 rounded-md bg-muted animate-pulse' />
<div className='h-5 w-96 rounded-md bg-muted animate-pulse' />
</div>
<div className='flex items-center gap-2'>
<BackHistoryButton />
<Button disabled aria-busy>
{t("pages.update.submit")}
</Button>
</div>
</div>
<div className='mt-6 grid gap-4' aria-hidden='true'>
<div className='h-10 w-full rounded-md bg-muted animate-pulse' />
<div className='h-10 w-full rounded-md bg-muted animate-pulse' />
<div className='h-28 w-full rounded-md bg-muted animate-pulse' />
</div>
<span className='sr-only'>{t("pages.update.loading", "Cargando cliente...")}</span>
</AppContent>
</>
);
};

View File

@ -0,0 +1,16 @@
// components/ErrorAlert.tsx
interface ErrorAlertProps {
title: string;
message: string;
}
export const ErrorAlert = ({ title, message }: ErrorAlertProps) => (
<div
className='mb-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4'
role='alert'
aria-live='assertive'
>
<p className='font-semibold text-destructive-foreground'>{title}</p>
<p className='text-sm text-destructive-foreground/90'>{message}</p>
</div>
);

View File

@ -1,3 +1,6 @@
export * from "./client-selector"; export * from "./client-selector";
export * from "./customer-editor-skeleton";
export * from "./customers-layout"; export * from "./customers-layout";
export * from "./customers-list-grid"; export * from "./customers-list-grid";
export * from "./error-alert";
export * from "./not-found-card";

View File

@ -0,0 +1,19 @@
// components/NotFoundCard.tsx
import { BackHistoryButton } from "@repo/rdx-ui/components";
interface NotFoundCardProps {
title: string;
message: string;
}
export const NotFoundCard = ({ title, message }: NotFoundCardProps) => (
<>
<div className='rounded-lg border bg-card p-6'>
<h3 className='text-lg font-semibold'>{title}</h3>
<p className='text-sm text-muted-foreground'>{message}</p>
</div>
<div className='mt-4 flex items-center justify-end'>
<BackHistoryButton />
</div>
</>
);

View File

@ -0,0 +1,28 @@
export const COUNTRY_OPTIONS = [
{ value: "ES", label: "España" },
{ value: "FR", label: "Francia" },
{ value: "DE", label: "Alemania" },
{ value: "IT", label: "Italia" },
{ value: "PT", label: "Portugal" },
{ value: "US", label: "Estados Unidos" },
{ value: "MX", label: "México" },
{ value: "AR", label: "Argentina" },
] as const;
export const LANGUAGE_OPTIONS = [
{ value: "es", label: "Español" },
{ value: "en", label: "Inglés" },
{ value: "fr", label: "Francés" },
{ value: "de", label: "Alemán" },
{ value: "it", label: "Italiano" },
{ value: "pt", label: "Portugués" },
] as const;
export const CURRENCY_OPTIONS = [
{ value: "EUR", label: "Euro" },
{ value: "USD", label: "Dólar estadounidense" },
{ value: "GBP", label: "Libra esterlina" },
{ value: "ARS", label: "Peso argentino" },
{ value: "MXN", label: "Peso mexicano" },
{ value: "JPY", label: "Yen japonés" },
] as const;

View File

@ -0,0 +1 @@
export * from "./customer.constants";

View File

@ -1,20 +1,21 @@
import { useDataSource, useQueryKey } from "@erp/core/hooks"; import { useDataSource } from "@erp/core/hooks";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { UpdateCustomerByIdRequestDTO } from "../../common/dto"; import { CustomerCreateData, CustomerData } from "../schemas";
import { CUSTOMERS_LIST_KEY } from "./use-update-customer-mutation";
export const useCreateCustomerMutation = () => { export function useCreateCustomerMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const dataSource = useDataSource(); const dataSource = useDataSource();
const keys = useQueryKey();
return useMutation<UpdateCustomerByIdRequestDTO, Error, Partial<UpdateCustomerByIdRequestDTO>>({ return useMutation<CustomerData, Error, CustomerCreateData>({
mutationKey: ["customer:create"], mutationKey: ["customer:create"],
mutationFn: (data) => { mutationFn: async (data: CustomerCreateData) => {
console.log(data); const created = await dataSource.createOne("customers", data);
return dataSource.createOne("customers", data); return created as CustomerData;
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["customers"] }); // Invalida el listado de clientes para incluir el nuevo
queryClient.invalidateQueries({ queryKey: CUSTOMERS_LIST_KEY });
}, },
}); });
}; }

View File

@ -1,32 +1,36 @@
import { useDataSource } from "@erp/core/hooks"; import { useDataSource } from "@erp/core/hooks";
import {
UpdateCustomerByIdRequestDTO,
UpdateCustomerByIdResponseDTO,
} from "@erp/customer-invoices/common";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CustomerData, CustomerUpdateData } from "../schemas";
import { CUSTOMER_QUERY_KEY } from "./use-customer-query"; import { CUSTOMER_QUERY_KEY } from "./use-customer-query";
export const CUSTOMERS_LIST_KEY = ["customers"] as const; export const CUSTOMERS_LIST_KEY = ["customers"] as const;
type MutationDeps = {}; type UpdateCustomerPayload = {
id: string;
data: CustomerUpdateData;
};
export function useUpdateCustomerMutation(customerId: string, deps?: MutationDeps) { export function useUpdateCustomerMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const dataSource = useDataSource(); const dataSource = useDataSource();
return useMutation<UpdateCustomerByIdResponseDTO, Error, UpdateCustomerByIdRequestDTO>({ return useMutation<CustomerData, Error, UpdateCustomerPayload>({
mutationKey: ["customer:update", customerId], mutationKey: ["customer:update"], //, customerId],
mutationFn: async (input) => {
if (!customerId) throw new Error("customerId is required"); mutationFn: async (payload) => {
const updated = await dataSource.updateOne("customers", customerId, input); const { id: customerId, data } = payload;
return updated as UpdateCustomerByIdResponseDTO; if (!customerId) {
throw new Error("customerId is required");
}
const updated = await dataSource.updateOne("customers", customerId, data);
return updated as CustomerData;
}, },
onSuccess: (updated) => { onSuccess: (updated, variables) => {
const { id: customerId } = variables;
// Refresca inmediatamente el detalle // Refresca inmediatamente el detalle
queryClient.setQueryData<UpdateCustomerByIdResponseDTO>( queryClient.setQueryData<CustomerData>(CUSTOMER_QUERY_KEY(customerId), updated);
CUSTOMER_QUERY_KEY(customerId),
updated
);
// Otra opción es invalidar el detalle para forzar refetch: // Otra opción es invalidar el detalle para forzar refetch:
// queryClient.invalidateQueries({ queryKey: CUSTOMER_QUERY_KEY(customerId) }); // queryClient.invalidateQueries({ queryKey: CUSTOMER_QUERY_KEY(customerId) });

View File

@ -22,7 +22,7 @@ import {
import { useUnsavedChangesNotifier } from "@erp/core/hooks"; import { useUnsavedChangesNotifier } from "@erp/core/hooks";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { CustomerData, CustomerDataUpdateUpdateSchema } from "../../schemas"; import { CustomerData, CustomerUpdateSchema } from "../../schemas";
const defaultCustomerData = { const defaultCustomerData = {
id: "5e4dc5b3-96b9-4968-9490-14bd032fec5f", id: "5e4dc5b3-96b9-4968-9490-14bd032fec5f",
@ -64,7 +64,7 @@ export const CustomerEditForm = ({
const { t } = useTranslation(); const { t } = useTranslation();
const form = useForm<CustomerData>({ const form = useForm<CustomerData>({
resolver: zodResolver(CustomerDataUpdateUpdateSchema), resolver: zodResolver(CustomerUpdateSchema),
defaultValues: initialData, defaultValues: initialData,
disabled: isPending, disabled: isPending,
}); });

View File

@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { FieldErrors, useForm } from "react-hook-form";
import { TaxesMultiSelectField } from "@erp/core/components"; import { TaxesMultiSelectField } from "@erp/core/components";
import { SelectField, TextAreaField, TextField } from "@repo/rdx-ui/components"; import { SelectField, TextAreaField, TextField } from "@repo/rdx-ui/components";
@ -21,26 +21,26 @@ import {
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { useUnsavedChangesNotifier } from "@erp/core/hooks"; import { useUnsavedChangesNotifier } from "@erp/core/hooks";
import { GetCustomerByIdResponseDTO } from "@erp/customer-invoices/common"; import { COUNTRY_OPTIONS, LANGUAGE_OPTIONS } from "../../constants/customer.constants";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { CustomerData, CustomerDataUpdateUpdateSchema } from "../../schemas"; import { CustomerData, CustomerUpdateData, CustomerUpdateSchema } from "../../schemas";
interface CustomerFormProps { interface CustomerFormProps {
formId: string; formId: string;
data?: GetCustomerByIdResponseDTO; data?: CustomerData;
isPending?: boolean; isPending?: boolean;
/** /**
* Callback function to handle form submission. * Callback function to handle form submission.
* @param data - The customer data submitted by the form. * @param data - The customer data submitted by the form.
*/ */
onSubmit?: (data: CustomerData) => void; onSubmit?: (data: CustomerUpdateData) => void;
} }
export const CustomerEditForm = ({ formId, data, onSubmit, isPending }: CustomerFormProps) => { export const CustomerEditForm = ({ formId, data, onSubmit, isPending }: CustomerFormProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const form = useForm<CustomerData>({ const form = useForm<CustomerUpdateData>({
resolver: zodResolver(CustomerDataUpdateUpdateSchema), resolver: zodResolver(CustomerUpdateSchema),
defaultValues: data, defaultValues: data,
disabled: isPending, disabled: isPending,
}); });
@ -49,12 +49,12 @@ export const CustomerEditForm = ({ formId, data, onSubmit, isPending }: Customer
isDirty: form.formState.isDirty, isDirty: form.formState.isDirty,
}); });
const handleSubmit = (data: CustomerData) => { const handleSubmit = (data: CustomerUpdateData) => {
console.log("Datos del formulario:", data); console.log("Datos del formulario:", data);
onSubmit?.(data); onSubmit?.(data);
}; };
const handleError = (errors: any) => { const handleError = (errors: FieldErrors<CustomerUpdateData>) => {
console.error("Errores en el formulario:", errors); console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario // Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
}; };
@ -63,8 +63,21 @@ export const CustomerEditForm = ({ formId, data, onSubmit, isPending }: Customer
form.reset(data); form.reset(data);
}; };
const {
formState: { isDirty, dirtyFields },
} = form;
return ( return (
<Form {...form}> <Form {...form}>
<div className='mt-6 p-4 border rounded bg-gray-50'>
<p>
<strong>¿Formulario modificado?</strong> {isDirty ? "Sí" : "No"}
</p>
<p>
<strong>Campos modificados:</strong>{" "}
{Object.keys(dirtyFields).length > 0 ? Object.keys(dirtyFields).join(", ") : "Ninguno"}
</p>
</div>{" "}
<form id={formId} onSubmit={form.handleSubmit(handleSubmit, handleError)}> <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'>
{/* Información básica */} {/* Información básica */}
@ -82,13 +95,13 @@ export const CustomerEditForm = ({ formId, data, onSubmit, isPending }: Customer
<FormLabel>{t("form_fields.customer_type.label")}</FormLabel> <FormLabel>{t("form_fields.customer_type.label")}</FormLabel>
<FormControl> <FormControl>
<RadioGroup <RadioGroup
onValueChange={field.onChange} onValueChange={(value) => field.onChange(value === "1")}
defaultValue={field.value ? "1" : "0"} defaultValue={field.value ? "1" : "0"}
className='flex gap-6' className='flex gap-6'
> >
<FormItem className='flex items-center space-x-2'> <FormItem className='flex items-center space-x-2'>
<FormControl> <FormControl>
<RadioGroupItem value={"1"} /> <RadioGroupItem value='1' />
</FormControl> </FormControl>
<FormLabel className='font-normal'> <FormLabel className='font-normal'>
{t("form_fields.customer_type.company")} {t("form_fields.customer_type.company")}
@ -97,7 +110,7 @@ export const CustomerEditForm = ({ formId, data, onSubmit, isPending }: Customer
<FormItem className='flex items-center space-x-2'> <FormItem className='flex items-center space-x-2'>
<FormControl> <FormControl>
<RadioGroupItem value={"0"} /> <RadioGroupItem value='0' />
</FormControl> </FormControl>
<FormLabel className='font-normal'> <FormLabel className='font-normal'>
{t("form_fields.customer_type.individual")} {t("form_fields.customer_type.individual")}
@ -188,16 +201,7 @@ export const CustomerEditForm = ({ formId, data, onSubmit, isPending }: Customer
label={t("form_fields.country.label")} label={t("form_fields.country.label")}
placeholder={t("form_fields.country.placeholder")} placeholder={t("form_fields.country.placeholder")}
description={t("form_fields.country.description")} description={t("form_fields.country.description")}
items={[ items={COUNTRY_OPTIONS}
{ value: "ES", label: "España" },
{ value: "FR", label: "Francia" },
{ value: "DE", label: "Alemania" },
{ value: "IT", label: "Italia" },
{ value: "PT", label: "Portugal" },
{ value: "US", label: "Estados Unidos" },
{ value: "MX", label: "México" },
{ value: "AR", label: "Argentina" },
]}
/> />
</CardContent> </CardContent>
</Card> </Card>
@ -294,36 +298,12 @@ export const CustomerEditForm = ({ formId, data, onSubmit, isPending }: Customer
<SelectField <SelectField
control={form.control} control={form.control}
name='lang_code' name='language_code'
required required
label={t("form_fields.lang_code.label")} label={t("form_fields.lang_code.label")}
placeholder={t("form_fields.lang_code.placeholder")} placeholder={t("form_fields.lang_code.placeholder")}
description={t("form_fields.lang_code.description")} description={t("form_fields.lang_code.description")}
items={[ items={LANGUAGE_OPTIONS}
{ value: "es", label: "Español" },
{ value: "en", label: "Inglés" },
{ value: "fr", label: "Francés" },
{ value: "de", label: "Alemán" },
{ value: "it", label: "Italiano" },
{ value: "pt", label: "Portugués" },
]}
/>
<SelectField
control={form.control}
name='currency_code'
required
label={t("form_fields.currency_code.label")}
placeholder={t("form_fields.currency_code.placeholder")}
description={t("form_fields.currency_code.description")}
items={[
{ value: "EUR", label: "Euro" },
{ value: "USD", label: "Dólar estadounidense" },
{ value: "GBP", label: "Libra esterlina" },
{ value: "ARS", label: "Peso argentino" },
{ value: "MXN", label: "Peso mexicano" },
{ value: "JPY", label: "Yen japonés" },
]}
/> />
<TextAreaField <TextAreaField
@ -338,7 +318,7 @@ export const CustomerEditForm = ({ formId, data, onSubmit, isPending }: Customer
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<Button type='submit'>Submit</Button> <Button type='submit'>{t("pages.update.submit")}</Button>
</form> </form>
</Form> </Form>
); );

View File

@ -3,8 +3,11 @@ import { Button } from "@repo/shadcn-ui/components";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useUrlParamId } from "@erp/core/hooks"; import { useUrlParamId } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast } from "@repo/shadcn-ui/lib/utils";
import { CustomerEditorSkeleton, ErrorAlert, NotFoundCard } from "../../components";
import { useCustomerQuery, useUpdateCustomerMutation } from "../../hooks"; import { useCustomerQuery, useUpdateCustomerMutation } from "../../hooks";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { CustomerUpdateData } from "../../schemas";
import { CustomerEditForm } from "./customer-edit-form"; import { CustomerEditForm } from "./customer-edit-form";
export const CustomerUpdate = () => { export const CustomerUpdate = () => {
@ -22,50 +25,29 @@ export const CustomerUpdate = () => {
// 2) Estado de actualización (mutación) // 2) Estado de actualización (mutación)
const { const {
mutateAsync: updateAsync, mutateAsync,
isPending: isUpdating, isPending: isUpdating,
isError: isUpdateError, isError: isUpdateError,
error: updateError, error: updateError,
} = useUpdateCustomerMutation(customerId || ""); } = useUpdateCustomerMutation();
// 3) Submit con navegación condicionada por éxito // 3) Submit con navegación condicionada por éxito
const handleSubmit = async (formData: any) => { const handleSubmit = async (formData: CustomerUpdateData) => {
try { try {
await updateAsync(formData); // solo navegamos si no lanza const result = await mutateAsync({ id: customerId!, data: formData });
// toast?.({ title: t('pages.update.successTitle'), description: t('pages.update.successMsg') });
navigate("/customers/list"); if (result) {
showSuccessToast(t("pages.update.successTitle"), t("pages.update.successMsg"));
navigate("/customers/list");
}
} catch (e) { } catch (e) {
// toast?.({ variant: 'destructive', title: t('pages.update.errorTitle'), description: (e as Error).message }); showErrorToast(t("pages.update.errorTitle"), (e as Error).message);
// No navegamos en caso de error } finally {
} }
}; };
if (isLoadingCustomer) { if (isLoadingCustomer) {
return ( return <CustomerEditorSkeleton />;
<>
<AppBreadcrumb />
<AppContent>
<div className='flex items-center justify-between'>
<div className='space-y-2'>
<div className='h-7 w-64 rounded-md bg-muted animate-pulse' />
<div className='h-5 w-96 rounded-md bg-muted animate-pulse' />
</div>
<div className='flex items-center gap-2'>
<BackHistoryButton />
<Button disabled aria-busy>
{t("pages.update.submit")}
</Button>
</div>
</div>
<div className='mt-6 grid gap-4'>
{/* Skeleton simple para el formulario */}
<div className='h-10 w-full rounded-md bg-muted animate-pulse' />
<div className='h-10 w-full rounded-md bg-muted animate-pulse' />
<div className='h-28 w-full rounded-md bg-muted animate-pulse' />
</div>
</AppContent>
</>
);
} }
if (isLoadError) { if (isLoadError) {
@ -73,19 +55,14 @@ export const CustomerUpdate = () => {
<> <>
<AppBreadcrumb /> <AppBreadcrumb />
<AppContent> <AppContent>
<div <ErrorAlert
className='mb-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4' title={t("pages.update.loadErrorTitle", "No se pudo cargar el cliente")}
role='alert' message={
aria-live='assertive' (loadError as Error)?.message ??
> t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.")
<p className='font-semibold text-destructive-foreground'> }
{t("pages.update.loadErrorTitle", "No se pudo cargar el cliente")} />
</p>
<p className='text-sm text-destructive-foreground/90'>
{(loadError as Error)?.message ??
t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.")}
</p>
</div>
<div className='flex items-center justify-end'> <div className='flex items-center justify-end'>
<BackHistoryButton /> <BackHistoryButton />
</div> </div>
@ -94,26 +71,18 @@ export const CustomerUpdate = () => {
); );
} }
if (!customerData) { if (!customerData)
return ( return (
<> <>
<AppBreadcrumb /> <AppBreadcrumb />
<AppContent> <AppContent>
<div className='rounded-lg border bg-card p-6'> <NotFoundCard
<h3 className='text-lg font-semibold'> title={t("pages.update.notFoundTitle", "Cliente no encontrado")}
{t("pages.update.notFoundTitle", "Cliente no encontrado")} message={t("pages.update.notFoundMsg", "Revisa el identificador o vuelve al listado.")}
</h3> />
<p className='text-sm text-muted-foreground'>
{t("pages.update.notFoundMsg", "Revisa el identificador o vuelve al listado.")}
</p>
</div>
<div className='mt-4 flex items-center justify-end'>
<BackHistoryButton />
</div>
</AppContent> </AppContent>
</> </>
); );
}
return ( return (
<> <>
@ -145,19 +114,13 @@ export const CustomerUpdate = () => {
</div> </div>
{/* Alerta de error de actualización (si ha fallado el último intento) */} {/* Alerta de error de actualización (si ha fallado el último intento) */}
{isUpdateError && ( {isUpdateError && (
<div <ErrorAlert
className='mb-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3' title={t("pages.update.errorTitle", "No se pudo guardar los cambios")}
role='alert' message={
aria-live='assertive' (updateError as Error)?.message ??
> t("pages.update.errorMsg", "Revisa los datos e inténtalo de nuevo.")
<p className='text-sm font-medium text-destructive-foreground'> }
{t("pages.update.errorTitle", "No se pudo guardar los cambios")} />
</p>
<p className='text-xs text-destructive-foreground/90'>
{(updateError as Error)?.message ??
t("pages.update.errorMsg", "Revisa los datos e inténtalo de nuevo.")}
</p>
</div>
)} )}
<div className='flex flex-1 flex-col gap-4 p-4'> <div className='flex flex-1 flex-col gap-4 p-4'>

View File

@ -1,4 +1,6 @@
import { import {
CreateCustomerRequestDTO,
CreateCustomerRequestSchema,
GetCustomerByIdResponseDTO, GetCustomerByIdResponseDTO,
UpdateCustomerByIdRequestDTO, UpdateCustomerByIdRequestDTO,
UpdateCustomerByIdRequestSchema, UpdateCustomerByIdRequestSchema,
@ -6,5 +8,8 @@ import {
export type CustomerData = GetCustomerByIdResponseDTO; export type CustomerData = GetCustomerByIdResponseDTO;
export const CustomerDataUpdateUpdateSchema = UpdateCustomerByIdRequestSchema; export const CustomerCreateSchema = CreateCustomerRequestSchema;
export type CustomerDataFormUpdateDTO = UpdateCustomerByIdRequestDTO; export type CustomerCreateData = CreateCustomerRequestDTO;
export const CustomerUpdateSchema = UpdateCustomerByIdRequestSchema;
export type CustomerUpdateData = UpdateCustomerByIdRequestDTO;

View File

@ -201,6 +201,10 @@ export class MoneyValue extends ValueObject<MoneyValueProps> implements IMoneyVa
return this.dinero.hasSameAmount(comparator.dinero); return this.dinero.hasSameAmount(comparator.dinero);
} }
hasSameScale(comparator: MoneyValue): boolean {
return this.dinero.getPrecision() === comparator.dinero.getPrecision();
}
/** /**
* Devuelve una cadena con el importe formateado. * Devuelve una cadena con el importe formateado.
* Ejemplo: 123456 -> 1,234.56 * Ejemplo: 123456 -> 1,234.56

View File

@ -1,15 +1,15 @@
"use client" "use client";
import { useTheme } from "next-themes" import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner" import { Toaster as Sonner, ToasterProps, toast } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme();
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className='toaster group'
style={ style={
{ {
"--normal-bg": "var(--popover)", "--normal-bg": "var(--popover)",
@ -19,7 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
} }
{...props} {...props}
/> />
) );
} };
export { Toaster } export { Toaster, toast };

View File

@ -1,6 +1,25 @@
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { toast } from "../components/sonner.tsx";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
/**
* Muestra un toast de éxito
*/
export function showSuccessToast(title: string, description?: string) {
toast.success(title, {
description,
});
}
/**
* Muestra un toast de error
*/
export function showErrorToast(title: string, description?: string) {
toast.error(title, {
description,
});
}