This commit is contained in:
David Arranz 2025-09-16 19:29:37 +02:00
parent 7470b15cfe
commit c285c2d897
39 changed files with 1512 additions and 176 deletions

View File

@ -34,9 +34,6 @@ export abstract class ExpressController {
} satisfies ApiErrorContext; } satisfies ApiErrorContext;
const body = toProblemJson(apiError, ctx); const body = toProblemJson(apiError, ctx);
console.trace(body);
return res.type("application/problem+json").status(apiError.status).json(body); return res.type("application/problem+json").status(apiError.status).json(body);
} }

View File

@ -3,3 +3,4 @@ export * from "./use-pagination";
export * from "./use-query-key"; export * from "./use-query-key";
export * from "./use-toggle"; export * from "./use-toggle";
export * from "./use-unsaved-changes-notifier"; export * from "./use-unsaved-changes-notifier";
export * from "./use-url-param-id";

View File

@ -0,0 +1,6 @@
import { useParams } from "react-router-dom";
export const useUrlParamId = (): string | undefined => {
const { id } = useParams<{ id?: string }>();
return id;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File

@ -0,0 +1,254 @@
<html lang="{{lang_code}}">
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css"
referrerpolicy="no-referrer" />
<title>Factura #{{id}}</title>
<style>
:root {
--gray: #6b7280;
--light: #f3f4f6;
--border: #e5e7eb;
}
@page {
margin: 24mm 18mm;
}
body {
font-family: Arial, Helvetica, sans-serif;
color: #000;
font-size: 10.5pt;
line-height: 1.5;
}
.small {
font-size: 9pt;
}
.xsmall {
font-size: 8pt;
}
.muted {
color: var(--gray);
}
.box {
border: 1px solid var(--border);
border-radius: .375rem;
}
.table th {
background: var(--light);
}
.table th,
.table td {
border: 1px solid var(--border);
}
.totals td {
border: 0;
}
.totals tr td:last-child {
text-align: right;
}
#header h1 {
letter-spacing: .03em;
}
#meta td {
padding: .35rem .5rem;
}
@media print {
thead {
display: table-header-group;
}
tfoot {
display: table-footer-group;
}
a[href]:after {
content: "";
}
}
</style>
</head>
<body>
<header id="header" class="mb-4">
<section class="flex items-start justify-between">
<!-- Bloque empresa -->
<aside class="pr-4">
<h1 class="text-2xl font-semibold leading-tight">{{dealer.name}}</h1>
{{#if dealer.logo}}
<img id="dealer-logo" src="{{dealer.logo}}" alt="Logo" class="mt-1 h-10 object-contain" />
{{/if}}
<address id="from" class="not-italic whitespace-pre-line small mt-2">
{{dealer.contact_information}}
</address>
</aside>
<!-- Bloque meta factura -->
<aside class="box p-3 min-w-[260px]">
<table id="meta" class="w-full small">
<tbody>
<tr>
<td class="font-semibold">Factura nº:</td>
<td class="text-right">{{reference}}</td>
</tr>
<tr>
<td class="font-semibold">Fecha:</td>
<td class="text-right">{{date}}</td>
</tr>
<tr>
<td class="font-semibold">Página:</td>
<td class="text-right"><span class="pageNumber"></span> / <span class="totalPages"></span></td>
</tr>
</tbody>
</table>
</aside>
</section>
<!-- Cliente -->
<section class="grid grid-cols-2 gap-4 mt-4 pb-3 border-b border-gray-200">
<aside>
<h3 class="font-semibold mb-1">Cliente</h3>
<address id="to" class="not-italic whitespace-pre-line">{{customer_information}}</address>
</aside>
<aside class="small">
{{#if customer_reference}}
<p><span class="font-semibold">Referencia cliente:</span> {{customer_reference}}</p>
{{/if}}
</aside>
</section>
</header>
<main id="main">
<!-- Detalle líneas -->
<section id="details" class="mt-3">
<table class="table w-full border-collapse">
<thead>
<tr class="text-left">
<th class="px-2 py-2">Concepto</th>
<th class="px-2 py-2 text-right w-24">Cantidad</th>
<th class="px-2 py-2 text-right w-32">Precio unidad</th>
{{#if any_item_has_discount}}
<th class="px-2 py-2 text-right w-24">Dto (%)</th>
{{/if}}
<th class="px-2 py-2 text-right w-36">Importe total</th>
</tr>
</thead>
<tbody>
{{#each items}}
<tr class="align-top">
<td class="px-2 py-2">
<div class="font-medium">{{description}}</div>
{{#if note}}<div class="small muted whitespace-pre-line">{{note}}</div>{{/if}}
</td>
<td class="px-2 py-2 text-right">{{quantity}}</td>
<td class="px-2 py-2 text-right">{{unit_price}}</td>
{{#if ../any_item_has_discount}}
<td class="px-2 py-2 text-right">{{discount}}</td>
{{/if}}
<td class="px-2 py-2 text-right">{{total_price}}</td>
</tr>
{{/each}}
</tbody>
<tfoot>
<tr>
<td colspan="5" class="px-2 py-1 xsmall muted">* Precios en {{currency}}.</td>
</tr>
</tfoot>
</table>
</section>
<!-- Resumen / totales -->
<section id="resume" class="grid grid-cols-2 gap-6 mt-6">
<!-- Notas y forma de pago -->
<aside>
{{#if payment_method}}
<p class="small"><span class="font-semibold">Forma de pago:</span> {{payment_method}}</p>
{{/if}}
{{#if notes}}
<div class="mt-2 small"><span class="font-semibold">Notas:</span> {{notes}}</div>
{{/if}}
{{!-- Bloque especial Domiciliación bancaria --}}
{{#if payment_is_direct_debit}}
<div class="mt-4 box p-3 small">
<div class="font-semibold mb-1 uppercase tracking-wide">DOMICILIACIÓN BANCARIA</div>
<div class="whitespace-pre-line">{{direct_debit_text}}</div>
</div>
{{/if}}
</aside>
<!-- Totals box -->
<aside class="justify-self-end w-full max-w-md">
<table class="w-full totals">
<tbody>
{{#if subtotal_price}}
<tr>
<td class="py-1">Importe neto</td>
<td class="py-1 text-right">{{subtotal_price}}</td>
</tr>
{{/if}}
{{#if discount_price}}
<tr>
<td class="py-1">% Descuento ({{discount.amount}})</td>
<td class="py-1 text-right">-{{discount_price}}</td>
</tr>
{{/if}}
<tr class="border-t border-gray-200">
<td class="py-1 font-medium">Base imponible</td>
<td class="py-1 text-right font-medium">{{before_tax_price}}</td>
</tr>
<tr>
<td class="py-1">IVA {{tax}}</td>
<td class="py-1 text-right">{{tax_price}}</td>
</tr>
<tr class="border-t-2 border-gray-300">
<td class="py-2 text-lg font-semibold">Total factura</td>
<td class="py-2 text-right text-lg font-semibold">{{total_price}}</td>
</tr>
</tbody>
</table>
</aside>
</section>
<!-- Términos legales -->
<section id="legal_terms" class="mt-6">
<p class="xsmall muted whitespace-pre-line">{{quote.default_legal_terms}}</p>
</section>
</main>
<footer id="footer" class="mt-8 pt-3 border-t border-gray-200">
<div class="grid grid-cols-2 gap-4 small">
<div>
<span class="font-semibold">{{dealer.name}}</span>
<div class="whitespace-pre-line">{{dealer.footer_information}}</div>
</div>
<div class="text-right">
{{#if dealer.website}}<div><a href="{{dealer.website}}">{{dealer.website}}</a></div>{{/if}}
{{#if dealer.email}}<div>{{dealer.email}}</div>{{/if}}
{{#if dealer.phone}}<div>{{dealer.phone}}</div>{{/if}}
</div>
</div>
</footer>
{{!-- Helpers opcionales esperados por la plantilla --}}
{{!--
any_item_has_discount: boolean precomputado en tu código
payment_is_direct_debit: boolean si forma de pago es domiciliación
direct_debit_text: texto para el bloque de domiciliación bancaria
currency: ISO o símbolo (EUR, €, etc.)
--}}
</body>
</html>

View File

@ -1,24 +1,87 @@
<html lang="{{lang_code}}"> <!DOCTYPE html>
<html lang="es">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css"
referrerpolicy="no-referrer" /> referrerpolicy="no-referrer" />
<title>Presupuesto #{{id}}</title> <title>Factura F26200</title>
<style> <style>
body { body {
font-family: Arial, Helvetica, sans-serif; font-family: Arial, sans-serif;
color: #000; margin: 40px;
color: #333;
font-size: 11pt; font-size: 11pt;
line-height: 1.6; line-height: 1.6;
} }
#header { header {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
margin-bottom: 0; margin-bottom: 0;
padding-bottom: 0; padding-bottom: 0;
} }
#footer {} .company-info,
.invoice-meta {
width: 48%;
}
.invoice-meta {
text-align: right;
}
h1 {
font-size: 20px;
margin-bottom: 5px;
}
.contact {
font-size: 14px;
margin-top: 10px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 25px;
}
table th,
table td {
border: 1px solid #ccc;
padding: 10px;
text-align: left;
vertical-align: top;
}
table th {
background-color: #f5f5f5;
}
.totals {
margin-top: 20px;
width: 100%;
}
.totals td {
padding: 5px 10px;
}
.totals td.label {
text-align: right;
font-weight: bold;
}
footer {
margin-top: 40px;
font-size: 12px;
}
.highlight {
background-color: #eef;
}
@media print { @media print {
thead { thead {
@ -34,9 +97,192 @@
<body> <body>
<header>
<footer id="footer" class="mt-4"> <aside class="flex items-start mb-4 w-full">
<aside><img src="https://uecko.com/assets/img/uecko-footer_logos.jpg" class="w-full" /></aside> <!-- Bloque IZQUIERDO: imagen arriba + texto abajo, alineado a la izquierda -->
<div class="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 gap-30">
<div class="w-[30%] bg-amber-400 p-3 text-sm leading-tight">
<p><span class="font-semibold">Factura nº:</span> {{reference}}</p>
<p><span class="font-semibold">Fecha:</span> {{date}}</p>
</div>
<div class="w-[70%] bg-amber-700 p-3 text-sm leading-tight text-white">
<h2 class="font-semibold uppercase mb-1">{{customer.name}}</h2>
<p>AAAA</p>
<p>BBBBBB</p>
<p>CCCCC</p>
<p>DDDDD</p>
</div>
</div>
</div>
<!-- Bloque DERECHO: logo2 arriba y texto DEBAJO -->
<div class="ml-auto flex flex-col items-end text-right">
<img src="https://rodax-software.com/images/logo2.jpg" alt="Logo secundario"
class="block h-5 w-auto md:h-8 mb-1" />
<div class="not-italic text-xs leading-tight">
<p>Telf: 91 785 02 47 / 686 62 10 59</p>
<p><a href="mailto:info@rodax-software.com" class="hover:underline">info@rodax-software.com</a></p>
<p><a href="https://www.rodax-software.com" target="_blank" rel="noopener"
class="hover:underline">www.rodax-software.com</a></p>
</div>
</div>
</aside>
</header>
<table>
<thead>
<tr>
<th>Concepto</th>
<th>Cantidad</th>
<th>Precio unidad</th>
<th>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>
<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>
<footer>
<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> </footer>
</body> </body>

View File

@ -0,0 +1,152 @@
<html lang="{{lang_code}}">
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css"
referrerpolicy="no-referrer" />
<title>Presupuesto #{{id}}</title>
<style>
body {
font-family: Arial, Helvetica, sans-serif;
color: #000;
font-size: 11pt;
line-height: 1.6;
}
#header {
margin-bottom: 0;
padding-bottom: 0;
}
#footer {}
@media print {
thead {
display: table-header-group;
}
tfoot {
display: table-footer-group;
}
}
</style>
</head>
<body>
<header id="header">
<aside class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold">DISTRIBUIDOR OFICIAL</h3>
<div id="logo" class="w-32">
<svg viewBox="0 0 336 133" fill="none" xmlns="http://www.w3.org/2000/svg" class="uecko-logo">
<path
d="M49.7002 83.0001H66.9002V22.5001H49.7002V56.2001C49.7002 64.3001 45.5002 68.5001 39.0002 68.5001C32.5002 68.5001 28.6002 64.3001 28.6002 56.2001V22.5001H0.700195V33.2001H11.4002V61.6001C11.4002 75.5001 19.0002 84.1001 31.9002 84.1001C40.6002 84.1001 45.7002 79.5001 49.6002 74.4001V83.0001H49.7002ZM120.6 48.0001H94.8002C96.2002 40.2001 100.8 35.1001 107.9 35.1001C115.1 35.2001 119.6 40.3001 120.6 48.0001ZM137.1 58.7001C137.2 57.1001 137.3 56.1001 137.3 54.4001V54.2001C137.3 37.0001 128 21.4001 107.8 21.4001C90.2002 21.4001 77.9002 35.6001 77.9002 52.9001V53.1001C77.9002 71.6001 91.3002 84.4001 109.5 84.4001C120.4 84.4001 128.6 80.1001 134.2 73.1001L124.4 64.4001C119.7 68.8001 115.5 70.6001 109.7 70.6001C102 70.6001 96.6002 66.5001 94.9002 58.7001H137.1ZM162.2 52.9001V52.7001C162.2 43.8001 168.3 36.2001 176.9 36.2001C183 36.2001 186.8 38.8001 190.7 42.9001L201.2 31.6001C195.6 25.3001 188.4 21.4001 177 21.4001C158.5 21.4001 145.3 35.6001 145.3 52.9001V53.1001C145.3 70.4001 158.6 84.4001 176.8 84.4001C188.9 84.4001 195.6 79.8001 201.5 73.3001L191.5 63.1001C187.3 67.1001 183.4 69.5001 177.6 69.5001C168.2 69.6001 162.2 62.1001 162.2 52.9001ZM269.1 83.0001L245.3 46.3001L268.3 22.5001H247.8L227.7 44.5001V0.600098H210.5V83.0001H227.7V64.6001L233.7 58.3001L249.5 83.0001H269.1ZM318.5 53.1001C318.5 62.0001 312.6 69.6001 302.8 69.6001C293.3 69.6001 286.9 61.8001 286.9 52.9001V52.7001C286.9 43.8001 292.8 36.2001 302.6 36.2001C312.1 36.2001 318.5 44.0001 318.5 52.9001V53.1001ZM335.4 52.9001V52.7001C335.4 35.3001 321.5 21.4001 302.8 21.4001C284 21.4001 270 35.5001 270 52.9001V53.1001C270 70.5001 283.9 84.4001 302.6 84.4001C321.4 84.4001 335.4 70.3001 335.4 52.9001Z"
fill="black" class="uecko-logo"></path>
</svg>
</div>
</aside>
<section class="flex pb-2 space-x-4">
<img id="dealer-logo" src={{dealer.logo}} alt="Logo distribuidor" />
<address class="text-base not-italic font-medium whitespace-pre-line" id="from">{{dealer.contact_information}}
</address>
</section>
<section class="grid grid-cols-2 gap-4 pb-4 mb-4 border-b">
<aside>
<p class="text-sm"><strong>Presupuesto nº:</strong> {{reference}}</p>
<p class="text-sm"><strong>Fecha:</strong> {{date}}</p>
<p class="text-sm"><strong>Validez:</strong> {{validity}}</p>
<p class="text-sm"><strong>Vendedor:</strong> {{dealer.name}}</p>
<p class="text-sm"><strong>Referencia cliente:</strong> {{customer_reference}}</p>
</aside>
<address class="text-base not-italic font-semibold whitespace-pre-line" id="to">{{customer_information}}
</address>
</section>
<aside class="flex items-center justify-between mb-4">
<h3 class="text-2xl font-normal">PRESUPUESTO</h3>
<div id="header-pagination">
Página <span class="pageNumber"></span> de <span class="totalPages"></span>
</div>
</aside>
</header>
<main id="main">
<section id="details">
<table class="table-header">
<thead>
<tr>
<th class="px-2 py-2 text-right border">Cant.</th>
<th class="px-2 py-2 border">Descripción</th>
<th class="px-2 py-2 text-right border">Prec. Unitario</th>
<th class="px-2 py-2 text-right border">Subtotal</th>
<th class="px-2 py-2 text-right border">Dto (%)</th>
<th class="px-2 py-2 text-right border">Importe total</th>
</tr>
</thead>
<tbody class="table-body">
{{#each items}}
<tr class="text-sm border-b">
<td class="content-start px-2 py-2 text-right">{{quantity}}</td>
<td class="px-2 py-2 font-medium">{{description}}</td>
<td class="content-start px-2 py-2 text-right">{{unit_price}}</td>
<td class="content-start px-2 py-2 text-right">{{subtotal_price}}</td>
<td class="content-start px-2 py-2 text-right">{{discount}}</td>
<td class="content-start px-2 py-2 text-right">{{total_price}}</td>
</tr>
{{/each}}
</tbody>
</table>
</section>
<section id="resume" class="flex items-center justify-between pb-4 mb-4">
<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="min-w-full bg-transparent">
<tbody>
<tr class="border-b">
<td class="px-4 py-2">Importe neto</td>
<td class="px-4 py-2"></td>
<td class="px-4 py-2 text-right border">{{subtotal_price}}</td>
</tr>
<tr class="border-b">
<td class="px-4 py-2">% Descuento</td>
<td class="px-4 py-2 text-right border">{{discount.amount}}</td>
<td class="px-4 py-2 text-right border">{{discount_price}}</td>
</tr>
<tr class="border-b">
<td class="px-4 py-2">Base imponible</td>
<td class="px-4 py-2"></td>
<td class="px-4 py-2 text-right border">{{before_tax_price}}</td>
</tr>
<tr class="border-b">
<td class="px-4 py-2">% IVA</td>
<td class="px-4 py-2 text-right border">{{tax}}</td>
<td class="px-4 py-2 text-right border">{{tax_price}}</td>
</tr>
<tr class="border-b">
<td class="px-4 py-2">Importe total</td>
<td class="px-4 py-2"></td>
<td class="px-4 py-2 text-right border">{{total_price}}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="legal_terms">
<p class="text-xs text-gray-500">{{quote.default_legal_terms}}</p>
</section>
</main>
<footer id="footer" class="mt-4">
<aside><img src="https://uecko.com/assets/img/uecko-footer_logos.jpg" class="w-full" /></aside>
</footer>
</body>
</html>

View File

@ -6,16 +6,12 @@ import {
Province, Province,
Street, Street,
TINNumber, TINNumber,
ValidationErrorDetail,
extractOrPushError,
maybeFromNullableVO, maybeFromNullableVO,
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { import { IQueryMapperWithBulk, MapperParamsType, SequelizeQueryMapper } from "@erp/core/api";
IQueryMapperWithBulk,
MapperParamsType,
SequelizeQueryMapper,
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { InvoiceRecipient } from "../../../domain"; import { InvoiceRecipient } from "../../../domain";

View File

@ -3,3 +3,4 @@ export * from "./customer-invoices-list.request.dto";
export * from "./delete-customer-invoice-by-id.request.dto"; export * from "./delete-customer-invoice-by-id.request.dto";
export * from "./get-customer-invoice-by-id.request.dto"; export * from "./get-customer-invoice-by-id.request.dto";
export * from "./report-customer-invoice-by-id.request.dto"; export * from "./report-customer-invoice-by-id.request.dto";
export * from "./update-customer-invoice-by-id.request.dto";

View File

@ -0,0 +1,9 @@
import * as z from "zod/v4";
export const UpdateCustomerInvoiceByIdParamsRequestSchema = z.object({
customer_id: z.string(),
});
export const UpdateCustomerByIdRequestSchema = z.object({});
export type UpdateCustomerByIdRequestDTO = z.infer<typeof UpdateCustomerByIdRequestSchema>;

View File

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

View File

@ -3,7 +3,7 @@ import { CustomerListDTO } from "@erp/customer-invoices/api/infrastructure";
import { Criteria } from "@repo/rdx-criteria/server"; import { Criteria } from "@repo/rdx-criteria/server";
import { toEmptyString } from "@repo/rdx-ddd"; import { toEmptyString } from "@repo/rdx-ddd";
import { Collection } from "@repo/rdx-utils"; import { Collection } from "@repo/rdx-utils";
import { CustomerListResponsetDTO } from "../../../../common/dto"; import { ListCustomersResponseDTO } from "../../../../common/dto";
export class ListCustomersPresenter extends Presenter { export class ListCustomersPresenter extends Presenter {
protected _mapCustomer(customer: CustomerListDTO) { protected _mapCustomer(customer: CustomerListDTO) {
@ -54,7 +54,7 @@ export class ListCustomersPresenter extends Presenter {
toOutput(params: { toOutput(params: {
customers: Collection<CustomerListDTO>; customers: Collection<CustomerListDTO>;
criteria: Criteria; criteria: Criteria;
}): CustomerListResponsetDTO { }): ListCustomersResponseDTO {
const { customers, criteria } = params; const { customers, criteria } = params;
const items = customers.map((customer) => this._mapCustomer(customer)); const items = customers.map((customer) => this._mapCustomer(customer));

View File

@ -3,7 +3,7 @@ import { Criteria } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize"; import { Transaction } from "sequelize";
import { CustomerListResponsetDTO } from "../../../common/dto"; import { ListCustomersResponseDTO } from "../../../common/dto";
import { CustomerService } from "../../domain"; import { CustomerService } from "../../domain";
import { ListCustomersPresenter } from "../presenters"; import { ListCustomersPresenter } from "../presenters";
@ -21,7 +21,7 @@ export class ListCustomersUseCase {
public execute( public execute(
params: ListCustomersUseCaseInput params: ListCustomersUseCaseInput
): Promise<Result<CustomerListResponsetDTO, Error>> { ): Promise<Result<ListCustomersResponseDTO, Error>> {
const { criteria, companyId } = params; const { criteria, companyId } = params;
const presenter = this.presenterRegistry.getPresenter({ const presenter = this.presenterRegistry.getPresenter({
resource: "customer", resource: "customer",

View File

@ -1,5 +1,5 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api"; import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { UpdateCustomerRequestDTO } from "../../../../common/dto"; import { UpdateCustomerByIdRequestDTO } from "../../../../common/dto";
import { UpdateCustomerUseCase } from "../../../application"; import { UpdateCustomerUseCase } from "../../../application";
export class UpdateCustomerController extends ExpressController { export class UpdateCustomerController extends ExpressController {
@ -15,7 +15,7 @@ export class UpdateCustomerController extends ExpressController {
return this.forbiddenError("Tenant ID not found"); return this.forbiddenError("Tenant ID not found");
} }
const { customer_id } = this.req.params; const { customer_id } = this.req.params;
const dto = this.req.body as UpdateCustomerRequestDTO; const dto = this.req.body as UpdateCustomerByIdRequestDTO;
const result = await this.useCase.execute({ customer_id, companyId, dto }); const result = await this.useCase.execute({ customer_id, companyId, dto });

View File

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

View File

@ -1,4 +1,4 @@
export * from "./create-customer.result.dto"; export * from "./create-customer.result.dto";
export * from "./customer-list.response.dto";
export * from "./get-customer-by-id.response.dto"; export * from "./get-customer-by-id.response.dto";
export * from "./list-customers.response.dto";
export * from "./update-customer-by-id.response.dto"; export * from "./update-customer-by-id.response.dto";

View File

@ -1,7 +1,7 @@
import { MetadataSchema, createListViewResponseSchema } from "@erp/core"; import { MetadataSchema, createListViewResponseSchema } from "@erp/core";
import * as z from "zod/v4"; import * as z from "zod/v4";
export const CustomerListResponseSchema = createListViewResponseSchema( export const ListCustomersResponseSchema = createListViewResponseSchema(
z.object({ z.object({
id: z.uuid(), id: z.uuid(),
company_id: z.uuid(), company_id: z.uuid(),
@ -34,4 +34,4 @@ export const CustomerListResponseSchema = createListViewResponseSchema(
}) })
); );
export type CustomerListResponsetDTO = z.infer<typeof CustomerListResponseSchema>; export type ListCustomersResponseDTO = z.infer<typeof ListCustomersResponseSchema>;

View File

@ -18,15 +18,19 @@ export const UpdateCustomerByIdResponseSchema = z.object({
postal_code: z.string(), postal_code: z.string(),
country: z.string(), country: z.string(),
email: z.string(), email_primary: z.string(),
phone: z.string(), email_secondary: z.string(),
phone_primary: z.string(),
phone_secondary: z.string(),
mobile_primary: z.string(),
mobile_secondary: z.string(),
fax: z.string(), fax: z.string(),
website: z.string(), website: z.string(),
legal_record: z.string(), legal_record: z.string(),
default_taxes: z.string(), default_taxes: z.string(),
status: z.string(),
language_code: z.string(), language_code: z.string(),
currency_code: z.string(), currency_code: z.string(),

View File

@ -10,7 +10,11 @@
"name": "Name", "name": "Name",
"trade_name": "Trade name", "trade_name": "Trade name",
"status": "Status", "status": "Status",
"email": "Email" "email": "Email",
"phone": "Phone",
"city": "City",
"tin": "TIN",
"mobile": "Mobile"
} }
}, },
"create": { "create": {
@ -71,15 +75,37 @@
"placeholder": "Select country", "placeholder": "Select country",
"description": "The country of the customer" "description": "The country of the customer"
}, },
"email": { "email_primary": {
"label": "Email", "label": "Primary email",
"placeholder": "Enter email", "placeholder": "Enter primary email",
"description": "The email address of the customer" "description": "The primary email address of the customer"
}, },
"phone": { "email_secondary": {
"label": "Phone", "label": "Secondary email",
"placeholder": "Enter phone number", "placeholder": "Enter secondary email",
"description": "The phone number of the customer" "description": "The secondary email address of the customer"
},
"phone_primary": {
"label": "Primary phone",
"placeholder": "Enter primary phone number",
"description": "The primary phone number of the customer"
},
"phone_secondary": {
"label": "Secondary phone",
"placeholder": "Enter secondary phone number ",
"description": "The secondary phone number of the customer"
},
"mobile_primary": {
"label": "Primary mobile",
"placeholder": "Enter primary mobile number",
"description": "The primary mobile number of the customer"
},
"mobile_secondary": {
"label": "Secondary mobile",
"placeholder": "Enter secondary mobile number",
"description": "The secondary mobile number of the customer"
}, },
"fax": { "fax": {
"label": "Fax", "label": "Fax",

View File

@ -10,7 +10,11 @@
"name": "Nombre", "name": "Nombre",
"trade_name": "Nombre comercial", "trade_name": "Nombre comercial",
"status": "Estado", "status": "Estado",
"email": "Correo electrónico" "email": "Correo electrónico",
"phone": "Teléfono",
"city": "Ciudad",
"tin": "Nº Id.",
"mobile": "Móvil"
} }
}, },
"create": { "create": {
@ -71,16 +75,40 @@
"placeholder": "Seleccione el país", "placeholder": "Seleccione el país",
"description": "El país del cliente" "description": "El país del cliente"
}, },
"email": {
"label": "Correo electrónico", "email_primary": {
"label": "Email principal",
"placeholder": "Ingrese el correo electrónico", "placeholder": "Ingrese el correo electrónico",
"description": "La dirección de correo electrónico del cliente" "description": "La dirección de correo electrónico principal del cliente"
}, },
"phone": { "email_secondary": {
"label": "Email secundario",
"placeholder": "Ingrese el correo electrónico",
"description": "La dirección de correo electrónico secundario del clientºe"
},
"phone_primary": {
"label": "Teléfono", "label": "Teléfono",
"placeholder": "Ingrese el número de teléfono", "placeholder": "Ingrese el número de teléfono",
"description": "El número de teléfono del cliente" "description": "El número de teléfono del cliente"
}, },
"phone_secondary": {
"label": "Teléfono secundario",
"placeholder": "Ingrese el número de teléfono secundario",
"description": "El número de teléfono secundario del cliente"
},
"mobile_primary": {
"label": "Teléfono",
"placeholder": "Ingrese el número de teléfono",
"description": "El número de teléfono del cliente"
},
"mobile_secondary": {
"label": "Teléfono secundario",
"placeholder": "Ingrese el número de teléfono secundario",
"description": "El número de teléfono secundario del cliente"
},
"fax": { "fax": {
"label": "Fax", "label": "Fax",
"placeholder": "Ingrese el número de fax", "placeholder": "Ingrese el número de fax",

View File

@ -18,10 +18,10 @@ import {
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { Building, Calendar, Mail, MapPin, Phone, Plus, User } from "lucide-react"; import { Building, Calendar, Mail, MapPin, Phone, Plus, User } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { CustomerListResponsetDTO } from "../../common"; import { ListCustomersResponseDTO } from "../../common";
import { useCustomersQuery } from "../hooks"; import { useCustomersQuery } from "../hooks";
type Customer = CustomerListResponsetDTO["items"][number]; type Customer = ListCustomersResponseDTO["items"][number];
const columns: TableColumn<Customer>[] = [ const columns: TableColumn<Customer>[] = [
{ {

View File

@ -1,59 +1,124 @@
import { useState } from "react";
import { AG_GRID_LOCALE_ES } from "@ag-grid-community/locale"; import { AG_GRID_LOCALE_ES } from "@ag-grid-community/locale";
// Grid import type { ValueFormatterParams } from "ag-grid-community";
import type { ColDef, GridOptions, ValueFormatterParams } from "ag-grid-community"; import {
import { AllCommunityModule, ModuleRegistry } from "ag-grid-community"; AllCommunityModule,
ColDef,
GridOptions,
ModuleRegistry,
SizeColumnsToContentStrategy,
SizeColumnsToFitGridStrategy,
SizeColumnsToFitProvidedWidthStrategy,
} from "ag-grid-community";
import { useMemo, useState } from "react";
ModuleRegistry.registerModules([AllCommunityModule]); import { Button } from "@repo/shadcn-ui/components";
// Core CSS
import { AgGridReact } from "ag-grid-react"; import { AgGridReact } from "ag-grid-react";
import { ChevronRightIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useCustomersQuery } from "../hooks"; import { useCustomersQuery } from "../hooks";
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
import { CustomerStatusBadge } from "./customer-status-badge"; import { CustomerStatusBadge } from "./customer-status-badge";
ModuleRegistry.registerModules([AllCommunityModule]);
// Create new GridExample component // Create new GridExample component
export const CustomersListGrid = () => { export const CustomersListGrid = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading, isPending, isError, error } = useCustomersQuery({}); const navigate = useNavigate();
const {
data: customersData,
isLoading: isLoadingCustomers,
isError: isLoadError,
error: loadError,
} = useCustomersQuery();
// Column Definitions: Defines & controls grid columns. // Column Definitions: Defines & controls grid columns.
const [colDefs] = useState<ColDef[]>([ const [colDefs] = useState<ColDef[]>([
{ field: "name", headerName: t("pages.list.grid_columns.name"), minWidth: 300 },
{
field: "tin",
headerName: t("pages.list.grid_columns.tin"),
maxWidth: 120,
},
{
field: "city",
headerName: t("pages.list.grid_columns.city"),
},
{
field: "email_primary",
headerName: t("pages.list.grid_columns.email"),
},
{
field: "phone_primary",
headerName: t("pages.list.grid_columns.phone"),
maxWidth: 120,
},
{
field: "mobile_primary",
headerName: t("pages.list.grid_columns.mobile"),
maxWidth: 120,
},
{ {
field: "status", field: "status",
headerName: t("pages.list.grid_columns.status"), headerName: t("pages.list.grid_columns.status"),
maxWidth: 125,
cellRenderer: (params: ValueFormatterParams) => { cellRenderer: (params: ValueFormatterParams) => {
return <CustomerStatusBadge status={params.value} />; return <CustomerStatusBadge status={params.value} />;
}, },
}, },
{ field: "name", headerName: t("pages.list.grid_columns.name") },
{ field: "trade_name", headerName: t("pages.list.grid_columns.trade_name") },
{ {
field: "email", colId: "actions",
headerName: t("pages.list.grid_columns.email"), headerName: t("pages.list.grid_columns.actions", "Actions"),
cellRenderer: (params: ValueFormatterParams) => {
const { data } = params;
return (
<Button
variant='secondary'
size='icon'
className='size-8'
onClick={() => {
navigate(`${data.id}/edit`);
}}
>
<ChevronRightIcon />
</Button>
);
},
}, },
]); ]);
const gridOptions: GridOptions = { const autoSizeStrategy = useMemo<
columnDefs: colDefs, | SizeColumnsToFitGridStrategy
defaultColDef: { | SizeColumnsToFitProvidedWidthStrategy
editable: true, | SizeColumnsToContentStrategy
flex: 1, >(() => {
minWidth: 100, return {
filter: false, type: "fitGridWidth",
sortable: false, defaultMinWidth: 100,
resizable: true, columnLimits: [{ colId: "actions", minWidth: 75, maxWidth: 75 }],
}, };
pagination: true, }, []);
paginationPageSize: 10,
paginationPageSizeSelector: [10, 20, 30, 50], const gridOptions: GridOptions = useMemo(
localeText: AG_GRID_LOCALE_ES, () => ({
rowSelection: { mode: "multiRow" }, columnDefs: colDefs,
}; autoSizeStrategy: autoSizeStrategy,
defaultColDef: {
editable: false,
flex: 1,
filter: false,
sortable: false,
resizable: true,
},
pagination: true,
paginationPageSize: 10,
paginationPageSizeSelector: [10, 20, 30, 50],
localeText: AG_GRID_LOCALE_ES,
}),
[autoSizeStrategy, colDefs]
);
// Container: Defines the grid's theme & dimensions. // Container: Defines the grid's theme & dimensions.
return ( return (
@ -64,7 +129,11 @@ export const CustomersListGrid = () => {
width: "100%", width: "100%",
}} }}
> >
<AgGridReact rowData={data?.items ?? []} loading={isLoading || isPending} {...gridOptions} /> <AgGridReact
rowData={customersData?.items ?? []}
loading={isLoadingCustomers}
{...gridOptions}
/>
</div> </div>
); );
}; };

View File

@ -1,6 +1,7 @@
import { ModuleClientParams } from "@erp/core/client"; import { ModuleClientParams } from "@erp/core/client";
import { lazy } from "react"; import { lazy } from "react";
import { Outlet, RouteObject } from "react-router-dom"; import { Outlet, RouteObject } from "react-router-dom";
import { CustomerUpdate } from "./pages/update";
// Lazy load components // Lazy load components
const CustomersLayout = lazy(() => const CustomersLayout = lazy(() =>
@ -43,6 +44,7 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
{ path: "", index: true, element: <CustomersList /> }, // index { path: "", index: true, element: <CustomersList /> }, // index
{ path: "list", element: <CustomersList /> }, { path: "list", element: <CustomersList /> },
{ path: "create", element: <CustomerAdd /> }, { path: "create", element: <CustomerAdd /> },
{ path: ":id/edit", element: <CustomerUpdate /> },
// //
/*{ path: "create", element: <CustomersList /> }, /*{ path: "create", element: <CustomersList /> },

View File

@ -1 +1,5 @@
export * from "./use-create-customer-mutation";
export * from "./use-customer-query";
export * from "./use-customers-context";
export * from "./use-customers-query"; export * from "./use-customers-query";
export * from "./use-update-customer-mutation";

View File

@ -1,13 +1,14 @@
import { useDataSource, useQueryKey } from "@erp/core/hooks"; import { useDataSource, useQueryKey } from "@erp/core/hooks";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { UpdateCustomerRequestDTO } from "../../common/dto"; import { UpdateCustomerByIdRequestDTO } from "../../common/dto";
export const useCreateCustomerMutation = () => { export const useCreateCustomerMutation = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const dataSource = useDataSource(); const dataSource = useDataSource();
const keys = useQueryKey(); const keys = useQueryKey();
return useMutation<UpdateCustomerRequestDTO, Error, Partial<UpdateCustomerRequestDTO>>({ return useMutation<UpdateCustomerByIdRequestDTO, Error, Partial<UpdateCustomerByIdRequestDTO>>({
mutationKey: ["customer:create"],
mutationFn: (data) => { mutationFn: (data) => {
console.log(data); console.log(data);
return dataSource.createOne("customers", data); return dataSource.createOne("customers", data);

View File

@ -0,0 +1,40 @@
import { useDataSource } from "@erp/core/hooks";
import { GetCustomerByIdResponseDTO } from "@erp/customer-invoices/common";
import { type QueryKey, type UseQueryOptions, useQuery } from "@tanstack/react-query";
export const CUSTOMER_QUERY_KEY = (id: string): QueryKey => ["customer", id] as const;
type Options = Omit<
UseQueryOptions<
GetCustomerByIdResponseDTO,
Error,
GetCustomerByIdResponseDTO,
ReturnType<typeof CUSTOMER_QUERY_KEY>
>,
"queryKey" | "queryFn" | "enabled"
> & {
enabled?: boolean;
};
export function useCustomerQuery(customerId?: string, options?: Options) {
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && !!customerId;
return useQuery<
GetCustomerByIdResponseDTO,
Error,
GetCustomerByIdResponseDTO,
ReturnType<typeof CUSTOMER_QUERY_KEY>
>({
queryKey: CUSTOMER_QUERY_KEY(customerId ?? "unknown"),
enabled,
queryFn: async (context) => {
if (!customerId) throw new Error("customerId is required");
const { signal } = context;
const customer = await dataSource.getOne("customers", customerId);
return customer as GetCustomerByIdResponseDTO;
},
...options,
});
}

View File

@ -1,21 +1,22 @@
import { useDataSource, useQueryKey } from "@erp/core/hooks"; import { useDataSource, useQueryKey } from "@erp/core/hooks";
import { ListCustomersResponseDTO } from "@erp/customer-invoices/common";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { CustomerListResponsetDTO } from "../../common/dto";
// Obtener todas las facturas // Obtener todas las facturas
export const useCustomersQuery = (params: any) => { export const useCustomersQuery = (params?: any) => {
const dataSource = useDataSource(); const dataSource = useDataSource();
const keys = useQueryKey(); const keys = useQueryKey();
return useQuery<CustomerListResponsetDTO>({ return useQuery<ListCustomersResponseDTO>({
queryKey: keys().data().resource("customers").action("list").params(params).get(), queryKey: keys().data().resource("customers").action("list").params(params).get(),
queryFn: (context) => { queryFn: async (context) => {
console.log(dataSource.getBaseUrl());
const { signal } = context; const { signal } = context;
return dataSource.getList<CustomerListResponsetDTO>("customers", { const customers = await dataSource.getList("customers", {
signal, signal,
...params, ...params,
}); });
return customers as ListCustomersResponseDTO;
}, },
}); });
}; };

View File

@ -1,75 +0,0 @@
import { useDataSource, useQueryKey } from "@erp/core/hooks";
import { IListCustomersResponseDTO } from "@erp/customers/common/dto";
export type UseCustomersListParams = Omit<IGetListDataProviderParams, "filters" | "resource"> & {
status?: string;
enabled?: boolean;
queryOptions?: Record<string, unknown>;
};
export type UseCustomersListResponse = UseListQueryResult<
IListResponseDTO<IListCustomersResponseDTO>,
unknown
>;
export type UseCustomersGetParamsType = {
enabled?: boolean;
queryOptions?: Record<string, unknown>;
};
export type UseCustomersReportParamsType = {
enabled?: boolean;
queryOptions?: Record<string, unknown>;
};
export const useCustomers = () => {
const actions = {
/**
* Hook para obtener la lista de facturas
* @param params - Parámetros para la consulta de la lista de facturas
* @returns - Respuesta de la consulta de la lista de facturas
*/
useList: (params: UseCustomersListParams): UseCustomersListResponse => {
const dataSource = useDataSource();
const keys = useQueryKey();
const {
pagination,
status = "draft",
quickSearchTerm = undefined,
enabled = true,
queryOptions,
} = params;
return useList({
queryKey: keys().data().resource("customers").action("list").params(params).get(),
queryFn: () => {
return dataSource.getList({
resource: "customers",
quickSearchTerm,
filters:
status !== "all"
? [
{
field: "status",
operator: "eq",
value: status,
},
]
: [
{
field: "status",
operator: "ne",
value: "archived",
},
],
pagination,
});
},
enabled,
queryOptions,
});
},
};
return actions;
};

View File

@ -0,0 +1,38 @@
import { useDataSource } from "@erp/core/hooks";
import {
UpdateCustomerByIdRequestDTO,
UpdateCustomerByIdResponseDTO,
} from "@erp/customer-invoices/common";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CUSTOMER_QUERY_KEY } from "./use-customer-query";
export const CUSTOMERS_LIST_KEY = ["customers"] as const;
type MutationDeps = {};
export function useUpdateCustomerMutation(customerId: string, deps?: MutationDeps) {
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;
},
onSuccess: (updated) => {
// Refresca inmediatamente el detalle
queryClient.setQueryData<UpdateCustomerByIdResponseDTO>(
CUSTOMER_QUERY_KEY(customerId),
updated
);
// Otra opción es invalidar el detalle para forzar refetch:
// queryClient.invalidateQueries({ queryKey: CUSTOMER_QUERY_KEY(customerId) });
// Invalida el listado para refrescar desde servidor
queryClient.invalidateQueries({ queryKey: CUSTOMERS_LIST_KEY });
},
});
}

View File

@ -1,4 +1,4 @@
import { AppBreadcrumb, AppContent, BackHistoryButton } from "@repo/rdx-ui/components"; import { AppBreadcrumb, AppContent, BackHistoryButton, ButtonGroup } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components"; import { Button } from "@repo/shadcn-ui/components";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@ -62,12 +62,12 @@ export const CustomerCreate = () => {
{t("pages.create.description")} {t("pages.create.description")}
</p> </p>
</div> </div>
<div className='flex items-center justify-end mb-4'> <ButtonGroup>
<BackHistoryButton /> <BackHistoryButton />
<Button type='submit' className='cursor-pointer'> <Button type='submit' className='cursor-pointer'>
{t("pages.create.submit")} {t("pages.create.submit")}
</Button> </Button>
</div> </ButtonGroup>
</div> </div>
<div className='flex flex-1 flex-col gap-4 p-4'> <div className='flex flex-1 flex-col gap-4 p-4'>
<CustomerEditForm onSubmit={handleSubmit} isPending={isPending} /> <CustomerEditForm onSubmit={handleSubmit} isPending={isPending} />

View File

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

View File

@ -1,6 +0,0 @@
import * as z from "zod/v4";
import { UpdateCustomerByIdRequestSchema } from "../../../common";
export const CustomerDataFormSchema = UpdateCustomerByIdRequestSchema;
export type CustomerData = z.infer<typeof CustomerDataFormSchema>;

View File

@ -0,0 +1,354 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { TaxesMultiSelectField } from "@erp/core/components";
import { SelectField, TextAreaField, TextField } from "@repo/rdx-ui/components";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
RadioGroup,
RadioGroupItem,
} from "@repo/shadcn-ui/components";
import { useUnsavedChangesNotifier } from "@erp/core/hooks";
import { GetCustomerByIdResponseDTO } from "@erp/customer-invoices/common";
import { useTranslation } from "../../i18n";
import { CustomerData, CustomerDataUpdateUpdateSchema } from "../../schemas";
interface CustomerFormProps {
formId: string;
data?: GetCustomerByIdResponseDTO;
isPending?: boolean;
/**
* Callback function to handle form submission.
* @param data - The customer data submitted by the form.
*/
onSubmit?: (data: CustomerData) => void;
}
export const CustomerEditForm = ({ formId, data, onSubmit, isPending }: CustomerFormProps) => {
const { t } = useTranslation();
const form = useForm<CustomerData>({
resolver: zodResolver(CustomerDataUpdateUpdateSchema),
defaultValues: data,
disabled: isPending,
});
useUnsavedChangesNotifier({
isDirty: form.formState.isDirty,
});
const handleSubmit = (data: CustomerData) => {
console.log("Datos del formulario:", data);
onSubmit?.(data);
};
const handleError = (errors: any) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
const handleCancel = () => {
form.reset(data);
};
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(handleSubmit, handleError)}>
<div className='w-full grid grid-cols-1 space-y-8 space-x-8 xl:grid-cols-2'>
{/* Información básica */}
<Card className='shadow-none'>
<CardHeader>
<CardTitle>{t("form_groups.basic_info.title")}</CardTitle>
<CardDescription>{t("form_groups.basic_info.description")}</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'>
<FormField
control={form.control}
name='is_company'
render={({ field }) => (
<FormItem className='space-y-3'>
<FormLabel>{t("form_fields.customer_type.label")}</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value ? "1" : "0"}
className='flex gap-6'
>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem value={"1"} />
</FormControl>
<FormLabel className='font-normal'>
{t("form_fields.customer_type.company")}
</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem value={"0"} />
</FormControl>
<FormLabel className='font-normal'>
{t("form_fields.customer_type.individual")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<TextField
control={form.control}
name='tin'
required
label={t("form_fields.tin.label")}
placeholder={t("form_fields.tin.placeholder")}
description={t("form_fields.tin.description")}
/>
<TextField
control={form.control}
name='name'
required
label={t("form_fields.name.label")}
placeholder={t("form_fields.name.placeholder")}
description={t("form_fields.name.description")}
/>
<TextField
control={form.control}
name='trade_name'
label={t("form_fields.trade_name.label")}
placeholder={t("form_fields.trade_name.placeholder")}
description={t("form_fields.trade_name.description")}
/>
<TextField
control={form.control}
name='reference'
label={t("form_fields.reference.label")}
placeholder={t("form_fields.reference.placeholder")}
description={t("form_fields.reference.description")}
/>
</CardContent>
</Card>
{/* Dirección */}
<Card className='shadow-none'>
<CardHeader>
<CardTitle>{t("form_groups.address.title")}</CardTitle>
<CardDescription>{t("form_groups.address.description")}</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'>
<TextField
className='xl:col-span-2'
control={form.control}
name='street'
required
label={t("form_fields.street.label")}
placeholder={t("form_fields.street.placeholder")}
description={t("form_fields.street.description")}
/>
<TextField
control={form.control}
name='city'
required
label={t("form_fields.city.label")}
placeholder={t("form_fields.city.placeholder")}
description={t("form_fields.city.description")}
/>
<TextField
control={form.control}
name='postal_code'
required
label={t("form_fields.postal_code.label")}
placeholder={t("form_fields.postal_code.placeholder")}
description={t("form_fields.postal_code.description")}
/>
<TextField
control={form.control}
name='province'
required
label={t("form_fields.province.label")}
placeholder={t("form_fields.province.placeholder")}
description={t("form_fields.province.description")}
/>
<SelectField
control={form.control}
name='country'
required
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" },
]}
/>
</CardContent>
</Card>
{/* Contacto */}
<Card className='shadow-none'>
<CardHeader>
<CardTitle>{t("form_groups.contact_info.title")}</CardTitle>
<CardDescription>{t("form_groups.contact_info.description")}</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'>
<TextField
control={form.control}
name='email_primary'
label={t("form_fields.email_primary.label")}
placeholder={t("form_fields.email_primary.placeholder")}
description={t("form_fields.email_primary.description")}
/>
<TextField
control={form.control}
name='email_secondary'
label={t("form_fields.email_secondary.label")}
placeholder={t("form_fields.email_secondary.placeholder")}
description={t("form_fields.email_secondary.description")}
/>
<TextField
control={form.control}
name='phone_primary'
label={t("form_fields.phone_primary.label")}
placeholder={t("form_fields.phone_primary.placeholder")}
description={t("form_fields.phone_primary.description")}
/>
<TextField
control={form.control}
name='phone_secondary'
label={t("form_fields.phone_secondary.label")}
placeholder={t("form_fields.phone_secondary.placeholder")}
description={t("form_fields.phone_secondary.description")}
/>
<TextField
control={form.control}
name='mobile_primary'
label={t("form_fields.mobile_primary.label")}
placeholder={t("form_fields.mobile_primary.placeholder")}
description={t("form_fields.mobile_primary.description")}
/>
<TextField
control={form.control}
name='mobile_secondary'
label={t("form_fields.mobile_secondary.label")}
placeholder={t("form_fields.mobile_secondary.placeholder")}
description={t("form_fields.mobile_secondary.description")}
/>
<TextField
control={form.control}
name='fax'
label={t("form_fields.fax.label")}
placeholder={t("form_fields.fax.placeholder")}
description={t("form_fields.fax.description")}
/>
<TextField
className='xl:col-span-2'
control={form.control}
name='website'
label={t("form_fields.website.label")}
placeholder={t("form_fields.website.placeholder")}
description={t("form_fields.website.description")}
/>
</CardContent>
</Card>
{/* Configuraciones Adicionales */}
<Card className='shadow-none'>
<CardHeader>
<CardTitle>{t("form_groups.additional_config.title")}</CardTitle>
<CardDescription>{t("form_groups.additional_config.description")}</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'>
<TaxesMultiSelectField
control={form.control}
name='default_tax'
required
label={t("form_fields.default_tax.label")}
placeholder={t("form_fields.default_tax.placeholder")}
description={t("form_fields.default_tax.description")}
/>
<SelectField
control={form.control}
name='lang_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" },
]}
/>
<TextAreaField
className=''
control={form.control}
name='legal_record'
required
label={t("form_fields.legal_record.label")}
placeholder={t("form_fields.legal_record.placeholder")}
description={t("form_fields.legal_record.description")}
/>
</CardContent>
</Card>
</div>
<Button type='submit'>Submit</Button>
</form>
</Form>
);
};

View File

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

View File

@ -0,0 +1,175 @@
import { AppBreadcrumb, AppContent, BackHistoryButton, ButtonGroup } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useNavigate } from "react-router-dom";
import { useUrlParamId } from "@erp/core/hooks";
import { useCustomerQuery, useUpdateCustomerMutation } from "../../hooks";
import { useTranslation } from "../../i18n";
import { CustomerEditForm } from "./customer-edit-form";
export const CustomerUpdate = () => {
const { t } = useTranslation();
const customerId = useUrlParamId();
const navigate = useNavigate();
// 1) Estado de carga del cliente (query)
const {
data: customerData,
isLoading: isLoadingCustomer,
isError: isLoadError,
error: loadError,
} = useCustomerQuery(customerId, { enabled: !!customerId });
// 2) Estado de actualización (mutación)
const {
mutateAsync: updateAsync,
isPending: isUpdating,
isError: isUpdateError,
error: updateError,
} = useUpdateCustomerMutation(customerId || "");
// 3) Submit con navegación condicionada por éxito
const handleSubmit = async (formData: any) => {
try {
await updateAsync(formData); // solo navegamos si no lanza
// toast?.({ title: t('pages.update.successTitle'), description: 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
}
};
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>
</>
);
}
if (isLoadError) {
return (
<>
<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>
<div className='flex items-center justify-end'>
<BackHistoryButton />
</div>
</AppContent>
</>
);
}
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>
</AppContent>
</>
);
}
return (
<>
<AppBreadcrumb />
<AppContent>
<div className='flex items-center justify-between space-y-2'>
<div>
<h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'>
{t("pages.update.title")}
</h2>
<p className='text-muted-foreground scroll-m-20 tracking-tight text-balance'>
{t("pages.update.description")}
</p>
</div>
<ButtonGroup>
<BackHistoryButton />
<Button
type='submit'
form='customer-edit-form'
className='cursor-pointer'
disabled={isUpdating || isLoadingCustomer}
aria-busy={isUpdating}
aria-disabled={isUpdating || isLoadingCustomer}
data-state={isUpdating ? "loading" : "idle"}
>
{t("pages.update.submit")}
</Button>
</ButtonGroup>
</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>
)}
<div className='flex flex-1 flex-col gap-4 p-4'>
{/* Importante: proveemos un formId para que el botón del header pueda hacer submit */}
<CustomerEditForm
formId='customer-edit-form'
data={customerData}
onSubmit={handleSubmit}
isPending={isUpdating}
/>
</div>
</AppContent>
</>
);
};

View File

@ -0,0 +1,10 @@
import {
GetCustomerByIdResponseDTO,
UpdateCustomerByIdRequestDTO,
UpdateCustomerByIdRequestSchema,
} from "@erp/customers";
export type CustomerData = GetCustomerByIdResponseDTO;
export const CustomerDataUpdateUpdateSchema = UpdateCustomerByIdRequestSchema;
export type CustomerDataFormUpdateDTO = UpdateCustomerByIdRequestDTO;

View File

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

View File

@ -28,6 +28,6 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["src", "../../packages/rdx-ddd/src/helpers/extract-or-push-error.ts"], "include": ["src"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }