Compare commits

...

3 Commits

Author SHA1 Message Date
4d3430cc91 Clientes y facturas de cliente 2025-09-16 19:59:58 +02:00
f72273b069 Clientes y facturas de cliente 2025-09-16 19:41:27 +02:00
c285c2d897 Clientes 2025-09-16 19:29:37 +02:00
47 changed files with 1795 additions and 435 deletions

View File

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

View File

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

View File

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

View File

@ -3,13 +3,13 @@ import { CustomerInvoiceListDTO } from "@erp/customer-invoices/api/infrastructur
import { Criteria } from "@repo/rdx-criteria/server";
import { toEmptyString } from "@repo/rdx-ddd";
import { ArrayElement, Collection } from "@repo/rdx-utils";
import { CustomerInvoiceListResponseDTO } from "../../../../common/dto";
import { ListCustomerInvoicesResponseDTO } from "../../../../common/dto";
export class ListCustomerInvoicesPresenter extends Presenter {
protected _mapInvoice(invoice: CustomerInvoiceListDTO) {
const recipientDTO = invoice.recipient.toObjectString();
const invoiceDTO: ArrayElement<CustomerInvoiceListResponseDTO["items"]> = {
const invoiceDTO: ArrayElement<ListCustomerInvoicesResponseDTO["items"]> = {
id: invoice.id.toString(),
company_id: invoice.companyId.toString(),
customer_id: invoice.customerId.toString(),
@ -48,7 +48,7 @@ export class ListCustomerInvoicesPresenter extends Presenter {
toOutput(params: {
customerInvoices: Collection<CustomerInvoiceListDTO>;
criteria: Criteria;
}): CustomerInvoiceListResponseDTO {
}): ListCustomerInvoicesResponseDTO {
const { customerInvoices, criteria } = params;
const invoices = customerInvoices.map((invoice) => this._mapInvoice(invoice));

View File

@ -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 { CustomerInvoiceListResponseDTO } from "../../../common/dto";
import { ListCustomerInvoicesResponseDTO } from "../../../common/dto";
import { CustomerInvoiceService } from "../../domain";
import { ListCustomerInvoicesPresenter } from "../presenters";
@ -21,7 +21,7 @@ export class ListCustomerInvoicesUseCase {
public execute(
params: ListCustomerInvoicesUseCaseInput
): Promise<Result<CustomerInvoiceListResponseDTO, Error>> {
): Promise<Result<ListCustomerInvoicesResponseDTO, Error>> {
const { criteria, companyId } = params;
const presenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice",

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>
<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,10 +97,199 @@
<body>
<header>
<footer id="footer" class="mt-4">
<aside><img src="https://uecko.com/assets/img/uecko-footer_logos.jpg" class="w-full" /></aside>
</footer>
<aside class="flex items-start mb-4 w-full">
<!-- Bloque IZQUIERDO: imagen arriba + texto abajo, alineado a la izquierda -->
<div class="w-[70%] flex flex-col items-start text-left">
<img src="https://rodax-software.com/images/logo1.jpg" alt="Logo Rodax" class="block h-14 w-auto mb-1" />
<div class="flex w-full">
<div class="p-1 ">
<p><span>Factura nº:</span>xxxxxxxx</p>
<p><span>Fecha:</span>12/12/2024</p>
</div>
<div class="p-1 ml-9">
<h2 class="font-semibold uppercase mb-1">{{customer.name}}</h2>
<p>AAAA</p>
<p>BBBBBBsdfsfsdf sfsdf sf sdfs fsdfsd fsdf sdfsd fds </p>
<p>CCCCC</p>
<p>DDDDD</p>
</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>
<div class="relative bg-blue-400">
<!-- Badge TOTAL superpuesto -->
<div class="absolute -top-7 right-0">
<div class="relative bg-[#f08119] text-white text-sm font-semibold px-3 py-1 shadow">
TOTAL: 960,56 €
<!-- Triángulo izquierdo -->
<span aria-hidden="true" class="absolute -left-3 top-0 bottom-0 my-auto h-0 w-0
border-y-[14px] border-y-transparent
border-r-[14px] border-r-amber-500"></span>
</div>
</div>
<!-- Tu tabla -->
<table class="w-full border-t border-black">
<thead>
<tr class="text-left">
<th class="py-2">Concepto</th>
<th class="py-2">Cantidad</th>
<th class="py-2">Precio unidad</th>
<th class="py-2">Importe total</th>
</tr>
</thead>
<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>

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

View File

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

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

@ -1,7 +1,7 @@
import { MetadataSchema, MoneySchema, createListViewResponseSchema } from "@erp/core";
import * as z from "zod/v4";
export const ListCustomerInvoiceResponseSchema = createListViewResponseSchema(
export const ListCustomerInvoicesResponseSchema = createListViewResponseSchema(
z.object({
id: z.uuid(),
company_id: z.uuid(),
@ -40,4 +40,4 @@ export const ListCustomerInvoiceResponseSchema = createListViewResponseSchema(
})
);
export type CustomerInvoiceListResponseDTO = z.infer<typeof ListCustomerInvoiceResponseSchema>;
export type ListCustomerInvoicesResponseDTO = z.infer<typeof ListCustomerInvoicesResponseSchema>;

View File

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

View File

@ -1,25 +1,37 @@
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";
ModuleRegistry.registerModules([AllCommunityModule]);
import type {
SizeColumnsToContentStrategy,
SizeColumnsToFitGridStrategy,
SizeColumnsToFitProvidedWidthStrategy,
ValueFormatterParams,
} from "ag-grid-community";
import { AllCommunityModule, ColDef, GridOptions, ModuleRegistry } from "ag-grid-community";
import { useMemo, useState } from "react";
import { MoneyDTO } from "@erp/core";
import { formatDate, formatMoney } from "@erp/core/client";
// Core CSS
import { Button } from "@repo/shadcn-ui/components";
import { AgGridReact } from "ag-grid-react";
import { Link } from "react-router-dom";
import { ChevronRightIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useCustomerInvoicesQuery } from "../hooks";
import { useTranslation } from "../i18n";
import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
ModuleRegistry.registerModules([AllCommunityModule]);
// Create new GridExample component
export const CustomerInvoicesListGrid = () => {
const { t } = useTranslation();
const { data, isLoading, isPending, isError, error } = useCustomerInvoicesQuery({
const navigate = useNavigate();
const {
data: customersData,
isLoading: isLoadingCustomerInvoices,
isError: isLoadError,
error: loadError,
} = useCustomerInvoicesQuery({
pagination: {
pageSize: 999,
},
@ -78,31 +90,56 @@ export const CustomerInvoicesListGrid = () => {
},
},
{
field: "id",
headerName: t("pages.list.grid_columns.total_amount"),
colId: "actions",
headerName: t("pages.list.grid_columns.actions", "Actions"),
cellRenderer: (params: ValueFormatterParams) => {
return <Link to={params.value}>Hola</Link>;
const { data } = params;
return (
<Button
variant='secondary'
size='icon'
className='size-8'
onClick={() => {
navigate(`${data.id}/edit`);
}}
>
<ChevronRightIcon />
</Button>
);
},
},
]);
const gridOptions: GridOptions = {
rowModelType: "clientSide",
columnDefs: colDefs,
defaultColDef: {
editable: false,
flex: 1,
minWidth: 100,
filter: true,
sortable: true,
resizable: true,
},
pagination: true,
paginationPageSize: 15,
paginationPageSizeSelector: [10, 15, 20, 30, 50],
localeText: AG_GRID_LOCALE_ES,
rowSelection: { mode: "multiRow" },
};
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: 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.
return (
@ -113,7 +150,11 @@ export const CustomerInvoicesListGrid = () => {
width: "100%",
}}
>
<AgGridReact rowData={data?.items ?? []} loading={isLoading || isPending} {...gridOptions} />
<AgGridReact
rowData={customersData?.items ?? []}
loading={isLoadingCustomerInvoices}
{...gridOptions}
/>
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>[] = [
{

View File

@ -1,59 +1,124 @@
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 = {
columnDefs: colDefs,
defaultColDef: {
editable: true,
flex: 1,
minWidth: 100,
filter: false,
sortable: false,
resizable: true,
},
pagination: true,
paginationPageSize: 10,
paginationPageSizeSelector: [10, 20, 30, 50],
localeText: AG_GRID_LOCALE_ES,
rowSelection: { mode: "multiRow" },
};
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: 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.
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>
);
};

View File

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

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-update-customer-mutation";

View File

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

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

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 { 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} />

View File

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

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,345 @@
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='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,
"noUncheckedSideEffectImports": true
},
"include": ["src", "../../packages/rdx-ddd/src/helpers/extract-or-push-error.ts"],
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@ -50,7 +50,12 @@ export function TextField<TFormValues extends FieldValues>({
</div>
)}
<FormControl>
<Input disabled={isDisabled} placeholder={placeholder} {...field} />
<Input
disabled={isDisabled}
placeholder={placeholder}
{...field}
className='font-medium'
/>
</FormControl>
<p className={cn("text-xs text-muted-foreground", !description && "invisible")}>

View File

@ -1,13 +1,14 @@
import { type LucideIcon, MailIcon, PlusCircleIcon } from "lucide-react";
import { Button } from "@repo/shadcn-ui/components/button";
import {
Button,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@repo/shadcn-ui/components/sidebar";
} from "@repo/shadcn-ui/components";
import { useNavigate } from "react-router";
export function NavMain({
items,
@ -18,6 +19,10 @@ export function NavMain({
icon?: LucideIcon;
}[];
}) {
const navigate = useNavigate();
console.log(window.location.href);
return (
<SidebarGroup>
<SidebarGroupContent className='flex flex-col gap-2'>
@ -43,7 +48,12 @@ export function NavMain({
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton tooltip={item.title}>
<SidebarMenuButton
isActive={String(window.location.href).includes(item.url)}
tooltip={item.title}
onClick={() => navigate(item.url)}
className='data-[active=true]:bg-accent data-[active=true]:text-accent-foreground cursor-pointer'
>
{item.icon && <item.icon />}
<span>{item.title}</span>
</SidebarMenuButton>

View File

@ -1,56 +1,54 @@
"use client"
"use client";
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { VariantProps, cva } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import * as React from "react";
import { useIsMobile } from "@repo/shadcn-ui/hooks/use-mobile"
import { cn } from "@repo/shadcn-ui/lib/utils"
import { Button } from "@repo/shadcn-ui/components/button"
import { Input } from "@repo/shadcn-ui/components/input"
import { Separator } from "@repo/shadcn-ui/components/separator"
import {
Button,
Input,
Separator,
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@repo/shadcn-ui/components/sheet"
import { Skeleton } from "@repo/shadcn-ui/components/skeleton"
import {
Skeleton,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@repo/shadcn-ui/components/tooltip"
} from "@repo/shadcn-ui/components";
import { useIsMobile } from "../hooks/use-mobile.ts";
import { cn } from "../lib/utils.ts";
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext)
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context
return context;
}
function SidebarProvider({
@ -62,56 +60,53 @@ function SidebarProvider({
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState)
setOpenProp(openState);
} else {
_setOpen(openState)
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
)
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
}
};
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
@ -124,13 +119,13 @@ function SidebarProvider({
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
data-slot='sidebar-wrapper'
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
@ -148,7 +143,7 @@ function SidebarProvider({
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
);
}
function Sidebar({
@ -159,16 +154,16 @@ function Sidebar({
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
data-slot='sidebar'
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
@ -177,17 +172,17 @@ function Sidebar({
>
{children}
</div>
)
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
data-sidebar='sidebar'
data-slot='sidebar'
data-mobile='true'
className='bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden'
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
@ -195,28 +190,28 @@ function Sidebar({
}
side={side}
>
<SheetHeader className="sr-only">
<SheetHeader className='sr-only'>
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
<div className='flex h-full w-full flex-col'>{children}</div>
</SheetContent>
</Sheet>
)
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
className='group peer text-sidebar-foreground hidden md:block'
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
data-slot='sidebar'
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
data-slot='sidebar-gap'
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
@ -227,7 +222,7 @@ function Sidebar({
)}
/>
<div
data-slot="sidebar-container"
data-slot='sidebar-container'
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
@ -242,54 +237,50 @@ function Sidebar({
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
data-sidebar='sidebar'
data-slot='sidebar-inner'
className='bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm'
>
{children}
</div>
</div>
</div>
)
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
data-sidebar='trigger'
data-slot='sidebar-trigger'
variant='ghost'
size='icon'
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
<span className='sr-only'>Toggle Sidebar</span>
</Button>
)
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
data-sidebar='rail'
data-slot='sidebar-rail'
aria-label='Toggle Sidebar'
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
title='Toggle Sidebar'
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
@ -301,13 +292,13 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
)}
{...props}
/>
)
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
data-slot='sidebar-inset'
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
@ -315,82 +306,76 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
)}
{...props}
/>
)
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
data-slot='sidebar-input'
data-sidebar='input'
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
data-slot='sidebar-header'
data-sidebar='header'
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
data-slot='sidebar-footer'
data-sidebar='footer'
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
data-slot='sidebar-separator'
data-sidebar='separator'
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
data-slot='sidebar-content'
data-sidebar='content'
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
data-slot='sidebar-group'
data-sidebar='group'
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
);
}
function SidebarGroupLabel({
@ -398,12 +383,12 @@ function SidebarGroupLabel({
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
data-slot='sidebar-group-label'
data-sidebar='group-label'
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
@ -411,7 +396,7 @@ function SidebarGroupLabel({
)}
{...props}
/>
)
);
}
function SidebarGroupAction({
@ -419,12 +404,12 @@ function SidebarGroupAction({
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
data-slot='sidebar-group-action'
data-sidebar='group-action'
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
@ -434,43 +419,40 @@ function SidebarGroupAction({
)}
{...props}
/>
)
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
data-slot='sidebar-group-content'
data-sidebar='group-content'
className={cn("w-full text-sm", className)}
{...props}
/>
)
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
data-slot='sidebar-menu'
data-sidebar='menu'
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
data-slot='sidebar-menu-item'
data-sidebar='menu-item'
className={cn("group/menu-item relative", className)}
{...props}
/>
)
);
}
const sidebarMenuButtonVariants = cva(
@ -493,7 +475,7 @@ const sidebarMenuButtonVariants = cva(
size: "default",
},
}
)
);
function SidebarMenuButton({
asChild = false,
@ -504,45 +486,45 @@ function SidebarMenuButton({
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-slot='sidebar-menu-button'
data-sidebar='menu-button'
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
);
if (!tooltip) {
return button
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
side='right'
align='center'
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
);
}
function SidebarMenuAction({
@ -551,15 +533,15 @@ function SidebarMenuAction({
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
data-slot='sidebar-menu-action'
data-sidebar='menu-action'
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
@ -574,17 +556,14 @@ function SidebarMenuAction({
)}
{...props}
/>
)
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
data-slot='sidebar-menu-badge'
data-sidebar='menu-badge'
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
@ -596,7 +575,7 @@ function SidebarMenuBadge({
)}
{...props}
/>
)
);
}
function SidebarMenuSkeleton({
@ -604,29 +583,24 @@ function SidebarMenuSkeleton({
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
data-slot='sidebar-menu-skeleton'
data-sidebar='menu-skeleton'
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
{showIcon && <Skeleton className='size-4 rounded-md' data-sidebar='menu-skeleton-icon' />}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
className='h-4 max-w-(--skeleton-width) flex-1'
data-sidebar='menu-skeleton-text'
style={
{
"--skeleton-width": width,
@ -634,14 +608,14 @@ function SidebarMenuSkeleton({
}
/>
</div>
)
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
data-slot='sidebar-menu-sub'
data-sidebar='menu-sub'
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
@ -649,21 +623,18 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
)}
{...props}
/>
)
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
data-slot='sidebar-menu-sub-item'
data-sidebar='menu-sub-item'
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
);
}
function SidebarMenuSubButton({
@ -673,16 +644,16 @@ function SidebarMenuSubButton({
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a"
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-slot='sidebar-menu-sub-button'
data-sidebar='menu-sub-button'
data-size={size}
data-active={isActive}
className={cn(
@ -695,7 +666,7 @@ function SidebarMenuSubButton({
)}
{...props}
/>
)
);
}
export {
@ -723,4 +694,4 @@ export {
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
};