Clientes y facturas de cliente
This commit is contained in:
parent
4d3430cc91
commit
096abdccb2
@ -62,7 +62,7 @@ export const App = () => {
|
||||
</Suspense>
|
||||
</UnsavedWarnProvider>
|
||||
</TooltipProvider>
|
||||
<Toaster />
|
||||
<Toaster position='top-right' />
|
||||
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
</AuthProvider>
|
||||
</DataSourceProvider>
|
||||
|
||||
@ -17,8 +17,8 @@ export interface IDataSource {
|
||||
getList<T, R>(resource: string, params?: Record<string, unknown>): Promise<R>;
|
||||
getOne<T>(resource: string, id: string | number): Promise<T>;
|
||||
getMany<T, R>(resource: string, ids: Array<string | number>): Promise<R>;
|
||||
createOne<T>(resource: string, data: Partial<T>): Promise<T>;
|
||||
updateOne<T>(resource: string, id: string | number, data: Partial<T>): Promise<T>;
|
||||
createOne<T, R>(resource: string, data: Partial<T>): Promise<R>;
|
||||
updateOne<T, R>(resource: string, id: string | number, data: Partial<T>): Promise<R>;
|
||||
deleteOne<T>(resource: string, id: string | number): Promise<void>;
|
||||
|
||||
custom: <R>(customParams: ICustomParams) => Promise<R>;
|
||||
|
||||
@ -30,8 +30,6 @@ export class CustomerInvoiceItemsFullPresenter extends Presenter {
|
||||
() => ({ value: "", scale: "", currency_code: "" })
|
||||
),
|
||||
|
||||
taxes: invoiceItem.taxes.getCodesToString(),
|
||||
|
||||
subtotal_amount: allAmounts.subtotalAmount.toObjectString(),
|
||||
|
||||
discount_percentage: invoiceItem.discountPercentage.match(
|
||||
@ -40,8 +38,11 @@ export class CustomerInvoiceItemsFullPresenter extends Presenter {
|
||||
),
|
||||
|
||||
discount_amount: allAmounts.discountAmount.toObjectString(),
|
||||
|
||||
taxable_amount: allAmounts.taxableAmount.toObjectString(),
|
||||
taxes: invoiceItem.taxes.getCodesToString(),
|
||||
taxes_amount: allAmounts.taxesAmount.toObjectString(),
|
||||
|
||||
total_amount: allAmounts.totalAmount.toObjectString(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { toEmptyString } from "@repo/rdx-ddd";
|
||||
import { GetCustomerInvoiceByIdResponseDTO } from "../../../../common/dto";
|
||||
import { CustomerInvoice } from "../../../domain";
|
||||
import { CustomerInvoiceItemsFullPresenter } from "./customer-invoice-items.full.presenter";
|
||||
import { RecipientInvoiceFullPresenter } from "./recipient-invoice.full.representer";
|
||||
|
||||
export class CustomerInvoiceFullPresenter extends Presenter<
|
||||
CustomerInvoice,
|
||||
@ -14,6 +15,12 @@ export class CustomerInvoiceFullPresenter extends Presenter<
|
||||
projection: "FULL",
|
||||
}) 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 allAmounts = invoice.getAllAmounts();
|
||||
|
||||
@ -33,6 +40,9 @@ export class CustomerInvoiceFullPresenter extends Presenter<
|
||||
language_code: invoice.languageCode.toString(),
|
||||
currency_code: invoice.currencyCode.toString(),
|
||||
|
||||
customer_id: invoice.customerId.toString(),
|
||||
recipient,
|
||||
|
||||
taxes: invoice.taxes.getCodesToString(),
|
||||
|
||||
subtotal_amount: allAmounts.subtotalAmount.toObjectString(),
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./customer-invoice-items.full.presenter";
|
||||
export * from "./customer-invoice.full.presenter";
|
||||
export * from "./recipient-invoice.full.representer";
|
||||
|
||||
@ -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: "",
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -45,12 +45,13 @@
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 25px;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
table th,
|
||||
table td {
|
||||
border: 1px solid #ccc;
|
||||
border: 0px solid #ccc;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
@ -98,22 +99,21 @@
|
||||
<body>
|
||||
|
||||
<header>
|
||||
|
||||
<aside class="flex items-start mb-4 w-full">
|
||||
<!-- Bloque IZQUIERDO: imagen arriba + texto abajo, alineado a la izquierda -->
|
||||
<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" />
|
||||
<div class="flex w-full">
|
||||
<div class="p-1 ">
|
||||
<p><span>Factura nº:</span>xxxxxxxx</p>
|
||||
<p><span>Fecha:</span>12/12/2024</p>
|
||||
<p>Factura nº:<strong> {{invoice_number}}</strong></p>
|
||||
<p><span>Fecha:<strong> {{operation_date}}</strong></p>
|
||||
<p><span>Página:</span>1 / 1</p>
|
||||
</div>
|
||||
<div class="p-1 ml-9">
|
||||
<h2 class="font-semibold uppercase mb-1">{{customer.name}}</h2>
|
||||
<p>AAAA</p>
|
||||
<p>BBBBBBsdfsfsdf sfsdf sf sdfs fsdfsd fsdf sdfsd fds </p>
|
||||
<p>CCCCC</p>
|
||||
<p>DDDDD</p>
|
||||
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
|
||||
<p>{{recipient.tin}}</p>
|
||||
<p>{{recipient.street}}</p>
|
||||
<p>{{recipient.postal_code}} {{recipient.city}} {{recipient.province}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -130,166 +130,90 @@
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
</header>
|
||||
|
||||
<div class="relative bg-blue-400">
|
||||
<!-- Badge TOTAL superpuesto -->
|
||||
<div class="absolute -top-7 right-0">
|
||||
<div class="relative bg-[#f08119] text-white text-sm font-semibold px-3 py-1 shadow">
|
||||
TOTAL: 960,56 €
|
||||
<!-- Triángulo izquierdo -->
|
||||
<span aria-hidden="true" class="absolute -left-3 top-0 bottom-0 my-auto h-0 w-0
|
||||
border-y-[14px] border-y-transparent
|
||||
border-r-[14px] border-r-amber-500"></span>
|
||||
<main id="main">
|
||||
<section id="details">
|
||||
|
||||
<div class="relative pt-0 border-b border-black">
|
||||
<!-- Badge TOTAL superpuesto -->
|
||||
<div class="absolute -top-7 right-0">
|
||||
<div class="relative bg-[#f08119] text-white text-sm font-semibold px-3 py-1 shadow">
|
||||
TOTAL: {{total_amount.value}} €
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tu tabla -->
|
||||
<table class="w-full border-t border-black">
|
||||
<thead>
|
||||
<tr class="text-left">
|
||||
<th class="py-2">Concepto</th>
|
||||
<th class="py-2">Cantidad</th>
|
||||
<th class="py-2">Precio unidad</th>
|
||||
<th class="py-2">Importe total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<!-- Tu tabla -->
|
||||
<table class="table-header">
|
||||
<thead>
|
||||
<tr class="text-left">
|
||||
<th class="py-2">Concepto</th>
|
||||
<th class="py-2">Cantidad</th>
|
||||
<th class="py-2">Precio unidad</th>
|
||||
<th class="py-2">Importe total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Mantenimiento de sistemas informáticos - Agosto (1 Equipo Servidor, 30 Ordenadores, 2 Impresoras, Disco
|
||||
copias de seguridad)</td>
|
||||
<td>1</td>
|
||||
<td>0,14 €</td>
|
||||
<td>0,14 €</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Mantenimiento del programa FactuGES</td>
|
||||
<td>1</td>
|
||||
<td>40,00 €</td>
|
||||
<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>
|
||||
<tbody>
|
||||
{{#each items}}
|
||||
<tr>
|
||||
<td>{{description}}</td>
|
||||
<td class="text-right">{{quantity.value}}</td>
|
||||
<td class="text-right">{{unit_amount.value}} €</td>
|
||||
<td class="text-right">{{total_amount.value}} €</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<table class="totals">
|
||||
<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>
|
||||
<section id="resume" class="flex items-center justify-between pb-4 mb-4">
|
||||
|
||||
<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 neto</td>
|
||||
<td class="px-4 py-2 text-right">761,14 €</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-4 py-2 text-right">Descuento 0%</td>
|
||||
<td class="px-4 py-2 text-right">0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-4 py-2 text-right">Base imponible</td>
|
||||
<td class="px-4 py-2 text-right">765,14€</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-4 py-2 text-right">IVA 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 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 -
|
||||
Rodax Software S.L.</p>
|
||||
<p><strong>Forma de pago:</strong> Domiciliación bancaria</p>
|
||||
</footer>
|
||||
</aside>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { DomainValidationError } from "@erp/core/api";
|
||||
import {
|
||||
AggregateRoot,
|
||||
CurrencyCode,
|
||||
DomainValidationError,
|
||||
LanguageCode,
|
||||
Percentage,
|
||||
TextValue,
|
||||
@ -168,8 +168,27 @@ export class CustomerInvoice
|
||||
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 {
|
||||
const itemsSubtotal = this.items.getTotalAmount().convertScale(2);
|
||||
const itemsSubtotal = this.items.getSubtotalAmount().convertScale(2);
|
||||
|
||||
return InvoiceAmount.create({
|
||||
value: itemsSubtotal.value,
|
||||
@ -178,11 +197,11 @@ export class CustomerInvoice
|
||||
}
|
||||
|
||||
public getDiscountAmount(): InvoiceAmount {
|
||||
return this.getSubtotalAmount().percentage(this.discountPercentage) as InvoiceAmount;
|
||||
return this._getDiscountAmount(this.getSubtotalAmount());
|
||||
}
|
||||
|
||||
public getTaxableAmount(): InvoiceAmount {
|
||||
return this.getSubtotalAmount().subtract(this.getDiscountAmount()) as InvoiceAmount;
|
||||
return this._getTaxableAmount(this.getSubtotalAmount(), this.getDiscountAmount());
|
||||
}
|
||||
|
||||
public getTaxesAmount(): InvoiceAmount {
|
||||
@ -191,20 +210,24 @@ export class CustomerInvoice
|
||||
|
||||
public getTotalAmount(): InvoiceAmount {
|
||||
const taxableAmount = this.getTaxableAmount();
|
||||
return taxableAmount.add(this._getTaxesAmount(taxableAmount)) as InvoiceAmount;
|
||||
const taxesAmount = this._getTaxesAmount(taxableAmount);
|
||||
|
||||
return this._getTotalAmount(taxableAmount, taxesAmount);
|
||||
}
|
||||
|
||||
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 {
|
||||
subtotalAmount: this.getSubtotalAmount(),
|
||||
discountAmount: this.getDiscountAmount(),
|
||||
taxableAmount: this.getTaxableAmount(),
|
||||
taxesAmount: this.getTaxesAmount(),
|
||||
totalAmount: this.getTotalAmount(),
|
||||
subtotalAmount,
|
||||
discountAmount,
|
||||
taxableAmount,
|
||||
taxesAmount,
|
||||
totalAmount,
|
||||
};
|
||||
}
|
||||
|
||||
private _getTaxesAmount(_taxableAmount: InvoiceAmount): InvoiceAmount {
|
||||
return this._taxes.getTaxesAmount(_taxableAmount);
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,6 +95,26 @@ export class CustomerInvoiceItem
|
||||
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 {
|
||||
const curCode = this.currencyCode.code;
|
||||
const quantity = this.quantity.match(
|
||||
@ -110,15 +130,11 @@ export class CustomerInvoiceItem
|
||||
}
|
||||
|
||||
public getDiscountAmount(): ItemAmount {
|
||||
const discount = this.discountPercentage.match(
|
||||
(percentage) => percentage,
|
||||
() => ItemDiscount.zero()
|
||||
);
|
||||
return this.getSubtotalAmount().percentage(discount);
|
||||
return this._getDiscountAmount(this.getSubtotalAmount());
|
||||
}
|
||||
|
||||
public getTaxableAmount(): ItemAmount {
|
||||
return this.getSubtotalAmount().subtract(this.getDiscountAmount());
|
||||
return this._getTaxableAmount(this.getSubtotalAmount(), this.getDiscountAmount());
|
||||
}
|
||||
|
||||
public getTaxesAmount(): ItemAmount {
|
||||
@ -127,20 +143,24 @@ export class CustomerInvoiceItem
|
||||
|
||||
public getTotalAmount(): ItemAmount {
|
||||
const taxableAmount = this.getTaxableAmount();
|
||||
return taxableAmount.add(this._getTaxesAmount(taxableAmount));
|
||||
const taxesAmount = this._getTaxesAmount(taxableAmount);
|
||||
|
||||
return this._getTotalAmount(taxableAmount, taxesAmount);
|
||||
}
|
||||
|
||||
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 {
|
||||
subtotalAmount: this.getSubtotalAmount(),
|
||||
discountAmount: this.getDiscountAmount(),
|
||||
taxableAmount: this.getTaxableAmount(),
|
||||
taxesAmount: this.getTaxesAmount(),
|
||||
totalAmount: this.getTotalAmount(),
|
||||
subtotalAmount,
|
||||
discountAmount,
|
||||
taxableAmount,
|
||||
taxesAmount,
|
||||
totalAmount,
|
||||
};
|
||||
}
|
||||
|
||||
private _getTaxesAmount(_taxableAmount: ItemAmount): ItemAmount {
|
||||
return this.props.taxes.getTaxesAmount(_taxableAmount);
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,9 +36,37 @@ export class CustomerInvoiceItems extends Collection<CustomerInvoiceItem> {
|
||||
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 {
|
||||
return this.getAll().reduce(
|
||||
(total, tax) => total.add(tax.getTotalAmount()),
|
||||
(total, item) => total.add(item.getTotalAmount()),
|
||||
ItemAmount.zero(this._currencyCode.code)
|
||||
);
|
||||
}
|
||||
|
||||
@ -35,36 +35,60 @@ export class InvoiceAmount extends MoneyValue {
|
||||
convertScale(newScale: number) {
|
||||
const mv = super.convertScale(newScale);
|
||||
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) {
|
||||
const mv = super.add(addend);
|
||||
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) {
|
||||
const mv = super.subtract(subtrahend);
|
||||
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) {
|
||||
const mv = super.multiply(multiplier);
|
||||
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) {
|
||||
const mv = super.divide(divisor);
|
||||
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) {
|
||||
const mv = super.percentage(percentage);
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,36 +35,63 @@ export class ItemAmount extends MoneyValue {
|
||||
convertScale(newScale: number) {
|
||||
const mv = super.convertScale(newScale);
|
||||
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) {
|
||||
const mv = super.add(addend);
|
||||
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) {
|
||||
const mv = super.subtract(subtrahend);
|
||||
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) {
|
||||
const mv = super.multiply(multiplier);
|
||||
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) {
|
||||
const mv = super.divide(divisor);
|
||||
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) {
|
||||
const mv = super.percentage(percentage);
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
GetCustomerInvoiceUseCase,
|
||||
ListCustomerInvoicesPresenter,
|
||||
ListCustomerInvoicesUseCase,
|
||||
RecipientInvoiceFullPresenter,
|
||||
ReportCustomerInvoiceUseCase,
|
||||
} from "../application";
|
||||
|
||||
@ -77,6 +78,13 @@ export function buildCustomerInvoiceDependencies(params: ModuleParams): Customer
|
||||
},
|
||||
presenter: new CustomerInvoiceItemsFullPresenter(presenterRegistry),
|
||||
},
|
||||
{
|
||||
key: {
|
||||
resource: "recipient-invoice",
|
||||
projection: "FULL",
|
||||
},
|
||||
presenter: new RecipientInvoiceFullPresenter(presenterRegistry),
|
||||
},
|
||||
{
|
||||
key: {
|
||||
resource: "customer-invoice",
|
||||
|
||||
@ -130,6 +130,7 @@ export class CustomerInvoiceRepository
|
||||
|
||||
const row = await CustomerInvoiceModel.findOne({
|
||||
where: { id: id.toString(), company_id: companyId.toString() },
|
||||
order: [[{ model: CustomerInvoiceItemModel, as: "items" }, "position", "ASC"]],
|
||||
include: [
|
||||
{
|
||||
model: CustomerModel,
|
||||
|
||||
@ -217,7 +217,9 @@ export default (database: Sequelize) => {
|
||||
|
||||
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
|
||||
|
||||
defaultScope: {},
|
||||
defaultScope: {
|
||||
order: [["position", "ASC"]],
|
||||
},
|
||||
|
||||
scopes: {},
|
||||
}
|
||||
|
||||
@ -17,6 +17,19 @@ export const GetCustomerInvoiceByIdResponseSchema = z.object({
|
||||
language_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(),
|
||||
|
||||
subtotal_amount: MoneySchema,
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
16
modules/customers/src/web/components/error-alert.tsx
Normal file
16
modules/customers/src/web/components/error-alert.tsx
Normal 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>
|
||||
);
|
||||
@ -1,3 +1,6 @@
|
||||
export * from "./client-selector";
|
||||
export * from "./customer-editor-skeleton";
|
||||
export * from "./customers-layout";
|
||||
export * from "./customers-list-grid";
|
||||
export * from "./error-alert";
|
||||
export * from "./not-found-card";
|
||||
|
||||
19
modules/customers/src/web/components/not-found-card.tsx
Normal file
19
modules/customers/src/web/components/not-found-card.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
28
modules/customers/src/web/constants/customer.constants.ts
Normal file
28
modules/customers/src/web/constants/customer.constants.ts
Normal 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;
|
||||
1
modules/customers/src/web/constants/index.ts
Normal file
1
modules/customers/src/web/constants/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./customer.constants";
|
||||
@ -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 { 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 dataSource = useDataSource();
|
||||
const keys = useQueryKey();
|
||||
|
||||
return useMutation<UpdateCustomerByIdRequestDTO, Error, Partial<UpdateCustomerByIdRequestDTO>>({
|
||||
return useMutation<CustomerData, Error, CustomerCreateData>({
|
||||
mutationKey: ["customer:create"],
|
||||
mutationFn: (data) => {
|
||||
console.log(data);
|
||||
return dataSource.createOne("customers", data);
|
||||
mutationFn: async (data: CustomerCreateData) => {
|
||||
const created = await dataSource.createOne("customers", data);
|
||||
return created as CustomerData;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["customers"] });
|
||||
// Invalida el listado de clientes para incluir el nuevo
|
||||
queryClient.invalidateQueries({ queryKey: CUSTOMERS_LIST_KEY });
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,32 +1,36 @@
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import {
|
||||
UpdateCustomerByIdRequestDTO,
|
||||
UpdateCustomerByIdResponseDTO,
|
||||
} from "@erp/customer-invoices/common";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { CustomerData, CustomerUpdateData } from "../schemas";
|
||||
import { CUSTOMER_QUERY_KEY } from "./use-customer-query";
|
||||
|
||||
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 dataSource = useDataSource();
|
||||
|
||||
return useMutation<UpdateCustomerByIdResponseDTO, Error, UpdateCustomerByIdRequestDTO>({
|
||||
mutationKey: ["customer:update", customerId],
|
||||
mutationFn: async (input) => {
|
||||
if (!customerId) throw new Error("customerId is required");
|
||||
const updated = await dataSource.updateOne("customers", customerId, input);
|
||||
return updated as UpdateCustomerByIdResponseDTO;
|
||||
return useMutation<CustomerData, Error, UpdateCustomerPayload>({
|
||||
mutationKey: ["customer:update"], //, customerId],
|
||||
|
||||
mutationFn: async (payload) => {
|
||||
const { id: customerId, data } = payload;
|
||||
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
|
||||
queryClient.setQueryData<UpdateCustomerByIdResponseDTO>(
|
||||
CUSTOMER_QUERY_KEY(customerId),
|
||||
updated
|
||||
);
|
||||
queryClient.setQueryData<CustomerData>(CUSTOMER_QUERY_KEY(customerId), updated);
|
||||
|
||||
// Otra opción es invalidar el detalle para forzar refetch:
|
||||
// queryClient.invalidateQueries({ queryKey: CUSTOMER_QUERY_KEY(customerId) });
|
||||
|
||||
@ -22,7 +22,7 @@ import {
|
||||
|
||||
import { useUnsavedChangesNotifier } from "@erp/core/hooks";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { CustomerData, CustomerDataUpdateUpdateSchema } from "../../schemas";
|
||||
import { CustomerData, CustomerUpdateSchema } from "../../schemas";
|
||||
|
||||
const defaultCustomerData = {
|
||||
id: "5e4dc5b3-96b9-4968-9490-14bd032fec5f",
|
||||
@ -64,7 +64,7 @@ export const CustomerEditForm = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm<CustomerData>({
|
||||
resolver: zodResolver(CustomerDataUpdateUpdateSchema),
|
||||
resolver: zodResolver(CustomerUpdateSchema),
|
||||
defaultValues: initialData,
|
||||
disabled: isPending,
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { SelectField, TextAreaField, TextField } from "@repo/rdx-ui/components";
|
||||
@ -21,26 +21,26 @@ import {
|
||||
} from "@repo/shadcn-ui/components";
|
||||
|
||||
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 { CustomerData, CustomerDataUpdateUpdateSchema } from "../../schemas";
|
||||
import { CustomerData, CustomerUpdateData, CustomerUpdateSchema } from "../../schemas";
|
||||
|
||||
interface CustomerFormProps {
|
||||
formId: string;
|
||||
data?: GetCustomerByIdResponseDTO;
|
||||
data?: CustomerData;
|
||||
isPending?: boolean;
|
||||
/**
|
||||
* Callback function to handle form submission.
|
||||
* @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) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm<CustomerData>({
|
||||
resolver: zodResolver(CustomerDataUpdateUpdateSchema),
|
||||
const form = useForm<CustomerUpdateData>({
|
||||
resolver: zodResolver(CustomerUpdateSchema),
|
||||
defaultValues: data,
|
||||
disabled: isPending,
|
||||
});
|
||||
@ -49,12 +49,12 @@ export const CustomerEditForm = ({ formId, data, onSubmit, isPending }: Customer
|
||||
isDirty: form.formState.isDirty,
|
||||
});
|
||||
|
||||
const handleSubmit = (data: CustomerData) => {
|
||||
const handleSubmit = (data: CustomerUpdateData) => {
|
||||
console.log("Datos del formulario:", data);
|
||||
onSubmit?.(data);
|
||||
};
|
||||
|
||||
const handleError = (errors: any) => {
|
||||
const handleError = (errors: FieldErrors<CustomerUpdateData>) => {
|
||||
console.error("Errores en el formulario:", errors);
|
||||
// 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);
|
||||
};
|
||||
|
||||
const {
|
||||
formState: { isDirty, dirtyFields },
|
||||
} = form;
|
||||
|
||||
return (
|
||||
<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)}>
|
||||
<div className='w-full grid grid-cols-1 space-y-8 space-x-8 xl:grid-cols-2'>
|
||||
{/* Información básica */}
|
||||
@ -82,13 +95,13 @@ export const CustomerEditForm = ({ formId, data, onSubmit, isPending }: Customer
|
||||
<FormLabel>{t("form_fields.customer_type.label")}</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
onValueChange={(value) => field.onChange(value === "1")}
|
||||
defaultValue={field.value ? "1" : "0"}
|
||||
className='flex gap-6'
|
||||
>
|
||||
<FormItem className='flex items-center space-x-2'>
|
||||
<FormControl>
|
||||
<RadioGroupItem value={"1"} />
|
||||
<RadioGroupItem value='1' />
|
||||
</FormControl>
|
||||
<FormLabel className='font-normal'>
|
||||
{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'>
|
||||
<FormControl>
|
||||
<RadioGroupItem value={"0"} />
|
||||
<RadioGroupItem value='0' />
|
||||
</FormControl>
|
||||
<FormLabel className='font-normal'>
|
||||
{t("form_fields.customer_type.individual")}
|
||||
@ -188,16 +201,7 @@ export const CustomerEditForm = ({ formId, data, onSubmit, isPending }: Customer
|
||||
label={t("form_fields.country.label")}
|
||||
placeholder={t("form_fields.country.placeholder")}
|
||||
description={t("form_fields.country.description")}
|
||||
items={[
|
||||
{ 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" },
|
||||
]}
|
||||
items={COUNTRY_OPTIONS}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -294,36 +298,12 @@ export const CustomerEditForm = ({ formId, data, onSubmit, isPending }: Customer
|
||||
|
||||
<SelectField
|
||||
control={form.control}
|
||||
name='lang_code'
|
||||
name='language_code'
|
||||
required
|
||||
label={t("form_fields.lang_code.label")}
|
||||
placeholder={t("form_fields.lang_code.placeholder")}
|
||||
description={t("form_fields.lang_code.description")}
|
||||
items={[
|
||||
{ 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" },
|
||||
]}
|
||||
items={LANGUAGE_OPTIONS}
|
||||
/>
|
||||
|
||||
<TextAreaField
|
||||
@ -338,7 +318,7 @@ export const CustomerEditForm = ({ formId, data, onSubmit, isPending }: Customer
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Button type='submit'>Submit</Button>
|
||||
<Button type='submit'>{t("pages.update.submit")}</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@ -3,8 +3,11 @@ import { Button } from "@repo/shadcn-ui/components";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
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 { useTranslation } from "../../i18n";
|
||||
import { CustomerUpdateData } from "../../schemas";
|
||||
import { CustomerEditForm } from "./customer-edit-form";
|
||||
|
||||
export const CustomerUpdate = () => {
|
||||
@ -22,50 +25,29 @@ export const CustomerUpdate = () => {
|
||||
|
||||
// 2) Estado de actualización (mutación)
|
||||
const {
|
||||
mutateAsync: updateAsync,
|
||||
mutateAsync,
|
||||
isPending: isUpdating,
|
||||
isError: isUpdateError,
|
||||
error: updateError,
|
||||
} = useUpdateCustomerMutation(customerId || "");
|
||||
} = useUpdateCustomerMutation();
|
||||
|
||||
// 3) Submit con navegación condicionada por éxito
|
||||
const handleSubmit = async (formData: any) => {
|
||||
const handleSubmit = async (formData: CustomerUpdateData) => {
|
||||
try {
|
||||
await updateAsync(formData); // solo navegamos si no lanza
|
||||
// toast?.({ title: t('pages.update.successTitle'), description: t('pages.update.successMsg') });
|
||||
navigate("/customers/list");
|
||||
const result = await mutateAsync({ id: customerId!, data: formData });
|
||||
|
||||
if (result) {
|
||||
showSuccessToast(t("pages.update.successTitle"), t("pages.update.successMsg"));
|
||||
navigate("/customers/list");
|
||||
}
|
||||
} catch (e) {
|
||||
// toast?.({ variant: 'destructive', title: t('pages.update.errorTitle'), description: (e as Error).message });
|
||||
// No navegamos en caso de error
|
||||
showErrorToast(t("pages.update.errorTitle"), (e as Error).message);
|
||||
} finally {
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingCustomer) {
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
return <CustomerEditorSkeleton />;
|
||||
}
|
||||
|
||||
if (isLoadError) {
|
||||
@ -73,19 +55,14 @@ export const CustomerUpdate = () => {
|
||||
<>
|
||||
<AppBreadcrumb />
|
||||
<AppContent>
|
||||
<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'>
|
||||
{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>
|
||||
<ErrorAlert
|
||||
title={t("pages.update.loadErrorTitle", "No se pudo cargar el cliente")}
|
||||
message={
|
||||
(loadError as Error)?.message ??
|
||||
t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.")
|
||||
}
|
||||
/>
|
||||
|
||||
<div className='flex items-center justify-end'>
|
||||
<BackHistoryButton />
|
||||
</div>
|
||||
@ -94,26 +71,18 @@ export const CustomerUpdate = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!customerData) {
|
||||
if (!customerData)
|
||||
return (
|
||||
<>
|
||||
<AppBreadcrumb />
|
||||
<AppContent>
|
||||
<div className='rounded-lg border bg-card p-6'>
|
||||
<h3 className='text-lg font-semibold'>
|
||||
{t("pages.update.notFoundTitle", "Cliente no encontrado")}
|
||||
</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>
|
||||
<NotFoundCard
|
||||
title={t("pages.update.notFoundTitle", "Cliente no encontrado")}
|
||||
message={t("pages.update.notFoundMsg", "Revisa el identificador o vuelve al listado.")}
|
||||
/>
|
||||
</AppContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -145,19 +114,13 @@ export const CustomerUpdate = () => {
|
||||
</div>
|
||||
{/* Alerta de error de actualización (si ha fallado el último intento) */}
|
||||
{isUpdateError && (
|
||||
<div
|
||||
className='mb-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3'
|
||||
role='alert'
|
||||
aria-live='assertive'
|
||||
>
|
||||
<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>
|
||||
<ErrorAlert
|
||||
title={t("pages.update.errorTitle", "No se pudo guardar los cambios")}
|
||||
message={
|
||||
(updateError as Error)?.message ??
|
||||
t("pages.update.errorMsg", "Revisa los datos e inténtalo de nuevo.")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='flex flex-1 flex-col gap-4 p-4'>
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import {
|
||||
CreateCustomerRequestDTO,
|
||||
CreateCustomerRequestSchema,
|
||||
GetCustomerByIdResponseDTO,
|
||||
UpdateCustomerByIdRequestDTO,
|
||||
UpdateCustomerByIdRequestSchema,
|
||||
@ -6,5 +8,8 @@ import {
|
||||
|
||||
export type CustomerData = GetCustomerByIdResponseDTO;
|
||||
|
||||
export const CustomerDataUpdateUpdateSchema = UpdateCustomerByIdRequestSchema;
|
||||
export type CustomerDataFormUpdateDTO = UpdateCustomerByIdRequestDTO;
|
||||
export const CustomerCreateSchema = CreateCustomerRequestSchema;
|
||||
export type CustomerCreateData = CreateCustomerRequestDTO;
|
||||
|
||||
export const CustomerUpdateSchema = UpdateCustomerByIdRequestSchema;
|
||||
export type CustomerUpdateData = UpdateCustomerByIdRequestDTO;
|
||||
|
||||
@ -201,6 +201,10 @@ export class MoneyValue extends ValueObject<MoneyValueProps> implements IMoneyVa
|
||||
return this.dinero.hasSameAmount(comparator.dinero);
|
||||
}
|
||||
|
||||
hasSameScale(comparator: MoneyValue): boolean {
|
||||
return this.dinero.getPrecision() === comparator.dinero.getPrecision();
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve una cadena con el importe formateado.
|
||||
* Ejemplo: 123456 -> €1,234.56
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, ToasterProps, toast } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
className='toaster group'
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
@ -19,7 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster }
|
||||
export { Toaster, toast };
|
||||
|
||||
@ -1,6 +1,25 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { toast } from "../components/sonner.tsx";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user