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>
</UnsavedWarnProvider>
</TooltipProvider>
<Toaster />
<Toaster position='top-right' />
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</AuthProvider>
</DataSourceProvider>

View File

@ -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>;

View File

@ -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(),
};
}

View File

@ -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(),

View File

@ -1,2 +1,3 @@
export * from "./customer-invoice-items.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 {
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>&nbsp;{{invoice_number}}</strong></p>
<p><span>Fecha:<strong>&nbsp;{{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}}&nbsp;&nbsp;{{recipient.city}}&nbsp;&nbsp;{{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&nbsp;unidad</th>
<th class="py-2">Importe&nbsp;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&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 -
Rodax Software S.L.</p>
<p><strong>Forma de pago:</strong> Domiciliación bancaria</p>
</footer>
</aside>
</footer>
</body>

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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)
);
}

View File

@ -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,
});
}
}

View File

@ -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,
});
}
}

View File

@ -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",

View File

@ -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,

View File

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

View File

@ -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,

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 "./customer-editor-skeleton";
export * from "./customers-layout";
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 { 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 });
},
});
};
}

View File

@ -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) });

View File

@ -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,
});

View File

@ -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>
);

View File

@ -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'>

View File

@ -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;

View File

@ -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

View File

@ -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 };

View File

@ -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,
});
}