Clientes
This commit is contained in:
parent
7470b15cfe
commit
c285c2d897
@ -34,9 +34,6 @@ export abstract class ExpressController {
|
||||
} satisfies ApiErrorContext;
|
||||
|
||||
const body = toProblemJson(apiError, ctx);
|
||||
|
||||
console.trace(body);
|
||||
|
||||
return res.type("application/problem+json").status(apiError.status).json(body);
|
||||
}
|
||||
|
||||
|
||||
@ -3,3 +3,4 @@ export * from "./use-pagination";
|
||||
export * from "./use-query-key";
|
||||
export * from "./use-toggle";
|
||||
export * from "./use-unsaved-changes-notifier";
|
||||
export * from "./use-url-param-id";
|
||||
|
||||
6
modules/core/src/web/hooks/use-url-param-id.ts
Normal file
6
modules/core/src/web/hooks/use-url-param-id.ts
Normal 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 |
@ -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>
|
||||
@ -1,24 +1,87 @@
|
||||
<html lang="{{lang_code}}">
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
|
||||
<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"
|
||||
referrerpolicy="no-referrer" />
|
||||
<title>Presupuesto #{{id}}</title>
|
||||
<title>Factura F26200</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: #000;
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 40px;
|
||||
color: #333;
|
||||
font-size: 11pt;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#header {
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
margin-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 {
|
||||
thead {
|
||||
@ -34,9 +97,192 @@
|
||||
|
||||
<body>
|
||||
|
||||
<header>
|
||||
|
||||
<footer id="footer" class="mt-4">
|
||||
<aside><img src="https://uecko.com/assets/img/uecko-footer_logos.jpg" class="w-full" /></aside>
|
||||
<aside class="flex items-start mb-4 w-full">
|
||||
<!-- 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>
|
||||
|
||||
</body>
|
||||
|
||||
@ -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>
|
||||
@ -6,16 +6,12 @@ import {
|
||||
Province,
|
||||
Street,
|
||||
TINNumber,
|
||||
ValidationErrorDetail,
|
||||
extractOrPushError,
|
||||
maybeFromNullableVO,
|
||||
} from "@repo/rdx-ddd";
|
||||
|
||||
import {
|
||||
IQueryMapperWithBulk,
|
||||
MapperParamsType,
|
||||
SequelizeQueryMapper,
|
||||
ValidationErrorDetail,
|
||||
extractOrPushError,
|
||||
} from "@erp/core/api";
|
||||
import { IQueryMapperWithBulk, MapperParamsType, SequelizeQueryMapper } from "@erp/core/api";
|
||||
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { InvoiceRecipient } from "../../../domain";
|
||||
|
||||
@ -3,3 +3,4 @@ export * from "./customer-invoices-list.request.dto";
|
||||
export * from "./delete-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 "./update-customer-invoice-by-id.request.dto";
|
||||
|
||||
@ -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>;
|
||||
1
modules/customer-invoices/src/common/index.ts
Normal file
1
modules/customer-invoices/src/common/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./dto";
|
||||
@ -3,7 +3,7 @@ import { CustomerListDTO } from "@erp/customer-invoices/api/infrastructure";
|
||||
import { Criteria } from "@repo/rdx-criteria/server";
|
||||
import { toEmptyString } from "@repo/rdx-ddd";
|
||||
import { Collection } from "@repo/rdx-utils";
|
||||
import { CustomerListResponsetDTO } from "../../../../common/dto";
|
||||
import { ListCustomersResponseDTO } from "../../../../common/dto";
|
||||
|
||||
export class ListCustomersPresenter extends Presenter {
|
||||
protected _mapCustomer(customer: CustomerListDTO) {
|
||||
@ -54,7 +54,7 @@ export class ListCustomersPresenter extends Presenter {
|
||||
toOutput(params: {
|
||||
customers: Collection<CustomerListDTO>;
|
||||
criteria: Criteria;
|
||||
}): CustomerListResponsetDTO {
|
||||
}): ListCustomersResponseDTO {
|
||||
const { customers, criteria } = params;
|
||||
|
||||
const items = customers.map((customer) => this._mapCustomer(customer));
|
||||
|
||||
@ -3,7 +3,7 @@ import { Criteria } from "@repo/rdx-criteria/server";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { Transaction } from "sequelize";
|
||||
import { CustomerListResponsetDTO } from "../../../common/dto";
|
||||
import { ListCustomersResponseDTO } from "../../../common/dto";
|
||||
import { CustomerService } from "../../domain";
|
||||
import { ListCustomersPresenter } from "../presenters";
|
||||
|
||||
@ -21,7 +21,7 @@ export class ListCustomersUseCase {
|
||||
|
||||
public execute(
|
||||
params: ListCustomersUseCaseInput
|
||||
): Promise<Result<CustomerListResponsetDTO, Error>> {
|
||||
): Promise<Result<ListCustomersResponseDTO, Error>> {
|
||||
const { criteria, companyId } = params;
|
||||
const presenter = this.presenterRegistry.getPresenter({
|
||||
resource: "customer",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||
import { UpdateCustomerRequestDTO } from "../../../../common/dto";
|
||||
import { UpdateCustomerByIdRequestDTO } from "../../../../common/dto";
|
||||
import { UpdateCustomerUseCase } from "../../../application";
|
||||
|
||||
export class UpdateCustomerController extends ExpressController {
|
||||
@ -15,7 +15,7 @@ export class UpdateCustomerController extends ExpressController {
|
||||
return this.forbiddenError("Tenant ID not found");
|
||||
}
|
||||
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 });
|
||||
|
||||
|
||||
@ -31,4 +31,4 @@ export const UpdateCustomerByIdRequestSchema = z.object({
|
||||
currency_code: z.string().optional(),
|
||||
});
|
||||
|
||||
export type UpdateCustomerRequestDTO = z.infer<typeof UpdateCustomerByIdRequestSchema>;
|
||||
export type UpdateCustomerByIdRequestDTO = z.infer<typeof UpdateCustomerByIdRequestSchema>;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export * from "./create-customer.result.dto";
|
||||
export * from "./customer-list.response.dto";
|
||||
export * from "./get-customer-by-id.response.dto";
|
||||
export * from "./list-customers.response.dto";
|
||||
export * from "./update-customer-by-id.response.dto";
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { MetadataSchema, createListViewResponseSchema } from "@erp/core";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const CustomerListResponseSchema = createListViewResponseSchema(
|
||||
export const ListCustomersResponseSchema = createListViewResponseSchema(
|
||||
z.object({
|
||||
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>;
|
||||
@ -18,15 +18,19 @@ export const UpdateCustomerByIdResponseSchema = z.object({
|
||||
postal_code: z.string(),
|
||||
country: z.string(),
|
||||
|
||||
email: z.string(),
|
||||
phone: z.string(),
|
||||
email_primary: 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(),
|
||||
website: z.string(),
|
||||
|
||||
legal_record: z.string(),
|
||||
|
||||
default_taxes: z.string(),
|
||||
status: z.string(),
|
||||
language_code: z.string(),
|
||||
currency_code: z.string(),
|
||||
|
||||
|
||||
@ -10,7 +10,11 @@
|
||||
"name": "Name",
|
||||
"trade_name": "Trade name",
|
||||
"status": "Status",
|
||||
"email": "Email"
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"city": "City",
|
||||
"tin": "TIN",
|
||||
"mobile": "Mobile"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@ -71,15 +75,37 @@
|
||||
"placeholder": "Select country",
|
||||
"description": "The country of the customer"
|
||||
},
|
||||
"email": {
|
||||
"label": "Email",
|
||||
"placeholder": "Enter email",
|
||||
"description": "The email address of the customer"
|
||||
"email_primary": {
|
||||
"label": "Primary email",
|
||||
"placeholder": "Enter primary email",
|
||||
"description": "The primary email address of the customer"
|
||||
},
|
||||
"phone": {
|
||||
"label": "Phone",
|
||||
"placeholder": "Enter phone number",
|
||||
"description": "The phone number of the customer"
|
||||
"email_secondary": {
|
||||
"label": "Secondary email",
|
||||
"placeholder": "Enter secondary email",
|
||||
"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": {
|
||||
"label": "Fax",
|
||||
|
||||
@ -10,7 +10,11 @@
|
||||
"name": "Nombre",
|
||||
"trade_name": "Nombre comercial",
|
||||
"status": "Estado",
|
||||
"email": "Correo electrónico"
|
||||
"email": "Correo electrónico",
|
||||
"phone": "Teléfono",
|
||||
"city": "Ciudad",
|
||||
"tin": "Nº Id.",
|
||||
"mobile": "Móvil"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@ -71,16 +75,40 @@
|
||||
"placeholder": "Seleccione el país",
|
||||
"description": "El país del cliente"
|
||||
},
|
||||
"email": {
|
||||
"label": "Correo electrónico",
|
||||
|
||||
"email_primary": {
|
||||
"label": "Email principal",
|
||||
"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",
|
||||
"placeholder": "Ingrese el número de teléfono",
|
||||
"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": {
|
||||
"label": "Fax",
|
||||
"placeholder": "Ingrese el número de fax",
|
||||
|
||||
@ -18,10 +18,10 @@ import {
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { Building, Calendar, Mail, MapPin, Phone, Plus, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { CustomerListResponsetDTO } from "../../common";
|
||||
import { ListCustomersResponseDTO } from "../../common";
|
||||
import { useCustomersQuery } from "../hooks";
|
||||
|
||||
type Customer = CustomerListResponsetDTO["items"][number];
|
||||
type Customer = ListCustomersResponseDTO["items"][number];
|
||||
|
||||
const columns: TableColumn<Customer>[] = [
|
||||
{
|
||||
|
||||
@ -1,49 +1,113 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { AG_GRID_LOCALE_ES } from "@ag-grid-community/locale";
|
||||
// Grid
|
||||
import type { ColDef, GridOptions, ValueFormatterParams } from "ag-grid-community";
|
||||
import { AllCommunityModule, ModuleRegistry } from "ag-grid-community";
|
||||
import type { ValueFormatterParams } from "ag-grid-community";
|
||||
import {
|
||||
AllCommunityModule,
|
||||
ColDef,
|
||||
GridOptions,
|
||||
ModuleRegistry,
|
||||
SizeColumnsToContentStrategy,
|
||||
SizeColumnsToFitGridStrategy,
|
||||
SizeColumnsToFitProvidedWidthStrategy,
|
||||
} from "ag-grid-community";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
// Core CSS
|
||||
import { Button } from "@repo/shadcn-ui/components";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCustomersQuery } from "../hooks";
|
||||
import { useTranslation } from "../i18n";
|
||||
import { CustomerStatusBadge } from "./customer-status-badge";
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
// Create new GridExample component
|
||||
export const CustomersListGrid = () => {
|
||||
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.
|
||||
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",
|
||||
|
||||
headerName: t("pages.list.grid_columns.status"),
|
||||
maxWidth: 125,
|
||||
cellRenderer: (params: ValueFormatterParams) => {
|
||||
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",
|
||||
headerName: t("pages.list.grid_columns.email"),
|
||||
colId: "actions",
|
||||
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<
|
||||
| SizeColumnsToFitGridStrategy
|
||||
| SizeColumnsToFitProvidedWidthStrategy
|
||||
| SizeColumnsToContentStrategy
|
||||
>(() => {
|
||||
return {
|
||||
type: "fitGridWidth",
|
||||
defaultMinWidth: 100,
|
||||
columnLimits: [{ colId: "actions", minWidth: 75, maxWidth: 75 }],
|
||||
};
|
||||
}, []);
|
||||
|
||||
const gridOptions: GridOptions = useMemo(
|
||||
() => ({
|
||||
columnDefs: colDefs,
|
||||
autoSizeStrategy: autoSizeStrategy,
|
||||
defaultColDef: {
|
||||
editable: true,
|
||||
editable: false,
|
||||
flex: 1,
|
||||
minWidth: 100,
|
||||
filter: false,
|
||||
sortable: false,
|
||||
resizable: true,
|
||||
@ -52,8 +116,9 @@ export const CustomersListGrid = () => {
|
||||
paginationPageSize: 10,
|
||||
paginationPageSizeSelector: [10, 20, 30, 50],
|
||||
localeText: AG_GRID_LOCALE_ES,
|
||||
rowSelection: { mode: "multiRow" },
|
||||
};
|
||||
}),
|
||||
[autoSizeStrategy, colDefs]
|
||||
);
|
||||
|
||||
// Container: Defines the grid's theme & dimensions.
|
||||
return (
|
||||
@ -64,7 +129,11 @@ export const CustomersListGrid = () => {
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<AgGridReact rowData={data?.items ?? []} loading={isLoading || isPending} {...gridOptions} />
|
||||
<AgGridReact
|
||||
rowData={customersData?.items ?? []}
|
||||
loading={isLoadingCustomers}
|
||||
{...gridOptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { ModuleClientParams } from "@erp/core/client";
|
||||
import { lazy } from "react";
|
||||
import { Outlet, RouteObject } from "react-router-dom";
|
||||
import { CustomerUpdate } from "./pages/update";
|
||||
|
||||
// Lazy load components
|
||||
const CustomersLayout = lazy(() =>
|
||||
@ -43,6 +44,7 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
|
||||
{ path: "", index: true, element: <CustomersList /> }, // index
|
||||
{ path: "list", element: <CustomersList /> },
|
||||
{ path: "create", element: <CustomerAdd /> },
|
||||
{ path: ":id/edit", element: <CustomerUpdate /> },
|
||||
|
||||
//
|
||||
/*{ path: "create", element: <CustomersList /> },
|
||||
|
||||
@ -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-update-customer-mutation";
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import { useDataSource, useQueryKey } from "@erp/core/hooks";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { UpdateCustomerRequestDTO } from "../../common/dto";
|
||||
import { UpdateCustomerByIdRequestDTO } from "../../common/dto";
|
||||
|
||||
export const useCreateCustomerMutation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const dataSource = useDataSource();
|
||||
const keys = useQueryKey();
|
||||
|
||||
return useMutation<UpdateCustomerRequestDTO, Error, Partial<UpdateCustomerRequestDTO>>({
|
||||
return useMutation<UpdateCustomerByIdRequestDTO, Error, Partial<UpdateCustomerByIdRequestDTO>>({
|
||||
mutationKey: ["customer:create"],
|
||||
mutationFn: (data) => {
|
||||
console.log(data);
|
||||
return dataSource.createOne("customers", data);
|
||||
|
||||
40
modules/customers/src/web/hooks/use-customer-query.ts
Normal file
40
modules/customers/src/web/hooks/use-customer-query.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@ -1,21 +1,22 @@
|
||||
import { useDataSource, useQueryKey } from "@erp/core/hooks";
|
||||
import { ListCustomersResponseDTO } from "@erp/customer-invoices/common";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CustomerListResponsetDTO } from "../../common/dto";
|
||||
|
||||
// Obtener todas las facturas
|
||||
export const useCustomersQuery = (params: any) => {
|
||||
export const useCustomersQuery = (params?: any) => {
|
||||
const dataSource = useDataSource();
|
||||
const keys = useQueryKey();
|
||||
|
||||
return useQuery<CustomerListResponsetDTO>({
|
||||
return useQuery<ListCustomersResponseDTO>({
|
||||
queryKey: keys().data().resource("customers").action("list").params(params).get(),
|
||||
queryFn: (context) => {
|
||||
console.log(dataSource.getBaseUrl());
|
||||
queryFn: async (context) => {
|
||||
const { signal } = context;
|
||||
return dataSource.getList<CustomerListResponsetDTO>("customers", {
|
||||
const customers = await dataSource.getList("customers", {
|
||||
signal,
|
||||
...params,
|
||||
});
|
||||
|
||||
return customers as ListCustomersResponseDTO;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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 { useNavigate } from "react-router-dom";
|
||||
|
||||
@ -62,12 +62,12 @@ export const CustomerCreate = () => {
|
||||
{t("pages.create.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center justify-end mb-4'>
|
||||
<ButtonGroup>
|
||||
<BackHistoryButton />
|
||||
<Button type='submit' className='cursor-pointer'>
|
||||
{t("pages.create.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<div className='flex flex-1 flex-col gap-4 p-4'>
|
||||
<CustomerEditForm onSubmit={handleSubmit} isPending={isPending} />
|
||||
|
||||
@ -22,7 +22,7 @@ import {
|
||||
|
||||
import { useUnsavedChangesNotifier } from "@erp/core/hooks";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { CustomerData, CustomerDataFormSchema } from "./customer.schema";
|
||||
import { CustomerData, CustomerDataUpdateUpdateSchema } 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(CustomerDataFormSchema),
|
||||
resolver: zodResolver(CustomerDataUpdateUpdateSchema),
|
||||
defaultValues: initialData,
|
||||
disabled: isPending,
|
||||
});
|
||||
|
||||
@ -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>;
|
||||
354
modules/customers/src/web/pages/update/customer-edit-form.tsx
Normal file
354
modules/customers/src/web/pages/update/customer-edit-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
modules/customers/src/web/pages/update/index.ts
Normal file
1
modules/customers/src/web/pages/update/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./update";
|
||||
175
modules/customers/src/web/pages/update/update.tsx
Normal file
175
modules/customers/src/web/pages/update/update.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
10
modules/customers/src/web/schemas/customer.schema.ts
Normal file
10
modules/customers/src/web/schemas/customer.schema.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import {
|
||||
GetCustomerByIdResponseDTO,
|
||||
UpdateCustomerByIdRequestDTO,
|
||||
UpdateCustomerByIdRequestSchema,
|
||||
} from "@erp/customers";
|
||||
|
||||
export type CustomerData = GetCustomerByIdResponseDTO;
|
||||
|
||||
export const CustomerDataUpdateUpdateSchema = UpdateCustomerByIdRequestSchema;
|
||||
export type CustomerDataFormUpdateDTO = UpdateCustomerByIdRequestDTO;
|
||||
1
modules/customers/src/web/schemas/index.ts
Normal file
1
modules/customers/src/web/schemas/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./customer.schema";
|
||||
@ -28,6 +28,6 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src", "../../packages/rdx-ddd/src/helpers/extract-or-push-error.ts"],
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user