Compare commits
3 Commits
7470b15cfe
...
4d3430cc91
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d3430cc91 | |||
| f72273b069 | |||
| c285c2d897 |
@ -34,9 +34,6 @@ export abstract class ExpressController {
|
|||||||
} satisfies ApiErrorContext;
|
} satisfies ApiErrorContext;
|
||||||
|
|
||||||
const body = toProblemJson(apiError, ctx);
|
const body = toProblemJson(apiError, ctx);
|
||||||
|
|
||||||
console.trace(body);
|
|
||||||
|
|
||||||
return res.type("application/problem+json").status(apiError.status).json(body);
|
return res.type("application/problem+json").status(apiError.status).json(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,3 +3,4 @@ export * from "./use-pagination";
|
|||||||
export * from "./use-query-key";
|
export * from "./use-query-key";
|
||||||
export * from "./use-toggle";
|
export * from "./use-toggle";
|
||||||
export * from "./use-unsaved-changes-notifier";
|
export * from "./use-unsaved-changes-notifier";
|
||||||
|
export * from "./use-url-param-id";
|
||||||
|
|||||||
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;
|
||||||
|
};
|
||||||
@ -3,13 +3,13 @@ import { CustomerInvoiceListDTO } from "@erp/customer-invoices/api/infrastructur
|
|||||||
import { Criteria } from "@repo/rdx-criteria/server";
|
import { Criteria } from "@repo/rdx-criteria/server";
|
||||||
import { toEmptyString } from "@repo/rdx-ddd";
|
import { toEmptyString } from "@repo/rdx-ddd";
|
||||||
import { ArrayElement, Collection } from "@repo/rdx-utils";
|
import { ArrayElement, Collection } from "@repo/rdx-utils";
|
||||||
import { CustomerInvoiceListResponseDTO } from "../../../../common/dto";
|
import { ListCustomerInvoicesResponseDTO } from "../../../../common/dto";
|
||||||
|
|
||||||
export class ListCustomerInvoicesPresenter extends Presenter {
|
export class ListCustomerInvoicesPresenter extends Presenter {
|
||||||
protected _mapInvoice(invoice: CustomerInvoiceListDTO) {
|
protected _mapInvoice(invoice: CustomerInvoiceListDTO) {
|
||||||
const recipientDTO = invoice.recipient.toObjectString();
|
const recipientDTO = invoice.recipient.toObjectString();
|
||||||
|
|
||||||
const invoiceDTO: ArrayElement<CustomerInvoiceListResponseDTO["items"]> = {
|
const invoiceDTO: ArrayElement<ListCustomerInvoicesResponseDTO["items"]> = {
|
||||||
id: invoice.id.toString(),
|
id: invoice.id.toString(),
|
||||||
company_id: invoice.companyId.toString(),
|
company_id: invoice.companyId.toString(),
|
||||||
customer_id: invoice.customerId.toString(),
|
customer_id: invoice.customerId.toString(),
|
||||||
@ -48,7 +48,7 @@ export class ListCustomerInvoicesPresenter extends Presenter {
|
|||||||
toOutput(params: {
|
toOutput(params: {
|
||||||
customerInvoices: Collection<CustomerInvoiceListDTO>;
|
customerInvoices: Collection<CustomerInvoiceListDTO>;
|
||||||
criteria: Criteria;
|
criteria: Criteria;
|
||||||
}): CustomerInvoiceListResponseDTO {
|
}): ListCustomerInvoicesResponseDTO {
|
||||||
const { customerInvoices, criteria } = params;
|
const { customerInvoices, criteria } = params;
|
||||||
|
|
||||||
const invoices = customerInvoices.map((invoice) => this._mapInvoice(invoice));
|
const invoices = customerInvoices.map((invoice) => this._mapInvoice(invoice));
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { Criteria } from "@repo/rdx-criteria/server";
|
|||||||
import { UniqueID } from "@repo/rdx-ddd";
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
import { Transaction } from "sequelize";
|
import { Transaction } from "sequelize";
|
||||||
import { CustomerInvoiceListResponseDTO } from "../../../common/dto";
|
import { ListCustomerInvoicesResponseDTO } from "../../../common/dto";
|
||||||
import { CustomerInvoiceService } from "../../domain";
|
import { CustomerInvoiceService } from "../../domain";
|
||||||
import { ListCustomerInvoicesPresenter } from "../presenters";
|
import { ListCustomerInvoicesPresenter } from "../presenters";
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ export class ListCustomerInvoicesUseCase {
|
|||||||
|
|
||||||
public execute(
|
public execute(
|
||||||
params: ListCustomerInvoicesUseCaseInput
|
params: ListCustomerInvoicesUseCaseInput
|
||||||
): Promise<Result<CustomerInvoiceListResponseDTO, Error>> {
|
): Promise<Result<ListCustomerInvoicesResponseDTO, Error>> {
|
||||||
const { criteria, companyId } = params;
|
const { criteria, companyId } = params;
|
||||||
const presenter = this.presenterRegistry.getPresenter({
|
const presenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "customer-invoice",
|
resource: "customer-invoice",
|
||||||
|
|||||||
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>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css"
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css"
|
||||||
referrerpolicy="no-referrer" />
|
referrerpolicy="no-referrer" />
|
||||||
<title>Presupuesto #{{id}}</title>
|
<title>Factura F26200</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
color: #000;
|
margin: 40px;
|
||||||
|
color: #333;
|
||||||
font-size: 11pt;
|
font-size: 11pt;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header {
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#footer {}
|
.company-info,
|
||||||
|
.invoice-meta {
|
||||||
|
width: 48%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-meta {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table th,
|
||||||
|
table td {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
table th {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals {
|
||||||
|
margin-top: 20px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals td {
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals td.label {
|
||||||
|
text-align: right;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
margin-top: 40px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
background-color: #eef;
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
thead {
|
thead {
|
||||||
@ -34,10 +97,199 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
|
||||||
<footer id="footer" class="mt-4">
|
<aside class="flex items-start mb-4 w-full">
|
||||||
<aside><img src="https://uecko.com/assets/img/uecko-footer_logos.jpg" class="w-full" /></aside>
|
<!-- Bloque IZQUIERDO: imagen arriba + texto abajo, alineado a la izquierda -->
|
||||||
</footer>
|
<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>
|
</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,
|
Province,
|
||||||
Street,
|
Street,
|
||||||
TINNumber,
|
TINNumber,
|
||||||
|
ValidationErrorDetail,
|
||||||
|
extractOrPushError,
|
||||||
maybeFromNullableVO,
|
maybeFromNullableVO,
|
||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
|
|
||||||
import {
|
import { IQueryMapperWithBulk, MapperParamsType, SequelizeQueryMapper } from "@erp/core/api";
|
||||||
IQueryMapperWithBulk,
|
|
||||||
MapperParamsType,
|
|
||||||
SequelizeQueryMapper,
|
|
||||||
ValidationErrorDetail,
|
|
||||||
extractOrPushError,
|
|
||||||
} from "@erp/core/api";
|
|
||||||
|
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
import { InvoiceRecipient } from "../../../domain";
|
import { InvoiceRecipient } from "../../../domain";
|
||||||
|
|||||||
@ -3,3 +3,4 @@ export * from "./customer-invoices-list.request.dto";
|
|||||||
export * from "./delete-customer-invoice-by-id.request.dto";
|
export * from "./delete-customer-invoice-by-id.request.dto";
|
||||||
export * from "./get-customer-invoice-by-id.request.dto";
|
export * from "./get-customer-invoice-by-id.request.dto";
|
||||||
export * from "./report-customer-invoice-by-id.request.dto";
|
export * from "./report-customer-invoice-by-id.request.dto";
|
||||||
|
export * from "./update-customer-invoice-by-id.request.dto";
|
||||||
|
|||||||
@ -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,7 +1,7 @@
|
|||||||
import { MetadataSchema, MoneySchema, createListViewResponseSchema } from "@erp/core";
|
import { MetadataSchema, MoneySchema, createListViewResponseSchema } from "@erp/core";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const ListCustomerInvoiceResponseSchema = createListViewResponseSchema(
|
export const ListCustomerInvoicesResponseSchema = createListViewResponseSchema(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
company_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>;
|
||||||
|
|||||||
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";
|
||||||
@ -1,25 +1,37 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { AG_GRID_LOCALE_ES } from "@ag-grid-community/locale";
|
import { AG_GRID_LOCALE_ES } from "@ag-grid-community/locale";
|
||||||
// Grid
|
import type {
|
||||||
import type { ColDef, GridOptions, ValueFormatterParams } from "ag-grid-community";
|
SizeColumnsToContentStrategy,
|
||||||
import { AllCommunityModule, ModuleRegistry } from "ag-grid-community";
|
SizeColumnsToFitGridStrategy,
|
||||||
|
SizeColumnsToFitProvidedWidthStrategy,
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
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 { MoneyDTO } from "@erp/core";
|
||||||
import { formatDate, formatMoney } from "@erp/core/client";
|
import { formatDate, formatMoney } from "@erp/core/client";
|
||||||
// Core CSS
|
import { Button } from "@repo/shadcn-ui/components";
|
||||||
import { AgGridReact } from "ag-grid-react";
|
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 { useCustomerInvoicesQuery } from "../hooks";
|
||||||
import { useTranslation } from "../i18n";
|
import { useTranslation } from "../i18n";
|
||||||
import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
|
import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
|
||||||
|
|
||||||
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
// Create new GridExample component
|
// Create new GridExample component
|
||||||
export const CustomerInvoicesListGrid = () => {
|
export const CustomerInvoicesListGrid = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading, isPending, isError, error } = useCustomerInvoicesQuery({
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: customersData,
|
||||||
|
isLoading: isLoadingCustomerInvoices,
|
||||||
|
isError: isLoadError,
|
||||||
|
error: loadError,
|
||||||
|
} = useCustomerInvoicesQuery({
|
||||||
pagination: {
|
pagination: {
|
||||||
pageSize: 999,
|
pageSize: 999,
|
||||||
},
|
},
|
||||||
@ -78,31 +90,56 @@ export const CustomerInvoicesListGrid = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "id",
|
colId: "actions",
|
||||||
headerName: t("pages.list.grid_columns.total_amount"),
|
headerName: t("pages.list.grid_columns.actions", "Actions"),
|
||||||
cellRenderer: (params: ValueFormatterParams) => {
|
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 = {
|
const autoSizeStrategy = useMemo<
|
||||||
rowModelType: "clientSide",
|
| SizeColumnsToFitGridStrategy
|
||||||
columnDefs: colDefs,
|
| SizeColumnsToFitProvidedWidthStrategy
|
||||||
defaultColDef: {
|
| SizeColumnsToContentStrategy
|
||||||
editable: false,
|
>(() => {
|
||||||
flex: 1,
|
return {
|
||||||
minWidth: 100,
|
type: "fitGridWidth",
|
||||||
filter: true,
|
defaultMinWidth: 100,
|
||||||
sortable: true,
|
columnLimits: [{ colId: "actions", minWidth: 75, maxWidth: 75 }],
|
||||||
resizable: true,
|
};
|
||||||
},
|
}, []);
|
||||||
pagination: true,
|
|
||||||
paginationPageSize: 15,
|
const gridOptions: GridOptions = useMemo(
|
||||||
paginationPageSizeSelector: [10, 15, 20, 30, 50],
|
() => ({
|
||||||
localeText: AG_GRID_LOCALE_ES,
|
columnDefs: colDefs,
|
||||||
rowSelection: { mode: "multiRow" },
|
autoSizeStrategy: autoSizeStrategy,
|
||||||
};
|
defaultColDef: {
|
||||||
|
editable: false,
|
||||||
|
flex: 1,
|
||||||
|
filter: false,
|
||||||
|
sortable: false,
|
||||||
|
resizable: true,
|
||||||
|
},
|
||||||
|
pagination: true,
|
||||||
|
paginationPageSize: 10,
|
||||||
|
paginationPageSizeSelector: [10, 20, 30, 50],
|
||||||
|
localeText: AG_GRID_LOCALE_ES,
|
||||||
|
}),
|
||||||
|
[autoSizeStrategy, colDefs]
|
||||||
|
);
|
||||||
|
|
||||||
// Container: Defines the grid's theme & dimensions.
|
// Container: Defines the grid's theme & dimensions.
|
||||||
return (
|
return (
|
||||||
@ -113,7 +150,11 @@ export const CustomerInvoicesListGrid = () => {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AgGridReact rowData={data?.items ?? []} loading={isLoading || isPending} {...gridOptions} />
|
<AgGridReact
|
||||||
|
rowData={customersData?.items ?? []}
|
||||||
|
loading={isLoadingCustomerInvoices}
|
||||||
|
{...gridOptions}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
import { useDataSource, useQueryKey } from "@erp/core/hooks";
|
import { useDataSource, useQueryKey } from "@erp/core/hooks";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { CustomerInvoiceListResponseDTO } from "../../common/dto";
|
import { ListCustomerInvoicesResponseDTO } from "../../common/dto";
|
||||||
|
|
||||||
// Obtener todas las facturas
|
// Obtener todas las facturas
|
||||||
export const useCustomerInvoicesQuery = (params: any) => {
|
export const useCustomerInvoicesQuery = (params?: any) => {
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const keys = useQueryKey();
|
const keys = useQueryKey();
|
||||||
|
|
||||||
return useQuery<CustomerInvoiceListResponseDTO>({
|
return useQuery<ListCustomerInvoicesResponseDTO>({
|
||||||
queryKey: keys().data().resource("customer-invoices").action("list").params(params).get(),
|
queryKey: keys().data().resource("customer-invoices").action("list").params(params).get(),
|
||||||
queryFn: (context) => {
|
queryFn: async (context) => {
|
||||||
console.log(dataSource.getBaseUrl());
|
|
||||||
console.log(params);
|
|
||||||
const { signal } = context;
|
const { signal } = context;
|
||||||
return dataSource.getList<CustomerInvoiceListResponseDTO>("customer-invoices", {
|
const invoices = await dataSource.getList("customer-invoices", {
|
||||||
signal,
|
signal,
|
||||||
...params,
|
...params,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return invoices as ListCustomerInvoicesResponseDTO;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { CustomerListDTO } from "@erp/customer-invoices/api/infrastructure";
|
|||||||
import { Criteria } from "@repo/rdx-criteria/server";
|
import { Criteria } from "@repo/rdx-criteria/server";
|
||||||
import { toEmptyString } from "@repo/rdx-ddd";
|
import { toEmptyString } from "@repo/rdx-ddd";
|
||||||
import { Collection } from "@repo/rdx-utils";
|
import { Collection } from "@repo/rdx-utils";
|
||||||
import { CustomerListResponsetDTO } from "../../../../common/dto";
|
import { ListCustomersResponseDTO } from "../../../../common/dto";
|
||||||
|
|
||||||
export class ListCustomersPresenter extends Presenter {
|
export class ListCustomersPresenter extends Presenter {
|
||||||
protected _mapCustomer(customer: CustomerListDTO) {
|
protected _mapCustomer(customer: CustomerListDTO) {
|
||||||
@ -54,7 +54,7 @@ export class ListCustomersPresenter extends Presenter {
|
|||||||
toOutput(params: {
|
toOutput(params: {
|
||||||
customers: Collection<CustomerListDTO>;
|
customers: Collection<CustomerListDTO>;
|
||||||
criteria: Criteria;
|
criteria: Criteria;
|
||||||
}): CustomerListResponsetDTO {
|
}): ListCustomersResponseDTO {
|
||||||
const { customers, criteria } = params;
|
const { customers, criteria } = params;
|
||||||
|
|
||||||
const items = customers.map((customer) => this._mapCustomer(customer));
|
const items = customers.map((customer) => this._mapCustomer(customer));
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { Criteria } from "@repo/rdx-criteria/server";
|
|||||||
import { UniqueID } from "@repo/rdx-ddd";
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
import { Transaction } from "sequelize";
|
import { Transaction } from "sequelize";
|
||||||
import { CustomerListResponsetDTO } from "../../../common/dto";
|
import { ListCustomersResponseDTO } from "../../../common/dto";
|
||||||
import { CustomerService } from "../../domain";
|
import { CustomerService } from "../../domain";
|
||||||
import { ListCustomersPresenter } from "../presenters";
|
import { ListCustomersPresenter } from "../presenters";
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ export class ListCustomersUseCase {
|
|||||||
|
|
||||||
public execute(
|
public execute(
|
||||||
params: ListCustomersUseCaseInput
|
params: ListCustomersUseCaseInput
|
||||||
): Promise<Result<CustomerListResponsetDTO, Error>> {
|
): Promise<Result<ListCustomersResponseDTO, Error>> {
|
||||||
const { criteria, companyId } = params;
|
const { criteria, companyId } = params;
|
||||||
const presenter = this.presenterRegistry.getPresenter({
|
const presenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "customer",
|
resource: "customer",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||||
import { UpdateCustomerRequestDTO } from "../../../../common/dto";
|
import { UpdateCustomerByIdRequestDTO } from "../../../../common/dto";
|
||||||
import { UpdateCustomerUseCase } from "../../../application";
|
import { UpdateCustomerUseCase } from "../../../application";
|
||||||
|
|
||||||
export class UpdateCustomerController extends ExpressController {
|
export class UpdateCustomerController extends ExpressController {
|
||||||
@ -15,7 +15,7 @@ export class UpdateCustomerController extends ExpressController {
|
|||||||
return this.forbiddenError("Tenant ID not found");
|
return this.forbiddenError("Tenant ID not found");
|
||||||
}
|
}
|
||||||
const { customer_id } = this.req.params;
|
const { customer_id } = this.req.params;
|
||||||
const dto = this.req.body as UpdateCustomerRequestDTO;
|
const dto = this.req.body as UpdateCustomerByIdRequestDTO;
|
||||||
|
|
||||||
const result = await this.useCase.execute({ customer_id, companyId, dto });
|
const result = await this.useCase.execute({ customer_id, companyId, dto });
|
||||||
|
|
||||||
|
|||||||
@ -31,4 +31,4 @@ export const UpdateCustomerByIdRequestSchema = z.object({
|
|||||||
currency_code: z.string().optional(),
|
currency_code: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateCustomerRequestDTO = z.infer<typeof UpdateCustomerByIdRequestSchema>;
|
export type UpdateCustomerByIdRequestDTO = z.infer<typeof UpdateCustomerByIdRequestSchema>;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
export * from "./create-customer.result.dto";
|
export * from "./create-customer.result.dto";
|
||||||
export * from "./customer-list.response.dto";
|
|
||||||
export * from "./get-customer-by-id.response.dto";
|
export * from "./get-customer-by-id.response.dto";
|
||||||
|
export * from "./list-customers.response.dto";
|
||||||
export * from "./update-customer-by-id.response.dto";
|
export * from "./update-customer-by-id.response.dto";
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { MetadataSchema, createListViewResponseSchema } from "@erp/core";
|
import { MetadataSchema, createListViewResponseSchema } from "@erp/core";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const CustomerListResponseSchema = createListViewResponseSchema(
|
export const ListCustomersResponseSchema = createListViewResponseSchema(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
company_id: z.uuid(),
|
company_id: z.uuid(),
|
||||||
@ -34,4 +34,4 @@ export const CustomerListResponseSchema = createListViewResponseSchema(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export type CustomerListResponsetDTO = z.infer<typeof CustomerListResponseSchema>;
|
export type ListCustomersResponseDTO = z.infer<typeof ListCustomersResponseSchema>;
|
||||||
@ -18,15 +18,19 @@ export const UpdateCustomerByIdResponseSchema = z.object({
|
|||||||
postal_code: z.string(),
|
postal_code: z.string(),
|
||||||
country: z.string(),
|
country: z.string(),
|
||||||
|
|
||||||
email: z.string(),
|
email_primary: z.string(),
|
||||||
phone: z.string(),
|
email_secondary: z.string(),
|
||||||
|
phone_primary: z.string(),
|
||||||
|
phone_secondary: z.string(),
|
||||||
|
mobile_primary: z.string(),
|
||||||
|
mobile_secondary: z.string(),
|
||||||
|
|
||||||
fax: z.string(),
|
fax: z.string(),
|
||||||
website: z.string(),
|
website: z.string(),
|
||||||
|
|
||||||
legal_record: z.string(),
|
legal_record: z.string(),
|
||||||
|
|
||||||
default_taxes: z.string(),
|
default_taxes: z.string(),
|
||||||
status: z.string(),
|
|
||||||
language_code: z.string(),
|
language_code: z.string(),
|
||||||
currency_code: z.string(),
|
currency_code: z.string(),
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,11 @@
|
|||||||
"name": "Name",
|
"name": "Name",
|
||||||
"trade_name": "Trade name",
|
"trade_name": "Trade name",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"email": "Email"
|
"email": "Email",
|
||||||
|
"phone": "Phone",
|
||||||
|
"city": "City",
|
||||||
|
"tin": "TIN",
|
||||||
|
"mobile": "Mobile"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
@ -71,15 +75,37 @@
|
|||||||
"placeholder": "Select country",
|
"placeholder": "Select country",
|
||||||
"description": "The country of the customer"
|
"description": "The country of the customer"
|
||||||
},
|
},
|
||||||
"email": {
|
"email_primary": {
|
||||||
"label": "Email",
|
"label": "Primary email",
|
||||||
"placeholder": "Enter email",
|
"placeholder": "Enter primary email",
|
||||||
"description": "The email address of the customer"
|
"description": "The primary email address of the customer"
|
||||||
},
|
},
|
||||||
"phone": {
|
"email_secondary": {
|
||||||
"label": "Phone",
|
"label": "Secondary email",
|
||||||
"placeholder": "Enter phone number",
|
"placeholder": "Enter secondary email",
|
||||||
"description": "The phone number of the customer"
|
"description": "The secondary email address of the customer"
|
||||||
|
},
|
||||||
|
|
||||||
|
"phone_primary": {
|
||||||
|
"label": "Primary phone",
|
||||||
|
"placeholder": "Enter primary phone number",
|
||||||
|
"description": "The primary phone number of the customer"
|
||||||
|
},
|
||||||
|
"phone_secondary": {
|
||||||
|
"label": "Secondary phone",
|
||||||
|
"placeholder": "Enter secondary phone number ",
|
||||||
|
"description": "The secondary phone number of the customer"
|
||||||
|
},
|
||||||
|
|
||||||
|
"mobile_primary": {
|
||||||
|
"label": "Primary mobile",
|
||||||
|
"placeholder": "Enter primary mobile number",
|
||||||
|
"description": "The primary mobile number of the customer"
|
||||||
|
},
|
||||||
|
"mobile_secondary": {
|
||||||
|
"label": "Secondary mobile",
|
||||||
|
"placeholder": "Enter secondary mobile number",
|
||||||
|
"description": "The secondary mobile number of the customer"
|
||||||
},
|
},
|
||||||
"fax": {
|
"fax": {
|
||||||
"label": "Fax",
|
"label": "Fax",
|
||||||
|
|||||||
@ -10,7 +10,11 @@
|
|||||||
"name": "Nombre",
|
"name": "Nombre",
|
||||||
"trade_name": "Nombre comercial",
|
"trade_name": "Nombre comercial",
|
||||||
"status": "Estado",
|
"status": "Estado",
|
||||||
"email": "Correo electrónico"
|
"email": "Correo electrónico",
|
||||||
|
"phone": "Teléfono",
|
||||||
|
"city": "Ciudad",
|
||||||
|
"tin": "Nº Id.",
|
||||||
|
"mobile": "Móvil"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
@ -71,16 +75,40 @@
|
|||||||
"placeholder": "Seleccione el país",
|
"placeholder": "Seleccione el país",
|
||||||
"description": "El país del cliente"
|
"description": "El país del cliente"
|
||||||
},
|
},
|
||||||
"email": {
|
|
||||||
"label": "Correo electrónico",
|
"email_primary": {
|
||||||
|
"label": "Email principal",
|
||||||
"placeholder": "Ingrese el correo electrónico",
|
"placeholder": "Ingrese el correo electrónico",
|
||||||
"description": "La dirección de correo electrónico del cliente"
|
"description": "La dirección de correo electrónico principal del cliente"
|
||||||
},
|
},
|
||||||
"phone": {
|
"email_secondary": {
|
||||||
|
"label": "Email secundario",
|
||||||
|
"placeholder": "Ingrese el correo electrónico",
|
||||||
|
"description": "La dirección de correo electrónico secundario del clientºe"
|
||||||
|
},
|
||||||
|
|
||||||
|
"phone_primary": {
|
||||||
"label": "Teléfono",
|
"label": "Teléfono",
|
||||||
"placeholder": "Ingrese el número de teléfono",
|
"placeholder": "Ingrese el número de teléfono",
|
||||||
"description": "El número de teléfono del cliente"
|
"description": "El número de teléfono del cliente"
|
||||||
},
|
},
|
||||||
|
"phone_secondary": {
|
||||||
|
"label": "Teléfono secundario",
|
||||||
|
"placeholder": "Ingrese el número de teléfono secundario",
|
||||||
|
"description": "El número de teléfono secundario del cliente"
|
||||||
|
},
|
||||||
|
|
||||||
|
"mobile_primary": {
|
||||||
|
"label": "Teléfono",
|
||||||
|
"placeholder": "Ingrese el número de teléfono",
|
||||||
|
"description": "El número de teléfono del cliente"
|
||||||
|
},
|
||||||
|
"mobile_secondary": {
|
||||||
|
"label": "Teléfono secundario",
|
||||||
|
"placeholder": "Ingrese el número de teléfono secundario",
|
||||||
|
"description": "El número de teléfono secundario del cliente"
|
||||||
|
},
|
||||||
|
|
||||||
"fax": {
|
"fax": {
|
||||||
"label": "Fax",
|
"label": "Fax",
|
||||||
"placeholder": "Ingrese el número de fax",
|
"placeholder": "Ingrese el número de fax",
|
||||||
|
|||||||
@ -18,10 +18,10 @@ import {
|
|||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
import { Building, Calendar, Mail, MapPin, Phone, Plus, User } from "lucide-react";
|
import { Building, Calendar, Mail, MapPin, Phone, Plus, User } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CustomerListResponsetDTO } from "../../common";
|
import { ListCustomersResponseDTO } from "../../common";
|
||||||
import { useCustomersQuery } from "../hooks";
|
import { useCustomersQuery } from "../hooks";
|
||||||
|
|
||||||
type Customer = CustomerListResponsetDTO["items"][number];
|
type Customer = ListCustomersResponseDTO["items"][number];
|
||||||
|
|
||||||
const columns: TableColumn<Customer>[] = [
|
const columns: TableColumn<Customer>[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,59 +1,124 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { AG_GRID_LOCALE_ES } from "@ag-grid-community/locale";
|
import { AG_GRID_LOCALE_ES } from "@ag-grid-community/locale";
|
||||||
// Grid
|
import type { ValueFormatterParams } from "ag-grid-community";
|
||||||
import type { ColDef, GridOptions, ValueFormatterParams } from "ag-grid-community";
|
import {
|
||||||
import { AllCommunityModule, ModuleRegistry } from "ag-grid-community";
|
AllCommunityModule,
|
||||||
|
ColDef,
|
||||||
|
GridOptions,
|
||||||
|
ModuleRegistry,
|
||||||
|
SizeColumnsToContentStrategy,
|
||||||
|
SizeColumnsToFitGridStrategy,
|
||||||
|
SizeColumnsToFitProvidedWidthStrategy,
|
||||||
|
} from "ag-grid-community";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
import { Button } from "@repo/shadcn-ui/components";
|
||||||
|
|
||||||
// Core CSS
|
|
||||||
import { AgGridReact } from "ag-grid-react";
|
import { AgGridReact } from "ag-grid-react";
|
||||||
|
import { ChevronRightIcon } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useCustomersQuery } from "../hooks";
|
import { useCustomersQuery } from "../hooks";
|
||||||
import { useTranslation } from "../i18n";
|
import { useTranslation } from "../i18n";
|
||||||
import { CustomerStatusBadge } from "./customer-status-badge";
|
import { CustomerStatusBadge } from "./customer-status-badge";
|
||||||
|
|
||||||
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
// Create new GridExample component
|
// Create new GridExample component
|
||||||
export const CustomersListGrid = () => {
|
export const CustomersListGrid = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading, isPending, isError, error } = useCustomersQuery({});
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: customersData,
|
||||||
|
isLoading: isLoadingCustomers,
|
||||||
|
isError: isLoadError,
|
||||||
|
error: loadError,
|
||||||
|
} = useCustomersQuery();
|
||||||
|
|
||||||
// Column Definitions: Defines & controls grid columns.
|
// Column Definitions: Defines & controls grid columns.
|
||||||
const [colDefs] = useState<ColDef[]>([
|
const [colDefs] = useState<ColDef[]>([
|
||||||
|
{ field: "name", headerName: t("pages.list.grid_columns.name"), minWidth: 300 },
|
||||||
|
{
|
||||||
|
field: "tin",
|
||||||
|
headerName: t("pages.list.grid_columns.tin"),
|
||||||
|
maxWidth: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "city",
|
||||||
|
headerName: t("pages.list.grid_columns.city"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "email_primary",
|
||||||
|
headerName: t("pages.list.grid_columns.email"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "phone_primary",
|
||||||
|
headerName: t("pages.list.grid_columns.phone"),
|
||||||
|
maxWidth: 120,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
field: "mobile_primary",
|
||||||
|
headerName: t("pages.list.grid_columns.mobile"),
|
||||||
|
maxWidth: 120,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: "status",
|
field: "status",
|
||||||
|
|
||||||
headerName: t("pages.list.grid_columns.status"),
|
headerName: t("pages.list.grid_columns.status"),
|
||||||
|
maxWidth: 125,
|
||||||
cellRenderer: (params: ValueFormatterParams) => {
|
cellRenderer: (params: ValueFormatterParams) => {
|
||||||
return <CustomerStatusBadge status={params.value} />;
|
return <CustomerStatusBadge status={params.value} />;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{ field: "name", headerName: t("pages.list.grid_columns.name") },
|
|
||||||
{ field: "trade_name", headerName: t("pages.list.grid_columns.trade_name") },
|
|
||||||
|
|
||||||
{
|
{
|
||||||
field: "email",
|
colId: "actions",
|
||||||
headerName: t("pages.list.grid_columns.email"),
|
headerName: t("pages.list.grid_columns.actions", "Actions"),
|
||||||
|
cellRenderer: (params: ValueFormatterParams) => {
|
||||||
|
const { data } = params;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant='secondary'
|
||||||
|
size='icon'
|
||||||
|
className='size-8'
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`${data.id}/edit`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const gridOptions: GridOptions = {
|
const autoSizeStrategy = useMemo<
|
||||||
columnDefs: colDefs,
|
| SizeColumnsToFitGridStrategy
|
||||||
defaultColDef: {
|
| SizeColumnsToFitProvidedWidthStrategy
|
||||||
editable: true,
|
| SizeColumnsToContentStrategy
|
||||||
flex: 1,
|
>(() => {
|
||||||
minWidth: 100,
|
return {
|
||||||
filter: false,
|
type: "fitGridWidth",
|
||||||
sortable: false,
|
defaultMinWidth: 100,
|
||||||
resizable: true,
|
columnLimits: [{ colId: "actions", minWidth: 75, maxWidth: 75 }],
|
||||||
},
|
};
|
||||||
pagination: true,
|
}, []);
|
||||||
paginationPageSize: 10,
|
|
||||||
paginationPageSizeSelector: [10, 20, 30, 50],
|
const gridOptions: GridOptions = useMemo(
|
||||||
localeText: AG_GRID_LOCALE_ES,
|
() => ({
|
||||||
rowSelection: { mode: "multiRow" },
|
columnDefs: colDefs,
|
||||||
};
|
autoSizeStrategy: autoSizeStrategy,
|
||||||
|
defaultColDef: {
|
||||||
|
editable: false,
|
||||||
|
flex: 1,
|
||||||
|
filter: false,
|
||||||
|
sortable: false,
|
||||||
|
resizable: true,
|
||||||
|
},
|
||||||
|
pagination: true,
|
||||||
|
paginationPageSize: 10,
|
||||||
|
paginationPageSizeSelector: [10, 20, 30, 50],
|
||||||
|
localeText: AG_GRID_LOCALE_ES,
|
||||||
|
}),
|
||||||
|
[autoSizeStrategy, colDefs]
|
||||||
|
);
|
||||||
|
|
||||||
// Container: Defines the grid's theme & dimensions.
|
// Container: Defines the grid's theme & dimensions.
|
||||||
return (
|
return (
|
||||||
@ -64,7 +129,11 @@ export const CustomersListGrid = () => {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AgGridReact rowData={data?.items ?? []} loading={isLoading || isPending} {...gridOptions} />
|
<AgGridReact
|
||||||
|
rowData={customersData?.items ?? []}
|
||||||
|
loading={isLoadingCustomers}
|
||||||
|
{...gridOptions}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { ModuleClientParams } from "@erp/core/client";
|
import { ModuleClientParams } from "@erp/core/client";
|
||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { Outlet, RouteObject } from "react-router-dom";
|
import { Outlet, RouteObject } from "react-router-dom";
|
||||||
|
import { CustomerUpdate } from "./pages/update";
|
||||||
|
|
||||||
// Lazy load components
|
// Lazy load components
|
||||||
const CustomersLayout = lazy(() =>
|
const CustomersLayout = lazy(() =>
|
||||||
@ -43,6 +44,7 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
|
|||||||
{ path: "", index: true, element: <CustomersList /> }, // index
|
{ path: "", index: true, element: <CustomersList /> }, // index
|
||||||
{ path: "list", element: <CustomersList /> },
|
{ path: "list", element: <CustomersList /> },
|
||||||
{ path: "create", element: <CustomerAdd /> },
|
{ path: "create", element: <CustomerAdd /> },
|
||||||
|
{ path: ":id/edit", element: <CustomerUpdate /> },
|
||||||
|
|
||||||
//
|
//
|
||||||
/*{ path: "create", element: <CustomersList /> },
|
/*{ path: "create", element: <CustomersList /> },
|
||||||
|
|||||||
@ -1 +1,5 @@
|
|||||||
|
export * from "./use-create-customer-mutation";
|
||||||
|
export * from "./use-customer-query";
|
||||||
|
export * from "./use-customers-context";
|
||||||
export * from "./use-customers-query";
|
export * from "./use-customers-query";
|
||||||
|
export * from "./use-update-customer-mutation";
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import { useDataSource, useQueryKey } from "@erp/core/hooks";
|
import { useDataSource, useQueryKey } from "@erp/core/hooks";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { UpdateCustomerRequestDTO } from "../../common/dto";
|
import { UpdateCustomerByIdRequestDTO } from "../../common/dto";
|
||||||
|
|
||||||
export const useCreateCustomerMutation = () => {
|
export const useCreateCustomerMutation = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const keys = useQueryKey();
|
const keys = useQueryKey();
|
||||||
|
|
||||||
return useMutation<UpdateCustomerRequestDTO, Error, Partial<UpdateCustomerRequestDTO>>({
|
return useMutation<UpdateCustomerByIdRequestDTO, Error, Partial<UpdateCustomerByIdRequestDTO>>({
|
||||||
|
mutationKey: ["customer:create"],
|
||||||
mutationFn: (data) => {
|
mutationFn: (data) => {
|
||||||
console.log(data);
|
console.log(data);
|
||||||
return dataSource.createOne("customers", data);
|
return dataSource.createOne("customers", data);
|
||||||
|
|||||||
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 { useDataSource, useQueryKey } from "@erp/core/hooks";
|
||||||
|
import { ListCustomersResponseDTO } from "@erp/customer-invoices/common";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { CustomerListResponsetDTO } from "../../common/dto";
|
|
||||||
|
|
||||||
// Obtener todas las facturas
|
// Obtener todas las facturas
|
||||||
export const useCustomersQuery = (params: any) => {
|
export const useCustomersQuery = (params?: any) => {
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const keys = useQueryKey();
|
const keys = useQueryKey();
|
||||||
|
|
||||||
return useQuery<CustomerListResponsetDTO>({
|
return useQuery<ListCustomersResponseDTO>({
|
||||||
queryKey: keys().data().resource("customers").action("list").params(params).get(),
|
queryKey: keys().data().resource("customers").action("list").params(params).get(),
|
||||||
queryFn: (context) => {
|
queryFn: async (context) => {
|
||||||
console.log(dataSource.getBaseUrl());
|
|
||||||
const { signal } = context;
|
const { signal } = context;
|
||||||
return dataSource.getList<CustomerListResponsetDTO>("customers", {
|
const customers = await dataSource.getList("customers", {
|
||||||
signal,
|
signal,
|
||||||
...params,
|
...params,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return customers as ListCustomersResponseDTO;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 { Button } from "@repo/shadcn-ui/components";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
@ -62,12 +62,12 @@ export const CustomerCreate = () => {
|
|||||||
{t("pages.create.description")}
|
{t("pages.create.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center justify-end mb-4'>
|
<ButtonGroup>
|
||||||
<BackHistoryButton />
|
<BackHistoryButton />
|
||||||
<Button type='submit' className='cursor-pointer'>
|
<Button type='submit' className='cursor-pointer'>
|
||||||
{t("pages.create.submit")}
|
{t("pages.create.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</ButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-1 flex-col gap-4 p-4'>
|
<div className='flex flex-1 flex-col gap-4 p-4'>
|
||||||
<CustomerEditForm onSubmit={handleSubmit} isPending={isPending} />
|
<CustomerEditForm onSubmit={handleSubmit} isPending={isPending} />
|
||||||
|
|||||||
@ -22,7 +22,7 @@ import {
|
|||||||
|
|
||||||
import { useUnsavedChangesNotifier } from "@erp/core/hooks";
|
import { useUnsavedChangesNotifier } from "@erp/core/hooks";
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { CustomerData, CustomerDataFormSchema } from "./customer.schema";
|
import { CustomerData, CustomerDataUpdateUpdateSchema } from "../../schemas";
|
||||||
|
|
||||||
const defaultCustomerData = {
|
const defaultCustomerData = {
|
||||||
id: "5e4dc5b3-96b9-4968-9490-14bd032fec5f",
|
id: "5e4dc5b3-96b9-4968-9490-14bd032fec5f",
|
||||||
@ -64,7 +64,7 @@ export const CustomerEditForm = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const form = useForm<CustomerData>({
|
const form = useForm<CustomerData>({
|
||||||
resolver: zodResolver(CustomerDataFormSchema),
|
resolver: zodResolver(CustomerDataUpdateUpdateSchema),
|
||||||
defaultValues: initialData,
|
defaultValues: initialData,
|
||||||
disabled: isPending,
|
disabled: isPending,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>;
|
|
||||||
345
modules/customers/src/web/pages/update/customer-edit-form.tsx
Normal file
345
modules/customers/src/web/pages/update/customer-edit-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
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,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src", "../../packages/rdx-ddd/src/helpers/extract-or-push-error.ts"],
|
"include": ["src"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,7 +50,12 @@ export function TextField<TFormValues extends FieldValues>({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input disabled={isDisabled} placeholder={placeholder} {...field} />
|
<Input
|
||||||
|
disabled={isDisabled}
|
||||||
|
placeholder={placeholder}
|
||||||
|
{...field}
|
||||||
|
className='font-medium'
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<p className={cn("text-xs text-muted-foreground", !description && "invisible")}>
|
<p className={cn("text-xs text-muted-foreground", !description && "invisible")}>
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import { type LucideIcon, MailIcon, PlusCircleIcon } from "lucide-react";
|
import { type LucideIcon, MailIcon, PlusCircleIcon } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
|
||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@repo/shadcn-ui/components/sidebar";
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
export function NavMain({
|
export function NavMain({
|
||||||
items,
|
items,
|
||||||
@ -18,6 +19,10 @@ export function NavMain({
|
|||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
}[];
|
}[];
|
||||||
}) {
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
console.log(window.location.href);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupContent className='flex flex-col gap-2'>
|
<SidebarGroupContent className='flex flex-col gap-2'>
|
||||||
@ -43,7 +48,12 @@ export function NavMain({
|
|||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<SidebarMenuItem key={item.title}>
|
<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 />}
|
{item.icon && <item.icon />}
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
|||||||
@ -1,56 +1,54 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { VariantProps, cva } from "class-variance-authority";
|
||||||
import { VariantProps, cva } from "class-variance-authority"
|
import { PanelLeftIcon } from "lucide-react";
|
||||||
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 {
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Separator,
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@repo/shadcn-ui/components/sheet"
|
Skeleton,
|
||||||
import { Skeleton } from "@repo/shadcn-ui/components/skeleton"
|
|
||||||
import {
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
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_NAME = "sidebar_state";
|
||||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||||
const SIDEBAR_WIDTH = "16rem"
|
const SIDEBAR_WIDTH = "16rem";
|
||||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||||
|
|
||||||
type SidebarContextProps = {
|
type SidebarContextProps = {
|
||||||
state: "expanded" | "collapsed"
|
state: "expanded" | "collapsed";
|
||||||
open: boolean
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void;
|
||||||
openMobile: boolean
|
openMobile: boolean;
|
||||||
setOpenMobile: (open: boolean) => void
|
setOpenMobile: (open: boolean) => void;
|
||||||
isMobile: boolean
|
isMobile: boolean;
|
||||||
toggleSidebar: () => void
|
toggleSidebar: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||||
|
|
||||||
function useSidebar() {
|
function useSidebar() {
|
||||||
const context = React.useContext(SidebarContext)
|
const context = React.useContext(SidebarContext);
|
||||||
if (!context) {
|
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({
|
function SidebarProvider({
|
||||||
@ -62,56 +60,53 @@ function SidebarProvider({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean;
|
||||||
open?: boolean
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile();
|
||||||
const [openMobile, setOpenMobile] = React.useState(false)
|
const [openMobile, setOpenMobile] = React.useState(false);
|
||||||
|
|
||||||
// This is the internal state of the sidebar.
|
// This is the internal state of the sidebar.
|
||||||
// We use openProp and setOpenProp for control from outside the component.
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||||
const open = openProp ?? _open
|
const open = openProp ?? _open;
|
||||||
const setOpen = React.useCallback(
|
const setOpen = React.useCallback(
|
||||||
(value: boolean | ((value: boolean) => boolean)) => {
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
const openState = typeof value === "function" ? value(open) : value
|
const openState = typeof value === "function" ? value(open) : value;
|
||||||
if (setOpenProp) {
|
if (setOpenProp) {
|
||||||
setOpenProp(openState)
|
setOpenProp(openState);
|
||||||
} else {
|
} else {
|
||||||
_setOpen(openState)
|
_setOpen(openState);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This sets the cookie to keep the sidebar state.
|
// 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]
|
[setOpenProp, open]
|
||||||
)
|
);
|
||||||
|
|
||||||
// Helper to toggle the sidebar.
|
// Helper to toggle the sidebar.
|
||||||
const toggleSidebar = React.useCallback(() => {
|
const toggleSidebar = React.useCallback(() => {
|
||||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||||
}, [isMobile, setOpen, setOpenMobile])
|
}, [isMobile, setOpen, setOpenMobile]);
|
||||||
|
|
||||||
// Adds a keyboard shortcut to toggle the sidebar.
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (
|
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
event.preventDefault();
|
||||||
(event.metaKey || event.ctrlKey)
|
toggleSidebar();
|
||||||
) {
|
|
||||||
event.preventDefault()
|
|
||||||
toggleSidebar()
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [toggleSidebar])
|
}, [toggleSidebar]);
|
||||||
|
|
||||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
// 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.
|
// 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>(
|
const contextValue = React.useMemo<SidebarContextProps>(
|
||||||
() => ({
|
() => ({
|
||||||
@ -124,13 +119,13 @@ function SidebarProvider({
|
|||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
}),
|
}),
|
||||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarContext.Provider value={contextValue}>
|
<SidebarContext.Provider value={contextValue}>
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-wrapper"
|
data-slot='sidebar-wrapper'
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--sidebar-width": SIDEBAR_WIDTH,
|
"--sidebar-width": SIDEBAR_WIDTH,
|
||||||
@ -148,7 +143,7 @@ function SidebarProvider({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</SidebarContext.Provider>
|
</SidebarContext.Provider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sidebar({
|
function Sidebar({
|
||||||
@ -159,16 +154,16 @@ function Sidebar({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
side?: "left" | "right"
|
side?: "left" | "right";
|
||||||
variant?: "sidebar" | "floating" | "inset"
|
variant?: "sidebar" | "floating" | "inset";
|
||||||
collapsible?: "offcanvas" | "icon" | "none"
|
collapsible?: "offcanvas" | "icon" | "none";
|
||||||
}) {
|
}) {
|
||||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||||
|
|
||||||
if (collapsible === "none") {
|
if (collapsible === "none") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar"
|
data-slot='sidebar'
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||||
className
|
className
|
||||||
@ -177,17 +172,17 @@ function Sidebar({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
data-sidebar="sidebar"
|
data-sidebar='sidebar'
|
||||||
data-slot="sidebar"
|
data-slot='sidebar'
|
||||||
data-mobile="true"
|
data-mobile='true'
|
||||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
className='bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden'
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||||
@ -195,28 +190,28 @@ function Sidebar({
|
|||||||
}
|
}
|
||||||
side={side}
|
side={side}
|
||||||
>
|
>
|
||||||
<SheetHeader className="sr-only">
|
<SheetHeader className='sr-only'>
|
||||||
<SheetTitle>Sidebar</SheetTitle>
|
<SheetTitle>Sidebar</SheetTitle>
|
||||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex h-full w-full flex-col">{children}</div>
|
<div className='flex h-full w-full flex-col'>{children}</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group peer text-sidebar-foreground hidden md:block"
|
className='group peer text-sidebar-foreground hidden md:block'
|
||||||
data-state={state}
|
data-state={state}
|
||||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
data-side={side}
|
data-side={side}
|
||||||
data-slot="sidebar"
|
data-slot='sidebar'
|
||||||
>
|
>
|
||||||
{/* This is what handles the sidebar gap on desktop */}
|
{/* This is what handles the sidebar gap on desktop */}
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-gap"
|
data-slot='sidebar-gap'
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||||
"group-data-[collapsible=offcanvas]:w-0",
|
"group-data-[collapsible=offcanvas]:w-0",
|
||||||
@ -227,7 +222,7 @@ function Sidebar({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-container"
|
data-slot='sidebar-container'
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||||
side === "left"
|
side === "left"
|
||||||
@ -242,54 +237,50 @@ function Sidebar({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-sidebar="sidebar"
|
data-sidebar='sidebar'
|
||||||
data-slot="sidebar-inner"
|
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"
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarTrigger({
|
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
|
||||||
className,
|
const { toggleSidebar } = useSidebar();
|
||||||
onClick,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Button>) {
|
|
||||||
const { toggleSidebar } = useSidebar()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
data-sidebar="trigger"
|
data-sidebar='trigger'
|
||||||
data-slot="sidebar-trigger"
|
data-slot='sidebar-trigger'
|
||||||
variant="ghost"
|
variant='ghost'
|
||||||
size="icon"
|
size='icon'
|
||||||
className={cn("size-7", className)}
|
className={cn("size-7", className)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
onClick?.(event)
|
onClick?.(event);
|
||||||
toggleSidebar()
|
toggleSidebar();
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<PanelLeftIcon />
|
<PanelLeftIcon />
|
||||||
<span className="sr-only">Toggle Sidebar</span>
|
<span className='sr-only'>Toggle Sidebar</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||||
const { toggleSidebar } = useSidebar()
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
data-sidebar="rail"
|
data-sidebar='rail'
|
||||||
data-slot="sidebar-rail"
|
data-slot='sidebar-rail'
|
||||||
aria-label="Toggle Sidebar"
|
aria-label='Toggle Sidebar'
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
title="Toggle Sidebar"
|
title='Toggle Sidebar'
|
||||||
className={cn(
|
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",
|
"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",
|
"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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
data-slot="sidebar-inset"
|
data-slot='sidebar-inset'
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background relative flex w-full flex-1 flex-col",
|
"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",
|
"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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarInput({
|
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Input>) {
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
data-slot="sidebar-input"
|
data-slot='sidebar-input'
|
||||||
data-sidebar="input"
|
data-sidebar='input'
|
||||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-header"
|
data-slot='sidebar-header'
|
||||||
data-sidebar="header"
|
data-sidebar='header'
|
||||||
className={cn("flex flex-col gap-2 p-2", className)}
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-footer"
|
data-slot='sidebar-footer'
|
||||||
data-sidebar="footer"
|
data-sidebar='footer'
|
||||||
className={cn("flex flex-col gap-2 p-2", className)}
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarSeparator({
|
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Separator>) {
|
|
||||||
return (
|
return (
|
||||||
<Separator
|
<Separator
|
||||||
data-slot="sidebar-separator"
|
data-slot='sidebar-separator'
|
||||||
data-sidebar="separator"
|
data-sidebar='separator'
|
||||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-content"
|
data-slot='sidebar-content'
|
||||||
data-sidebar="content"
|
data-sidebar='content'
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-group"
|
data-slot='sidebar-group'
|
||||||
data-sidebar="group"
|
data-sidebar='group'
|
||||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarGroupLabel({
|
function SidebarGroupLabel({
|
||||||
@ -398,12 +383,12 @@ function SidebarGroupLabel({
|
|||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot : "div"
|
const Comp = asChild ? Slot : "div";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="sidebar-group-label"
|
data-slot='sidebar-group-label'
|
||||||
data-sidebar="group-label"
|
data-sidebar='group-label'
|
||||||
className={cn(
|
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",
|
"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",
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||||
@ -411,7 +396,7 @@ function SidebarGroupLabel({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarGroupAction({
|
function SidebarGroupAction({
|
||||||
@ -419,12 +404,12 @@ function SidebarGroupAction({
|
|||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="sidebar-group-action"
|
data-slot='sidebar-group-action'
|
||||||
data-sidebar="group-action"
|
data-sidebar='group-action'
|
||||||
className={cn(
|
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",
|
"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.
|
// Increases the hit area of the button on mobile.
|
||||||
@ -434,43 +419,40 @@ function SidebarGroupAction({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarGroupContent({
|
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-group-content"
|
data-slot='sidebar-group-content'
|
||||||
data-sidebar="group-content"
|
data-sidebar='group-content'
|
||||||
className={cn("w-full text-sm", className)}
|
className={cn("w-full text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul
|
||||||
data-slot="sidebar-menu"
|
data-slot='sidebar-menu'
|
||||||
data-sidebar="menu"
|
data-sidebar='menu'
|
||||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
data-slot="sidebar-menu-item"
|
data-slot='sidebar-menu-item'
|
||||||
data-sidebar="menu-item"
|
data-sidebar='menu-item'
|
||||||
className={cn("group/menu-item relative", className)}
|
className={cn("group/menu-item relative", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidebarMenuButtonVariants = cva(
|
const sidebarMenuButtonVariants = cva(
|
||||||
@ -493,7 +475,7 @@ const sidebarMenuButtonVariants = cva(
|
|||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
function SidebarMenuButton({
|
function SidebarMenuButton({
|
||||||
asChild = false,
|
asChild = false,
|
||||||
@ -504,45 +486,45 @@ function SidebarMenuButton({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> & {
|
}: React.ComponentProps<"button"> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
isActive?: boolean
|
isActive?: boolean;
|
||||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button";
|
||||||
const { isMobile, state } = useSidebar()
|
const { isMobile, state } = useSidebar();
|
||||||
|
|
||||||
const button = (
|
const button = (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="sidebar-menu-button"
|
data-slot='sidebar-menu-button'
|
||||||
data-sidebar="menu-button"
|
data-sidebar='menu-button'
|
||||||
data-size={size}
|
data-size={size}
|
||||||
data-active={isActive}
|
data-active={isActive}
|
||||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
|
|
||||||
if (!tooltip) {
|
if (!tooltip) {
|
||||||
return button
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof tooltip === "string") {
|
if (typeof tooltip === "string") {
|
||||||
tooltip = {
|
tooltip = {
|
||||||
children: tooltip,
|
children: tooltip,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="right"
|
side='right'
|
||||||
align="center"
|
align='center'
|
||||||
hidden={state !== "collapsed" || isMobile}
|
hidden={state !== "collapsed" || isMobile}
|
||||||
{...tooltip}
|
{...tooltip}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuAction({
|
function SidebarMenuAction({
|
||||||
@ -551,15 +533,15 @@ function SidebarMenuAction({
|
|||||||
showOnHover = false,
|
showOnHover = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> & {
|
}: React.ComponentProps<"button"> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
showOnHover?: boolean
|
showOnHover?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="sidebar-menu-action"
|
data-slot='sidebar-menu-action'
|
||||||
data-sidebar="menu-action"
|
data-sidebar='menu-action'
|
||||||
className={cn(
|
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",
|
"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.
|
// Increases the hit area of the button on mobile.
|
||||||
@ -574,17 +556,14 @@ function SidebarMenuAction({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuBadge({
|
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-menu-badge"
|
data-slot='sidebar-menu-badge'
|
||||||
data-sidebar="menu-badge"
|
data-sidebar='menu-badge'
|
||||||
className={cn(
|
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",
|
"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",
|
"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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuSkeleton({
|
function SidebarMenuSkeleton({
|
||||||
@ -604,29 +583,24 @@ function SidebarMenuSkeleton({
|
|||||||
showIcon = false,
|
showIcon = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
showIcon?: boolean
|
showIcon?: boolean;
|
||||||
}) {
|
}) {
|
||||||
// Random width between 50 to 90%.
|
// Random width between 50 to 90%.
|
||||||
const width = React.useMemo(() => {
|
const width = React.useMemo(() => {
|
||||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-menu-skeleton"
|
data-slot='sidebar-menu-skeleton'
|
||||||
data-sidebar="menu-skeleton"
|
data-sidebar='menu-skeleton'
|
||||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{showIcon && (
|
{showIcon && <Skeleton className='size-4 rounded-md' data-sidebar='menu-skeleton-icon' />}
|
||||||
<Skeleton
|
|
||||||
className="size-4 rounded-md"
|
|
||||||
data-sidebar="menu-skeleton-icon"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Skeleton
|
<Skeleton
|
||||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
className='h-4 max-w-(--skeleton-width) flex-1'
|
||||||
data-sidebar="menu-skeleton-text"
|
data-sidebar='menu-skeleton-text'
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--skeleton-width": width,
|
"--skeleton-width": width,
|
||||||
@ -634,14 +608,14 @@ function SidebarMenuSkeleton({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul
|
||||||
data-slot="sidebar-menu-sub"
|
data-slot='sidebar-menu-sub'
|
||||||
data-sidebar="menu-sub"
|
data-sidebar='menu-sub'
|
||||||
className={cn(
|
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",
|
"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",
|
"group-data-[collapsible=icon]:hidden",
|
||||||
@ -649,21 +623,18 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuSubItem({
|
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"li">) {
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
data-slot="sidebar-menu-sub-item"
|
data-slot='sidebar-menu-sub-item'
|
||||||
data-sidebar="menu-sub-item"
|
data-sidebar='menu-sub-item'
|
||||||
className={cn("group/menu-sub-item relative", className)}
|
className={cn("group/menu-sub-item relative", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuSubButton({
|
function SidebarMenuSubButton({
|
||||||
@ -673,16 +644,16 @@ function SidebarMenuSubButton({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"a"> & {
|
}: React.ComponentProps<"a"> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
size?: "sm" | "md"
|
size?: "sm" | "md";
|
||||||
isActive?: boolean
|
isActive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "a"
|
const Comp = asChild ? Slot : "a";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="sidebar-menu-sub-button"
|
data-slot='sidebar-menu-sub-button'
|
||||||
data-sidebar="menu-sub-button"
|
data-sidebar='menu-sub-button'
|
||||||
data-size={size}
|
data-size={size}
|
||||||
data-active={isActive}
|
data-active={isActive}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -695,7 +666,7 @@ function SidebarMenuSubButton({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -723,4 +694,4 @@ export {
|
|||||||
SidebarSeparator,
|
SidebarSeparator,
|
||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
}
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user