This commit is contained in:
David Arranz 2025-11-13 12:49:36 +01:00
parent 1c66d20c24
commit 99c161cb31
47 changed files with 1613 additions and 892 deletions

View File

@ -11,6 +11,12 @@
"placeholder": "Select taxes",
"description": "Select the taxes to apply to the invoice items",
"invalid_tax_selection": "Invalid tax selection. Please select a valid tax."
},
"simple_search_input": {
"search_button": "Search",
"loading": "Loading",
"clear_search": "Clear search",
"search_placeholder": "Search..."
}
},
"hooks": {

View File

@ -10,6 +10,12 @@
"placeholder": "Seleccionar impuestos",
"description": "Seleccionar los impuestos a aplicar a los artículos de la factura",
"invalid_tax_selection": "Selección de impuestos no válida. Por favor, seleccione un impuesto válido."
},
"simple_search_input": {
"search_button": "Buscar",
"loading": "Buscando",
"clear_search": "Limpiar búsquedaClear search",
"search_placeholder": "Escribe aquí para buscar..."
}
},
"hooks": {

View File

@ -8,7 +8,8 @@ import {
import { Spinner } from "@repo/shadcn-ui/components/spinner";
import { SearchIcon, XIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../../i18n";
type SimpleSearchInputProps = {
onSearchChange: (value: string) => void;
@ -103,44 +104,48 @@ export const SimpleSearchInput = ({
};
return (
<div className='relative flex-1 max-w-xl'>
<InputGroup className='bg-background' data-disabled={loading}>
<div className="relative flex-1 max-w-xl">
<InputGroup className="bg-background" data-disabled={loading}>
<InputGroupInput
ref={inputRef}
placeholder={t("common.search_placeholder", "Search...")}
value={searchValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
inputMode='search'
autoComplete='off'
spellCheck={false}
autoComplete="off"
disabled={loading}
inputMode="search"
onChange={handleInputChange}
onFocus={() => history.length > 0 && setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={t("components.simple_search_input.search_placeholder", "Search...")}
ref={inputRef}
spellCheck={false}
value={searchValue}
/>
<InputGroupAddon>
<SearchIcon aria-hidden />
</InputGroupAddon>
<InputGroupAddon align='inline-end'>
{loading && <Spinner aria-label={t("common.loading", "Loading")} />}
{!searchValue && !loading && (
<InputGroupAddon align="inline-end">
{loading && (
<Spinner aria-label={t("components.simple_search_input.loading", "Loading")} />
)}
{!(searchValue || loading) && (
<InputGroupButton
variant='secondary'
className='cursor-pointer'
className="cursor-pointer"
onClick={() => onSearchChange(searchValue)}
variant="secondary"
>
{t("common.search", "Search")}
{t("components.simple_search_input.search_button", "Search")}
</InputGroupButton>
)}
{searchValue && !loading && (
<InputGroupButton
variant='secondary'
className='cursor-pointer'
aria-label={t("common.clear", "Clear search")}
aria-label={t("components.simple_search_input.clear_search", "Clear search")}
className="cursor-pointer"
onClick={handleClear}
variant="secondary"
>
<XIcon className='size-4' aria-hidden />
<span className='sr-only'>{t("common.clear", "Clear")}</span>
<XIcon aria-hidden className="size-4" />
<span className="sr-only">
{t("components.simple_search_input.clear_search", "Clear")}
</span>
</InputGroupButton>
)}
</InputGroupAddon>

View File

@ -1,4 +1,4 @@
import { ResponseType } from "axios";
import type { ResponseType } from "axios";
export interface ICustomParams {
url?: string;
@ -7,7 +7,7 @@ export interface ICustomParams {
signal?: AbortSignal;
responseType?: ResponseType;
headers?: {
[key: string]: any;
[key: string]: unknown;
};
[key: string]: unknown;
}

View File

@ -175,6 +175,26 @@ export class CustomerInvoiceApplicationService {
});
}
/**
* Obtiene una colección de proformas que cumplen con los filtros definidos en un objeto Criteria.
*
* @param companyId - Identificador de la empresa a la que pertenece la factura.
* @param criteria - Objeto con condiciones de filtro, paginación y orden.
* @param transaction - Transacción activa para la operación.
* @returns Result<Collection<ProformasListDTO>, Error> - Colección de proformas o error.
*/
async findProformasByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction, {
where: {
is_proforma: true,
},
});
}
/**
* Obtiene una colección de facturas que cumplen con los filtros definidos en un objeto Criteria.
*
@ -183,12 +203,16 @@ export class CustomerInvoiceApplicationService {
* @param transaction - Transacción activa para la operación.
* @returns Result<Collection<CustomerInvoiceListDTO>, Error> - Colección de facturas o error.
*/
async findInvoiceByCriteriaInCompany(
async findIssueInvoiceByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction);
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction, {
where: {
is_proforma: false,
},
});
}
/**

View File

@ -31,7 +31,7 @@ export class ListProformasUseCase {
return this.transactionManager.complete(async (transaction: Transaction) => {
try {
const result = await this.service.findInvoiceByCriteriaInCompany(
const result = await this.service.findProformasByCriteriaInCompany(
companyId,
criteria,
transaction

View File

@ -41,7 +41,7 @@ export interface ICustomerInvoiceRepository {
): Promise<Result<boolean, Error>>;
/**
* Recupera una factura por su ID y companyId.
* Recupera una factura/proforma por su ID y companyId.
* Devuelve un `NotFoundError` si no se encuentra.
*/
getByIdInCompany(
@ -53,13 +53,14 @@ export interface ICustomerInvoiceRepository {
/**
*
* Consulta facturas dentro de una empresa usando un
* Consulta facturas/proformas dentro de una empresa usando un
* objeto Criteria (filtros, orden, paginación).
* El resultado está encapsulado en un objeto `Collection<T>`.
*
* @param companyId - ID de la empresa.
* @param criteria - Criterios de búsqueda.
* @param transaction - Transacción activa para la operación.
* @param options - Opciones adicionales para la consulta (Sequelize FindOptions)
* @returns Result<Collection<CustomerInvoiceListDTO>, Error>
*
* @see Criteria
@ -67,7 +68,8 @@ export interface ICustomerInvoiceRepository {
findByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction: unknown
transaction: unknown,
options: unknown
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>>;
/**

View File

@ -51,7 +51,7 @@ export const issueInvoicesRouter = (params: ModuleParams) => {
//checkTabContext,
validateRequest(ListIssueInvoicesRequestSchema, "params"),
async (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.list();
const useCase = deps.useCases.list_proformas();
const controller = new ListProformasController(useCase /*, deps.presenters.list */);
return controller.execute(req, res, next);
}
@ -62,7 +62,7 @@ export const issueInvoicesRouter = (params: ModuleParams) => {
//checkTabContext,
validateRequest(GetIssueInvoiceByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.get();
const useCase = deps.useCases.get_proforma();
const controller = new GetProformaController(useCase);
return controller.execute(req, res, next);
}
@ -73,7 +73,7 @@ export const issueInvoicesRouter = (params: ModuleParams) => {
//checkTabContext,
validateRequest(ReportIssueInvoiceByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.report();
const useCase = deps.useCases.report_proforma();
const controller = new ReportProformaController(useCase);
return controller.execute(req, res, next);
}

View File

@ -1,25 +1,30 @@
import { ISequelizeQueryMapper, MapperParamsType, SequelizeQueryMapper } from "@erp/core/api";
import {
type ISequelizeQueryMapper,
type MapperParamsType,
SequelizeQueryMapper,
} from "@erp/core/api";
import {
CurrencyCode,
extractOrPushError,
LanguageCode,
maybeFromNullableVO,
Percentage,
UniqueID,
UtcDate,
ValidationErrorCollection,
ValidationErrorDetail,
type ValidationErrorDetail,
extractOrPushError,
maybeFromNullableVO,
} from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import { Maybe, Result } from "@repo/rdx-utils";
import {
CustomerInvoiceNumber,
CustomerInvoiceSerie,
CustomerInvoiceStatus,
InvoiceAmount,
InvoiceRecipient,
type InvoiceRecipient,
} from "../../../domain";
import { CustomerInvoiceModel } from "../../sequelize";
import type { CustomerInvoiceModel } from "../../sequelize";
import { InvoiceRecipientListMapper } from "./invoice-recipient.list.mapper";
export type CustomerInvoiceListDTO = {

View File

@ -7,7 +7,7 @@ import {
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import { type Collection, Result } from "@repo/rdx-utils";
import type { FindOptions, InferAttributes, Transaction } from "sequelize";
import type { FindOptions, InferAttributes, OrderItem, Transaction } from "sequelize";
import type {
CustomerInvoice,
@ -293,7 +293,8 @@ export class CustomerInvoiceRepository
public async findByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction: Transaction
transaction: Transaction,
options: FindOptions<InferAttributes<CustomerInvoiceModel>> = {}
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
const { CustomerModel } = this._database.models;
@ -315,13 +316,30 @@ export class CustomerInvoiceRepository
strictMode: true, // fuerza error si ORDER BY no permitido
});
// Normalización defensiva de order/include
const normalizedOrder = Array.isArray(options.order)
? options.order
: options.order
? [options.order]
: [];
const normalizedInclude = Array.isArray(options.include)
? options.include
: options.include
? [options.include]
: [];
query.where = {
...query.where,
company_id: companyId.toString(),
deleted_at: null,
...(options.where ?? {}),
};
query.order = [...(query.order as OrderItem[]), ...normalizedOrder];
query.include = [
...normalizedInclude,
{
model: CustomerModel,
as: "current_customer",
@ -338,7 +356,6 @@ export class CustomerInvoiceRepository
"country",
],
},
{
model: CustomerInvoiceTaxModel,
as: "taxes",

View File

@ -31,61 +31,63 @@
}
},
"pages": {
"title": "Customer invoices",
"description": "Manage your customer invoices",
"list": {
"title": "Customer invoices",
"description": "List all customer invoices",
"grid_columns": {
"invoice_number": "Inv. number",
"series": "Serie",
"status": "Status",
"invoice_date": "Invoice date",
"operation_date": "Operation date",
"recipient_tin": "TIN",
"recipient_name": "Customer name",
"recipient_street": "Street",
"recipient_city": "City",
"recipient_province": "Province",
"recipient_postal_code": "Postal code",
"recipient_country": "Country",
"total_amount": "Total price"
"proformas": {
"title": "Proformas",
"description": "Manage your customer proformas",
"list": {
"title": "Customer proformas",
"description": "List all customer proformas",
"grid_columns": {
"invoice_number": "Inv. number",
"series": "Serie",
"status": "Status",
"invoice_date": "Proforma date",
"operation_date": "Operation date",
"recipient_tin": "TIN",
"recipient_name": "Customer name",
"recipient_street": "Street",
"recipient_city": "City",
"recipient_province": "Province",
"recipient_postal_code": "Postal code",
"recipient_country": "Country",
"total_amount": "Total price"
}
},
"create": {
"title": "New customer proforma",
"description": "Create a new customer proforma",
"back_to_list": "Back to the list"
},
"edit": {
"title": "Edit customer proforma",
"description": "Edit the selected customer proforma"
},
"delete": {
"title": "Delete customer proforma",
"description": "Delete the selected customer proforma"
},
"view": {
"title": "View customer proforma",
"description": "View the details of the selected customer proforma"
}
},
"create": {
"title": "New customer invoice",
"description": "Create a new customer invoice",
"back_to_list": "Back to the list"
},
"edit": {
"title": "Edit customer invoice",
"description": "Edit the selected customer invoice"
},
"delete": {
"title": "Delete customer invoice",
"description": "Delete the selected customer invoice"
},
"view": {
"title": "View customer invoice",
"description": "View the details of the selected customer invoice"
}
},
"form_groups": {
"customer": {
"title": "Customer",
"description": "Select the customer for this invoice."
"description": "Select the customer for this proforma."
},
"items": {
"title": "Invoice details",
"title": "Proforma details",
"description": ""
},
"basic_info": {
"title": "Invoice information",
"description": "Basic invoice information"
"title": "Proforma information",
"description": "Basic proforma information"
},
"totals": {
"title": "Invoice totals",
"description": "Breakdown of invoice amounts with discounts and taxes."
"title": "Proforma totals",
"description": "Breakdown of proforma amounts with discounts and taxes."
},
"tax_resume": {
"title": "Resumen de impuestos",
@ -93,7 +95,7 @@
},
"preferences": {
"title": "Preferences",
"description": "Additional invoice settings"
"description": "Additional proforma settings"
}
},
"form_fields": {
@ -103,39 +105,39 @@
"description": ""
},
"invoice_number": {
"label": "Invoice number",
"label": "Proforma number",
"placeholder": "",
"description": ""
},
"invoice_date": {
"label": "Invoice date",
"label": "Proforma date",
"placeholder": "Select a date",
"description": "Invoice date"
"description": "Proforma date"
},
"series": {
"label": "Serie",
"placeholder": "",
"description": "Invoice serie"
"description": "Proforma serie"
},
"operation_date": {
"label": "Operation date",
"placeholder": "Select a date",
"description": "Invoice operation date"
"description": "Proforma operation date"
},
"reference": {
"label": "Reference",
"placeholder": "Reference of the invoice",
"description": "Reference of the invoice"
"placeholder": "Reference of the proforma",
"description": "Reference of the proforma"
},
"description": {
"label": "Description",
"placeholder": "Description of the invoice",
"description": "General description of the invoice"
"placeholder": "Description of the proforma",
"description": "General description of the proforma"
},
"subtotal_amount": {
"label": "Subtotal",
"placeholder": "",
"desc": "Invoice subtotal"
"desc": "Proforma subtotal"
},
"discount": {
"label": "Discount (%)",
@ -150,12 +152,12 @@
"total_amount": {
"label": "Total price",
"placeholder": "",
"desc": "Invoice total price"
"desc": "Proforma total price"
},
"notes": {
"label": "Notes",
"placeholder": "Additional notes about the invoice",
"description": "Additional notes that can be included in the invoice"
"placeholder": "Additional notes about the proforma",
"description": "Additional notes that can be included in the proforma"
},
"item": {
"quantity": {
@ -201,7 +203,7 @@
"total_amount": {
"label": "Total amount",
"placeholder": "",
"description": "Invoice line total"
"description": "Proforma line total"
}
}
},
@ -212,7 +214,7 @@
"customer_invoice_taxes_multi_select": {
"label": "Taxes",
"placeholder": "Select taxes",
"description": "Select the taxes to apply to the invoice items",
"description": "Select the taxes to apply to the proforma items",
"invalid_tax_selection": "Invalid tax selection. Please select a valid tax."
},
"hover_card_totals_summary": {

View File

@ -30,78 +30,80 @@
}
},
"pages": {
"title": "Facturas de clientes",
"description": "Gestiona tus facturas de clientes",
"list": {
"title": "Facturas de clientes",
"description": "Lista todas las facturas de clientes",
"grid_columns": {
"invoice_number": "Nº factura",
"series": "Serie",
"status": "Estado",
"invoice_date": "Fecha de factura",
"operation_date": "Fecha de operación",
"recipient_tin": "NIF/CIF",
"recipient_name": "Cliente",
"recipient_street": "Dirección",
"recipient_city": "Ciudad",
"recipient_province": "Provincia",
"recipient_postal_code": "Código postal",
"recipient_country": "País",
"total_amount": "Importe total"
"proformas": {
"title": "Proformas",
"description": "Gestiona tus proformas",
"list": {
"title": "Proformas",
"description": "Lista todas las proformas",
"grid_columns": {
"invoice_number": "Nº proforma",
"series": "Serie",
"status": "Estado",
"invoice_date": "Fecha de proforma",
"operation_date": "Fecha de operación",
"recipient_tin": "NIF/CIF",
"recipient_name": "Cliente",
"recipient_street": "Dirección",
"recipient_city": "Ciudad",
"recipient_province": "Provincia",
"recipient_postal_code": "Código postal",
"recipient_country": "País",
"total_amount": "Importe total"
}
},
"create": {
"title": "Nueva proforma",
"description": "Crear una nueva proforma",
"back_to_list": "Volver al listado"
},
"edit": {
"title": "Editar proforma",
"description": "Editar la proforma seleccionada"
},
"delete": {
"title": "Eliminar proforma",
"description": "Eliminar la proforma seleccionada"
},
"view": {
"title": "Ver proforma",
"description": "Ver los detalles de la proforma seleccionada"
}
},
"create": {
"title": "Nueva factura de cliente",
"description": "Crear una nueva factura de cliente",
"back_to_list": "Volver al listado"
},
"edit": {
"title": "Editar factura de cliente",
"description": "Editar la factura de cliente seleccionada"
},
"delete": {
"title": "Eliminar factura de cliente",
"description": "Eliminar la factura de cliente seleccionada"
},
"view": {
"title": "Ver factura de cliente",
"description": "Ver los detalles de la factura de cliente seleccionada"
}
},
"form_groups": {
"customer": {
"title": "Cliente",
"description": "Selecciona el cliente para esta factura"
"description": "Selecciona el cliente para esta proforma"
},
"items": {
"title": "Detalles de la factura",
"title": "Detalles de la proforma",
"description": ""
},
"basic_info": {
"title": "Información de la factura",
"description": "Información básica de la factura"
"title": "Información de la proforma",
"description": "Información básica de la proforma"
},
"totals": {
"title": "Totales de la factura",
"description": "Desglose de los importes de la factura con descuentos e impuestos."
"title": "Totales de la proforma",
"description": "Desglose de los importes de la proforma con descuentos e impuestos."
},
"preferences": {
"title": "Preferencias",
"description": "Configuraciones adicionales de la factura"
"description": "Configuraciones adicionales de la proforma"
}
},
"form_fields": {
"invoice_number": {
"label": "Número de factura",
"label": "Número de proforma",
"placeholder": "",
"description": ""
},
"invoice_date": {
"label": "Fecha",
"placeholder": "Selecciona una fecha",
"description": "Fecha de emisión de la factura"
"description": "Fecha de emisión de la proforma"
},
"series": {
"label": "Serie",
@ -111,23 +113,23 @@
"operation_date": {
"label": "Fecha de operación",
"placeholder": "Selecciona una fecha",
"description": "Fecha de la operación de la factura"
"description": "Fecha de la operación de la proforma"
},
"reference": {
"label": "Referencia",
"placeholder": "Referencia de la factura",
"description": "Referencia de la factura"
"placeholder": "Referencia de la proforma",
"description": "Referencia de la proforma"
},
"description": {
"label": "Descripción",
"placeholder": "Descripción de la factura",
"description": "Descripción general de la factura"
"placeholder": "Descripción de la proforma",
"description": "Descripción general de la proforma"
},
"subtotal_amount": {
"label": "Subtotal",
"placeholder": "",
"desc": "Subtotal de la factura"
"desc": "Subtotal de la proforma"
},
"discount": {
"label": "Descuento (%)",
@ -142,12 +144,12 @@
"total_amount": {
"label": "Precio total",
"placeholder": "",
"desc": "Precio total de la factura"
"desc": "Precio total de la proforma"
},
"notes": {
"label": "Notas",
"placeholder": "Notas adicionales sobre la factura",
"description": "Notas adicionales que se pueden incluir en la factura"
"placeholder": "Notas adicionales sobre la proforma",
"description": "Notas adicionales que se pueden incluir en la proforma"
},
"item": {
"quantity": {
@ -204,7 +206,7 @@
"customer_invoice_taxes_multi_select": {
"label": "Impuestos",
"placeholder": "Selecciona impuestos",
"description": "Selecciona los impuestos a aplicar a los artículos de la factura",
"description": "Selecciona los impuestos a aplicar a los artículos de la proforma",
"invalid_tax_selection": "Selección de impuestos no válida. Por favor, selecciona un impuesto válido."
},
"hover_card_totals_summary": {

View File

@ -8,7 +8,7 @@ const InvoicesLayout = lazy(() =>
);
const ProformaListPage = lazy(() =>
import("./pages").then((m) => ({ default: m.InvoiceListPage }))
import("./pages").then((m) => ({ default: m.ProformaListPage }))
);
const CustomerInvoiceAdd = lazy(() =>
@ -34,7 +34,7 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
{ path: ":id/edit", element: <InvoiceUpdatePage /> },
],
},
{
/*{
path: "customer-invoices",
element: (
<InvoicesLayout>
@ -45,7 +45,7 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
//{ path: "", index: true, element: <InvoiceListPage /> }, // index
//{ path: "list", element: <InvoiceListPage /> },
//
/*{ path: "create", element: <CustomerInvoicesList /> },
{ path: "create", element: <CustomerInvoicesList /> },
{ path: ":id", element: <CustomerInvoicesList /> },
{ path: ":id/edit", element: <CustomerInvoicesList /> },
{ path: ":id/delete", element: <CustomerInvoicesList /> },
@ -54,8 +54,8 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
{ path: ":id/email", element: <CustomerInvoicesList /> },
{ path: ":id/download", element: <CustomerInvoicesList /> },
{ path: ":id/duplicate", element: <CustomerInvoicesList /> },
{ path: ":id/preview", element: <CustomerInvoicesList /> },*/
{ path: ":id/preview", element: <CustomerInvoicesList /> },
],
},
},*/
];
};

View File

@ -1,19 +1,21 @@
import { useDataSource } from "@erp/core/hooks";
import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
import { DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { CreateCustomerInvoiceRequestSchema } from "../../common";
import { CustomerInvoice, InvoiceFormData } from "../schemas";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { CreateProformaRequestSchema } from "../../common";
import type { Proforma } from "../proformas/proforma.api.schema";
import type { InvoiceFormData } from "../schemas";
type CreateCustomerInvoicePayload = {
data: InvoiceFormData;
};
export const useCreateCustomerInvoiceMutation = () => {
export const useCreateProforma = () => {
const queryClient = useQueryClient();
const dataSource = useDataSource();
const schema = CreateCustomerInvoiceRequestSchema;
const schema = CreateProformaRequestSchema;
return useMutation<CustomerInvoice, DefaultError, CreateCustomerInvoicePayload>({
return useMutation<Proforma, DefaultError, CreateCustomerInvoicePayload>({
mutationKey: ["customer-invoice:create"],
mutationFn: async (payload) => {
@ -37,7 +39,7 @@ export const useCreateCustomerInvoiceMutation = () => {
}
const created = await dataSource.createOne("customer-invoices", newInvoiceData);
return created as CustomerInvoice;
return created;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["customer-invoices"] });

View File

@ -1,6 +1,7 @@
import { useDataSource } from "@erp/core/hooks";
import { DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
import { CustomerInvoice } from "../schemas";
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
import type { Proforma } from "../schemas";
export const CUSTOMER_INVOICE_QUERY_KEY = (id: string): QueryKey =>
["customer_invoice", id] as const;
@ -13,14 +14,14 @@ export const useInvoiceQuery = (invoiceId?: string, options?: InvoiceQueryOption
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && !!invoiceId;
return useQuery<CustomerInvoice, DefaultError>({
return useQuery<Proforma, DefaultError>({
queryKey: CUSTOMER_INVOICE_QUERY_KEY(invoiceId ?? "unknown"),
queryFn: async (context) => {
const { signal } = context;
if (!invoiceId) {
if (!invoiceId) throw new Error("invoiceId is required");
}
return await dataSource.getOne<CustomerInvoice>("customer-invoices", invoiceId, {
return await dataSource.getOne<Proforma>("customer-invoices", invoiceId, {
signal,
});
},

View File

@ -1,11 +1,10 @@
import { useDataSource } from "@erp/core/hooks";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
UpdateCustomerInvoiceByIdRequestDTO,
UpdateCustomerInvoiceByIdRequestSchema,
} from "../../common";
import { InvoiceFormData } from "../schemas";
import { type UpdateProformaByIdRequestDTO, UpdateProformaByIdRequestSchema } from "../../common";
import type { InvoiceFormData } from "../schemas";
import { CUSTOMER_INVOICE_QUERY_KEY } from "./use-invoice-query";
export const CUSTOMER_INVOICES_LIST_KEY = ["customer-invoices"] as const;
@ -17,10 +16,10 @@ type UpdateCustomerInvoicePayload = {
data: Partial<InvoiceFormData>;
};
export function useUpdateCustomerInvoice() {
export function useUpdateProforma() {
const queryClient = useQueryClient();
const dataSource = useDataSource();
const schema = UpdateCustomerInvoiceByIdRequestSchema;
const schema = UpdateProformaByIdRequestSchema;
return useMutation<
InvoiceFormData,
@ -59,7 +58,7 @@ export function useUpdateCustomerInvoice() {
const { id: invoiceId } = variables;
// Refresca inmediatamente el detalle
queryClient.setQueryData<UpdateCustomerInvoiceByIdRequestDTO>(
queryClient.setQueryData<UpdateProformaByIdRequestDTO>(
CUSTOMER_INVOICE_QUERY_KEY(invoiceId),
updated
);

View File

@ -0,0 +1,2 @@
export * from "./use-issue-invoice-query";
export * from "./use-issue-invoices-query";

View File

@ -0,0 +1,49 @@
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
import type { IssueInvoice } from "../issue-invoice.api.schema";
export const ISSUE_INVOICE_QUERY_KEY = (id: string): QueryKey => ["issue-invoice", id] as const;
type InvoiceQueryOptions = {
enabled?: boolean;
};
export const useIssueInvoiceQuery = (issueInvoiceId?: string, options?: InvoiceQueryOptions) => {
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && !!issueInvoiceId;
return useQuery<IssueInvoice, DefaultError>({
queryKey: ISSUE_INVOICE_QUERY_KEY(issueInvoiceId ?? "unknown"),
queryFn: async (context) => {
const { signal } = context;
if (!issueInvoiceId) {
if (!issueInvoiceId) throw new Error("issueInvoiceId is required");
}
return await dataSource.getOne<IssueInvoice>("issue-invoices", issueInvoiceId, {
signal,
});
},
enabled,
});
};
/*
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey
>
TQueryFnData: the type returned from the queryFn.
TError: the type of Errors to expect from the queryFn.
TData: the type our data property will eventually have.
Only relevant if you use the select option,
because then the data property can be different
from what the queryFn returns.
Otherwise, it will default to whatever the queryFn returns.
TQueryKey: the type of our queryKey, only relevant
if you use the queryKey that is passed to your queryFn.
*/

View File

@ -0,0 +1,41 @@
import type { CriteriaDTO } from "@erp/core";
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
import type { IssueInvoiceSummaryPage } from "../issue-invoice.api.schema";
export const ISSUE_INVOICES_QUERY_KEY = (criteria: CriteriaDTO): QueryKey => [
"issue-invoice",
{
pageNumber: criteria.pageNumber ?? 0,
pageSize: criteria.pageSize ?? 10,
q: criteria.q ?? "",
filters: criteria.filters ?? [],
orderBy: criteria.orderBy ?? "",
order: criteria.order ?? "",
},
];
type IssueInvoicesQueryOptions = {
enabled?: boolean;
criteria?: CriteriaDTO;
};
// Obtener todas las facturas
export const useIssueInvoicesQuery = (options?: IssueInvoicesQueryOptions) => {
const dataSource = useDataSource();
const enabled = options?.enabled ?? true;
const criteria = options?.criteria ?? {};
return useQuery<IssueInvoiceSummaryPage, DefaultError>({
queryKey: ISSUE_INVOICES_QUERY_KEY(criteria),
queryFn: async ({ signal }) => {
return await dataSource.getList<IssueInvoiceSummaryPage>("issue-invoices", {
signal,
...criteria,
});
},
enabled,
placeholderData: (previousData, previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
});
};

View File

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

View File

@ -0,0 +1,71 @@
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
import type { IssueInvoiceSummaryPage } from "./issue-invoice.api.schema";
import type {
IssueInvoiceSummaryData,
IssueInvoiceSummaryPageData,
} from "./issue-invoice-resume.form.schema";
/**
* Convierte el DTO completo de API a datos numéricos para el formulario.
*/
export const IssueInvoiceResumeDtoAdapter = {
fromDto(pageDto: IssueInvoiceSummaryPage, context?: unknown): IssueInvoiceSummaryPageData {
return {
...pageDto,
items: pageDto.items.map(
(summaryDto) =>
({
...summaryDto,
subtotal_amount: MoneyDTOHelper.toNumber(summaryDto.subtotal_amount),
subtotal_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.subtotal_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
discount_percentage: PercentageDTOHelper.toNumber(summaryDto.discount_percentage),
discount_percentage_fmt: PercentageDTOHelper.toNumericString(
summaryDto.discount_percentage
),
discount_amount: MoneyDTOHelper.toNumber(summaryDto.discount_amount),
discount_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.discount_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
taxable_amount: MoneyDTOHelper.toNumber(summaryDto.taxable_amount),
taxable_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.taxable_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
taxes_amount: MoneyDTOHelper.toNumber(summaryDto.taxes_amount),
taxes_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.taxes_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
total_amount: MoneyDTOHelper.toNumber(summaryDto.total_amount),
total_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.total_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
//taxes: dto.taxes,
}) as unknown as IssueInvoiceSummaryData
),
};
},
};

View File

@ -0,0 +1,25 @@
import type { IssueInvoiceSummary, IssueInvoiceSummaryPage } from "./issue-invoice.api.schema";
export type IssueInvoiceSummaryData = IssueInvoiceSummary & {
subtotal_amount_fmt: string;
subtotal_amount: number;
discount_percentage_fmt: string;
discount_percentage: number;
discount_amount_fmt: string;
discount_amount: number;
taxable_amount_fmt: string;
taxable_amount: number;
taxes_amoun_fmt: string;
taxes_amount: number;
total_amount_fmt: string;
total_amount: number;
};
export type IssueInvoiceSummaryPageData = IssueInvoiceSummaryPage & {
items: IssueInvoiceSummary[];
};

View File

@ -0,0 +1,23 @@
import {
GetIssueInvoiceByIdResponseSchema,
ListIssueInvoicesResponseSchema,
} from "@erp/customer-invoices/common";
import type { ArrayElement } from "@repo/rdx-utils";
import type { z } from "zod/v4";
// IssueInvoices
export const IssueInvoiceSchema = GetIssueInvoiceByIdResponseSchema.omit({
metadata: true,
});
export type IssueInvoice = z.infer<typeof IssueInvoiceSchema>;
export type IssueInvoiceRecipient = IssueInvoice["recipient"];
export type IssueInvoiceItem = ArrayElement<IssueInvoice["items"]>;
// Resultado de consulta con criteria (paginado, etc.)
export const IssueInvoiceSummaryPageSchema = ListIssueInvoicesResponseSchema.omit({
metadata: true,
});
export type IssueInvoiceSummaryPage = z.infer<typeof IssueInvoiceSummaryPageSchema>;
export type IssueInvoiceSummary = Omit<ArrayElement<IssueInvoiceSummaryPage["items"]>, "metadata">;

View File

@ -1,4 +1,5 @@
import { IModuleClient, ModuleClientParams } from "@erp/core/client";
import type { IModuleClient, ModuleClientParams } from "@erp/core/client";
import { CustomerInvoiceRoutes } from "./customer-invoice-routes";
export const MODULE_NAME = "CustomerInvoices";

View File

@ -1,15 +1,17 @@
import { AppContent } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useNavigate } from "react-router-dom";
import { useCreateCustomerInvoiceMutation } from "../../hooks";
import { useCreateProforma } from "../../hooks";
import { useTranslation } from "../../i18n";
import { CreateCustomerInvoiceEditForm } from "./create-customer-invoice-edit-form";
export const CustomerInvoiceCreate = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { mutate, isPending, isError, error } = useCreateCustomerInvoiceMutation();
const { mutate, isPending, isError, error } = useCreateProforma();
const handleSubmit = (data: any) => {
// Handle form submission logic here
@ -51,19 +53,19 @@ export const CustomerInvoiceCreate = () => {
return (
<>
<AppContent>
<div className='flex items-center justify-between space-y-2'>
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className='text-2xl font-bold tracking-tight'>{t("pages.create.title")}</h2>
<p className='text-muted-foreground'>{t("pages.create.description")}</p>
<h2 className="text-2xl font-bold tracking-tight">{t("pages.create.title")}</h2>
<p className="text-muted-foreground">{t("pages.create.description")}</p>
</div>
<div className='flex items-center justify-end mb-4'>
<Button className='cursor-pointer' onClick={() => navigate("/customer-invoices/list")}>
<div className="flex items-center justify-end mb-4">
<Button className="cursor-pointer" onClick={() => navigate("/customer-invoices/list")}>
{t("pages.create.back_to_list")}
</Button>
</div>
</div>
<div className='flex flex-1 flex-col gap-4 p-4'>
<CreateCustomerInvoiceEditForm onSubmit={handleSubmit} isPending={isPending} />
<div className="flex flex-1 flex-col gap-4 p-4">
<CreateCustomerInvoiceEditForm isPending={isPending} onSubmit={handleSubmit} />
</div>
</AppContent>
</>

View File

@ -1,7 +1,8 @@
import { z } from "zod/v4";
import { CreateCustomerInvoiceRequestSchema } from "../../../common/dto";
export const CustomerInvoiceItemDataFormSchema = CreateCustomerInvoiceRequestSchema.extend({
import { CreateProformaRequestSchema } from "../../../common/dto";
export const CustomerInvoiceItemDataFormSchema = CreateProformaRequestSchema.extend({
subtotal_price: z.object({
amount: z.number().nullable(),
scale: z.number(),

View File

@ -1,14 +1,12 @@
import type { CellKeyDownEvent, RowClickedEvent } from "ag-grid-community";
import { SimpleSearchInput } from "@erp/core/components";
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import { useCallback, useState } from "react";
import { SimpleSearchInput } from '@erp/core/components';
import { DataTable, SkeletonDataTable } from '@repo/rdx-ui/components';
import { useNavigate } from "react-router-dom";
import { usePinnedPreviewSheet } from '../../hooks';
import { usePinnedPreviewSheet } from "../../hooks";
import { useTranslation } from "../../i18n";
import { InvoiceSummaryFormData, InvoicesPageFormData } from '../../schemas';
import { useInvoicesListColumns } from './use-invoices-list-columns';
import { useProformasGridColumns } from "../../proformas/pages/list/use-proformas-grid-columns";
import type { InvoiceSummaryFormData, InvoicesPageFormData } from "../../schemas";
export type InvoiceUpdateCompProps = {
invoicesPage: InvoicesPageFormData;
@ -22,9 +20,12 @@ export type InvoiceUpdateCompProps = {
searchValue: string;
onSearchChange: (value: string) => void;
onRowClick?: (row: InvoiceSummaryFormData, index: number, event: React.MouseEvent<HTMLTableRowElement>) => void;
}
onRowClick?: (
row: InvoiceSummaryFormData,
index: number,
event: React.MouseEvent<HTMLTableRowElement>
) => void;
};
// Create new GridExample component
export const InvoicesListGrid = ({
@ -34,8 +35,9 @@ export const InvoicesListGrid = ({
pageSize,
onPageChange,
onPageSizeChange,
searchValue, onSearchChange,
onRowClick
searchValue,
onSearchChange,
onRowClick,
}: InvoiceUpdateCompProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
@ -49,7 +51,7 @@ export const InvoicesListGrid = ({
const [statusFilter, setStatusFilter] = useState("todas");
const columns = useInvoicesListColumns({
const columns = useProformasGridColumns({
onEdit: (invoice) => navigate(`/customer-invoices/${invoice.id}/edit`),
onDuplicate: (invoice) => null, //duplicateInvoice(inv.id),
onDownloadPdf: (invoice) => null, //downloadInvoicePdf(inv.id),
@ -61,9 +63,7 @@ export const InvoicesListGrid = ({
const goToRow = useCallback(
(id: string, newTab = false) => {
const url = `/customer-invoices/${id}/edit`;
newTab
? window.open(url, "_blank", "noopener,noreferrer")
: navigate(url);
newTab ? window.open(url, "_blank", "noopener,noreferrer") : navigate(url);
},
[navigate]
);
@ -71,8 +71,7 @@ export const InvoicesListGrid = ({
const onRowClicked = useCallback(
(e: RowClickedEvent<any>) => {
if (!e.data) return;
const newTab =
e.event instanceof MouseEvent && (e.event.metaKey || e.event.ctrlKey);
const newTab = e.event instanceof MouseEvent && (e.event.metaKey || e.event.ctrlKey);
goToRow(e.data.id, newTab);
},
[goToRow]
@ -83,7 +82,7 @@ export const InvoicesListGrid = ({
if (!e.data) return;
const ev = e.event;
if (!ev || !(ev instanceof KeyboardEvent)) return;
if (!(ev && ev instanceof KeyboardEvent)) return;
const key = ev.key;
if (key === "Enter" || key === " ") {
@ -98,11 +97,13 @@ export const InvoicesListGrid = ({
[goToRow]
);
const handleRowClick = useCallback(
(invoice: InvoiceSummaryFormData, _i: number, e: React.MouseEvent) => {
const url = `/customer-invoices/${invoice.id}/edit`;
if (e.metaKey || e.ctrlKey) { window.open(url, "_blank", "noopener,noreferrer"); return; }
if (e.metaKey || e.ctrlKey) {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
preview.open(invoice);
},
[preview]
@ -113,9 +114,9 @@ export const InvoicesListGrid = ({
<div className="flex flex-col gap-4">
<SkeletonDataTable
columns={columns.length}
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
rows={Math.max(6, pageSize)}
showFooter
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
/>
</div>
);
@ -126,7 +127,7 @@ export const InvoicesListGrid = ({
<div className="flex flex-col gap-4">
{/* Barra de filtros */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<SimpleSearchInput onSearchChange={onSearchChange} loading={loading} />
<SimpleSearchInput loading={loading} onSearchChange={onSearchChange} />
{/*<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-48 bg-white border-gray-200 shadow-sm">
<FilterIcon className="mr-2 h-4 w-4" />
@ -149,16 +150,16 @@ export const InvoicesListGrid = ({
<DataTable
columns={columns}
data={items}
readOnly
enableRowSelection
enablePagination
enableRowSelection
manualPagination
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={total_items}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onRowClick={handleRowClick}
pageIndex={pageIndex}
pageSize={pageSize}
readOnly
totalItems={total_items}
/>
</div>
@ -175,4 +176,4 @@ export const InvoicesListGrid = ({
</div>
</div>
);
};
};

View File

@ -1,14 +1,16 @@
import { PageHeader } from '@erp/core/components';
import { ErrorAlert } from '@erp/customers/components';
import { PageHeader } from "@erp/core/components";
import { ErrorAlert } from "@erp/customers/components";
import { AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useMemo, useState } from 'react';
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useInvoicesQuery } from '../../hooks';
import { useInvoicesQuery } from "../../hooks";
import { useTranslation } from "../../i18n";
import { invoiceResumeDtoToFormAdapter } from '../../schemas/invoice-resume-dto.adapter';
import { InvoicesListGrid } from './invoices-list-grid';
import { invoiceResumeDtoToFormAdapter } from "../../schemas/invoice-resume-dto.adapter";
import { InvoicesListGrid } from "./invoices-list-grid";
export const InvoiceListPage = () => {
const { t } = useTranslation();
@ -29,24 +31,18 @@ export const InvoiceListPage = () => {
[pageSize, pageIndex, debouncedQ]
);
const {
data,
isLoading,
isError,
error,
} = useInvoicesQuery({
criteria
const { data, isLoading, isError, error } = useInvoicesQuery({
criteria,
});
const invoicesPageData = useMemo(() => {
if (!data) return undefined;
return {
...data,
items: invoiceResumeDtoToFormAdapter.fromDto(data.items)
}
items: invoiceResumeDtoToFormAdapter.fromDto(data.items),
};
}, [data]);
const handlePageChange = (newPageIndex: number) => {
setPageIndex(newPageIndex);
};
@ -67,8 +63,8 @@ export const InvoiceListPage = () => {
return (
<AppContent>
<ErrorAlert
title={t("pages.list.loadErrorTitle")}
message={(error as Error)?.message || "Error al cargar el listado"}
title={t("pages.list.loadErrorTitle")}
/>
<BackHistoryButton />
</AppContent>
@ -79,36 +75,35 @@ export const InvoiceListPage = () => {
<>
<AppHeader>
<PageHeader
title={t("pages.list.title")}
description={t("pages.list.description")}
rightSlot={
<div className='flex items-center space-x-2'>
<div className="flex items-center space-x-2">
<Button
onClick={() => navigate("/customer-invoices/create")}
variant={'default'}
aria-label={t("pages.create.title")}
className='cursor-pointer'
className="cursor-pointer"
onClick={() => navigate("/customer-invoices/create")}
variant={"default"}
>
<PlusIcon className="mr-2 h-4 w-4" aria-hidden />
<PlusIcon aria-hidden className="mr-2 h-4 w-4" />
{t("pages.create.title")}
</Button>
</div>
}
title={t("pages.list.title")}
/>
</AppHeader>
<AppContent>
<div className='flex flex-col w-full h-full py-3'>
<div className="flex flex-col w-full h-full py-3">
<div className={"flex-1"}>
<InvoicesListGrid
invoicesPage={invoicesPageData}
loading={isLoading}
pageIndex={pageIndex}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
searchValue={search}
onSearchChange={handleSearchChange}
pageIndex={pageIndex}
pageSize={pageSize}
searchValue={search}
/>
</div>
</div>

View File

@ -1,233 +0,0 @@
import { formatDate } from '@erp/core/client';
import { DataTableColumnHeader } from '@repo/rdx-ui/components';
import {
Button, DropdownMenu, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,
Tooltip, TooltipContent, TooltipTrigger
} from '@repo/shadcn-ui/components';
import type { ColumnDef } from "@tanstack/react-table";
import { CopyIcon, DownloadIcon, EditIcon, MailIcon, MoreVerticalIcon, Trash2Icon } from 'lucide-react';
import * as React from "react";
import { CustomerInvoiceStatusBadge } from '../../components';
import { useTranslation } from '../../i18n';
import { InvoiceSummaryFormData } from '../../schemas';
type InvoiceActionHandlers = {
onEdit?: (invoice: InvoiceSummaryFormData) => void;
onDuplicate?: (invoice: InvoiceSummaryFormData) => void;
onDownloadPdf?: (invoice: InvoiceSummaryFormData) => void;
onSendEmail?: (invoice: InvoiceSummaryFormData) => void;
onDelete?: (invoice: InvoiceSummaryFormData) => void;
};
export function useInvoicesListColumns(
handlers: InvoiceActionHandlers = {}
): ColumnDef<InvoiceSummaryFormData>[] {
const { t } = useTranslation();
const {
onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete,
} = handlers;
return React.useMemo<ColumnDef<InvoiceSummaryFormData>[]>(() => [
// Nº
{
accessorKey: "invoice_number",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.invoice_number")} className="text-left" />
),
cell: ({ row }) => (
<div className="font-semibold text-left text-primary">
{row.getValue("invoice_number")}
</div>
),
enableHiding: false,
enableSorting: false,
size: 160,
minSize: 120,
},
// Estado
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.status")} className="text-left" />
),
cell: ({ row }) => <CustomerInvoiceStatusBadge status={row.getValue("status")} />,
enableSorting: false,
size: 140,
minSize: 120,
},
// Serie
{
accessorKey: "series",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.series")} className="text-left" />
),
cell: ({ row }) => <div className="font-medium text-left">{row.getValue("series")}</div>,
enableSorting: false,
size: 120,
minSize: 100,
},
// Fecha factura
{
accessorKey: "invoice_date",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.invoice_date")} className="text-left tabular-nums" />
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.getValue("invoice_date"))}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
},
// Fecha operación
{
accessorKey: "operation_date",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.operation_date")} className="text-left tabular-nums" />
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.getValue("operation_date"))}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
},
// TIN
{
id: "recipient_tin",
accessorKey: "recipient.tin",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.recipient_tin")} className="text-left tabular-nums" />
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">{row.getValue("recipient_tin")}</div>
),
enableSorting: false,
size: 160,
minSize: 140,
},
// Cliente
{
accessorKey: "recipient.name",
id: "recipient_name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.recipient_name")} className="text-left" />
),
cell: ({ row }) => (
<div className="font-semibold text-left truncate" title={row.getValue("recipient_name")}>
{row.getValue("recipient_name")}
</div>
),
enableSorting: false,
size: 260,
minSize: 200,
},
// Total
{
accessorKey: "total_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.total_amount")} className="text-right tabular-nums" />
),
cell: ({ row }) => (
<div className="font-semibold text-right tabular-nums">{row.getValue("total_amount_fmt")}</div>
),
enableSorting: false,
size: 140,
minSize: 120,
},
// ─────────────────────────────
// Acciones
// ─────────────────────────────
{
id: "actions",
header: () => <span className="sr-only">{t("common.actions")}</span>,
enableSorting: false,
enableHiding: false,
size: 110,
minSize: 96,
cell: ({ row }) => {
const invoice = row.original;
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
return (
<div className="flex items-center justify-end gap-1 pr-1" onClick={stop} onKeyDown={stop}>
{/* Editar (acción primaria) */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="sm"
variant="ghost"
className='cursor-pointer text-muted-foreground hover:text-primary'
aria-label={t("common.edit")}
onClick={(e) => {
e.stopPropagation();
onEdit?.(invoice);
}}
>
<EditIcon className="size-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.edit")}</TooltipContent>
</Tooltip>
{/* Menú demás acciones */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
variant="ghost"
className='cursor-pointer text-muted-foreground hover:text-primary'
aria-label={t("common.more_actions")}
onClick={stop}
>
<MoreVerticalIcon className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={() => onDuplicate?.(invoice)}
className="cursor-pointer"
>
<CopyIcon className="mr-2 size-4" />
{t("common.duplicate")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDownloadPdf?.(invoice)}
className="cursor-pointer"
>
<DownloadIcon className="mr-2 size-4" />
{t("common.download_pdf")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSendEmail?.(invoice)}
className="cursor-pointer"
>
<MailIcon className="mr-2 size-4" />
{t("common.send_email")}
</DropdownMenuItem> <DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete?.(invoice)}
className="text-destructive focus:text-destructive-foreground focus:bg-destructive cursor-pointer"
>
<Trash2Icon className="mr-2 size-4 text-destructive focus:text-destructive-foreground" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
], [t, onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete]);
}

View File

@ -1,116 +1,117 @@
import { PageHeader } from '@erp/core/components';
import {
UnsavedChangesProvider,
UpdateCommitButtonGroup,
useHookForm
} from "@erp/core/hooks";
import { PageHeader } from "@erp/core/components";
import { UnsavedChangesProvider, UpdateCommitButtonGroup, useHookForm } from "@erp/core/hooks";
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import { useId, useMemo } from 'react';
import { FieldErrors, FormProvider } from "react-hook-form";
import { useId, useMemo } from "react";
import { type FieldErrors, FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { useInvoiceContext } from '../../context';
import { useUpdateCustomerInvoice } from "../../hooks";
import { useInvoiceContext } from "../../context";
import { useUpdateProforma } from "../../hooks";
import { useTranslation } from "../../i18n";
import {
CustomerInvoice,
InvoiceFormData,
InvoiceFormSchema,
defaultCustomerInvoiceFormData,
invoiceDtoToFormAdapter
type InvoiceFormData,
InvoiceFormSchema,
type Proforma,
defaultCustomerInvoiceFormData,
invoiceDtoToFormAdapter,
} from "../../schemas";
import { InvoiceUpdateForm } from './invoice-update-form';
import { InvoiceUpdateForm } from "./invoice-update-form";
export type InvoiceUpdateCompProps = {
invoice: CustomerInvoice,
}
invoice: Proforma;
};
export const InvoiceUpdateComp = ({
invoice: invoiceData,
}: InvoiceUpdateCompProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const formId = useId();
export const InvoiceUpdateComp = ({ invoice: invoiceData }: InvoiceUpdateCompProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const formId = useId();
const context = useInvoiceContext();
const { invoice_id } = context;
const context = useInvoiceContext();
const { invoice_id } = context;
const isPending = !invoiceData;
const isPending = !invoiceData;
const {
mutate,
isPending: isUpdating,
isError: isUpdateError,
error: updateError,
} = useUpdateCustomerInvoice();
const {
mutate,
isPending: isUpdating,
isError: isUpdateError,
error: updateError,
} = useUpdateProforma();
const initialValues = useMemo(() => {
return invoiceData
? invoiceDtoToFormAdapter.fromDto(invoiceData, context)
: defaultCustomerInvoiceFormData
}, [invoiceData, context]);
const initialValues = useMemo(() => {
return invoiceData
? invoiceDtoToFormAdapter.fromDto(invoiceData, context)
: defaultCustomerInvoiceFormData;
}, [invoiceData, context]);
const form = useHookForm<InvoiceFormData>({
resolverSchema: InvoiceFormSchema,
initialValues,
disabled: !invoiceData || isUpdating,
});
const form = useHookForm<InvoiceFormData>({
resolverSchema: InvoiceFormSchema,
initialValues,
disabled: !invoiceData || isUpdating,
});
const handleSubmit = (formData: InvoiceFormData) => {
console.log('Guardo factura')
const dto = invoiceDtoToFormAdapter.toDto(formData, context)
console.log("dto => ", dto);
mutate(
{ id: invoice_id, data: dto as Partial<InvoiceFormData> },
{
onSuccess: () => showSuccessToast(t("pages.update.success.title"), t("pages.update.success.message")),
onError: (e) => showErrorToast(t("pages.update.errorTitle"), e.message),
}
);
};
const handleReset = () =>
form.reset((invoiceData as unknown as InvoiceFormData) ?? defaultCustomerInvoiceFormData);
const handleBack = () => {
navigate(-1);
};
const handleError = (errors: FieldErrors<InvoiceFormData>) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
return (
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
<AppHeader>
<PageHeader
backIcon
title={`${t("pages.edit.title")} #${invoiceData.invoice_number}`}
description={t("pages.edit.description")}
rightSlot={<>
<UpdateCommitButtonGroup
isLoading={isPending}
submit={{ formId, variant: 'default', disabled: isPending, label: t("pages.edit.actions.save_draft") }}
cancel={{ formId, to: "/customer-invoices/list" }}
/>
</>}
/>
</AppHeader>
<AppContent>
<FormProvider {...form}>
<InvoiceUpdateForm
formId={formId}
onSubmit={handleSubmit}
onError={handleError}
className="bg-white rounded-xl border shadow-xl max-w-full"
/>
</FormProvider>
</AppContent>
</UnsavedChangesProvider>
const handleSubmit = (formData: InvoiceFormData) => {
console.log("Guardo factura");
const dto = invoiceDtoToFormAdapter.toDto(formData, context);
console.log("dto => ", dto);
mutate(
{ id: invoice_id, data: dto as Partial<InvoiceFormData> },
{
onSuccess: () =>
showSuccessToast(t("pages.update.success.title"), t("pages.update.success.message")),
onError: (e) => showErrorToast(t("pages.update.errorTitle"), e.message),
}
);
};
};
const handleReset = () =>
form.reset((invoiceData as unknown as InvoiceFormData) ?? defaultCustomerInvoiceFormData);
const handleBack = () => {
navigate(-1);
};
const handleError = (errors: FieldErrors<InvoiceFormData>) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
return (
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
<AppHeader>
<PageHeader
backIcon
description={t("pages.edit.description")}
rightSlot={
<>
<UpdateCommitButtonGroup
cancel={{ formId, to: "/customer-invoices/list" }}
isLoading={isPending}
submit={{
formId,
variant: "default",
disabled: isPending,
label: t("pages.edit.actions.save_draft"),
}}
/>
</>
}
title={`${t("pages.edit.title")} #${invoiceData.invoice_number}`}
/>
</AppHeader>
<AppContent>
<FormProvider {...form}>
<InvoiceUpdateForm
className="bg-white rounded-xl border shadow-xl max-w-full"
formId={formId}
onError={handleError}
onSubmit={handleSubmit}
/>
</FormProvider>
</AppContent>
</UnsavedChangesProvider>
);
};

View File

@ -1 +1,2 @@
export * from "./use-proforma-query";
export * from "./use-proformas-query";

View File

@ -0,0 +1,49 @@
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
import type { Proforma } from "../proforma.api.schema";
export const PROFORMA_QUERY_KEY = (id: string): QueryKey => ["proforma", id] as const;
type InvoiceQueryOptions = {
enabled?: boolean;
};
export const useProformaQuery = (proformaId?: string, options?: InvoiceQueryOptions) => {
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && !!proformaId;
return useQuery<Proforma, DefaultError>({
queryKey: PROFORMA_QUERY_KEY(proformaId ?? "unknown"),
queryFn: async (context) => {
const { signal } = context;
if (!proformaId) {
if (!proformaId) throw new Error("proformaId is required");
}
return await dataSource.getOne<Proforma>("proformas", proformaId, {
signal,
});
},
enabled,
});
};
/*
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey
>
TQueryFnData: the type returned from the queryFn.
TError: the type of Errors to expect from the queryFn.
TData: the type our data property will eventually have.
Only relevant if you use the select option,
because then the data property can be different
from what the queryFn returns.
Otherwise, it will default to whatever the queryFn returns.
TQueryKey: the type of our queryKey, only relevant
if you use the queryKey that is passed to your queryFn.
*/

View File

@ -1,48 +1,41 @@
import type { CriteriaDTO } from "@erp/core";
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
export const CUSTOMER_INVOICE_QUERY_KEY = (id: string): QueryKey =>
["customer_invoice", id] as const;
import type { ProformaSummaryPage } from "../proforma.api.schema";
type InvoiceQueryOptions = {
export const PROFORMAS_QUERY_KEY = (criteria: CriteriaDTO): QueryKey => [
"proforma",
{
pageNumber: criteria.pageNumber ?? 0,
pageSize: criteria.pageSize ?? 10,
q: criteria.q ?? "",
filters: criteria.filters ?? [],
orderBy: criteria.orderBy ?? "",
order: criteria.order ?? "",
},
];
type ProformasQueryOptions = {
enabled?: boolean;
criteria?: CriteriaDTO;
};
export const useInvoiceQuery = (invoiceId?: string, options?: InvoiceQueryOptions) => {
// Obtener todas las facturas
export const useProformasQuery = (options?: ProformasQueryOptions) => {
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && !!invoiceId;
const enabled = options?.enabled ?? true;
const criteria = options?.criteria ?? {};
return useQuery<CustomerInvoice, DefaultError>({
queryKey: CUSTOMER_INVOICE_QUERY_KEY(invoiceId ?? "unknown"),
queryFn: async (context) => {
const { signal } = context;
if (!invoiceId) {
if (!invoiceId) throw new Error("invoiceId is required");
}
return await dataSource.getOne<CustomerInvoice>("customer-invoices", invoiceId, {
return useQuery<ProformaSummaryPage, DefaultError>({
queryKey: PROFORMAS_QUERY_KEY(criteria),
queryFn: async ({ signal }) => {
return await dataSource.getList<ProformaSummaryPage>("proformas", {
signal,
...criteria,
});
},
enabled,
placeholderData: (previousData, previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
});
};
/*
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey
>
TQueryFnData: the type returned from the queryFn.
TError: the type of Errors to expect from the queryFn.
TData: the type our data property will eventually have.
Only relevant if you use the select option,
because then the data property can be different
from what the queryFn returns.
Otherwise, it will default to whatever the queryFn returns.
TQueryKey: the type of our queryKey, only relevant
if you use the queryKey that is passed to your queryFn.
*/

View File

@ -1,3 +1,4 @@
import type { CriteriaDTO } from "@erp/core";
import { PageHeader } from "@erp/core/components";
import { ErrorAlert } from "@erp/customers/components";
import { AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components";
@ -6,9 +7,11 @@ import { PlusIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useInvoicesQuery } from "../../../hooks";
import { useTranslation } from "../../../i18n";
import { invoiceResumeDtoToFormAdapter } from "../../../schemas";
import { useProformasQuery } from "../../hooks";
import { ProformaResumeDtoAdapter } from "../../proforma-resume-dto.adapter";
import { ProformasGrid } from "./proformas-grid";
export const ProformaListPage = () => {
const { t } = useTranslation();
@ -21,24 +24,26 @@ export const ProformaListPage = () => {
const debouncedQ = useDebounce(search, 300);
const criteria = useMemo(
() => ({
q: debouncedQ || "",
pageSize,
pageNumber: pageIndex,
}),
() =>
({
q: debouncedQ || "",
pageSize,
pageNumber: pageIndex,
order: "desc",
orderBy: "invoice_date",
}) as CriteriaDTO,
[pageSize, pageIndex, debouncedQ]
);
const { data, isLoading, isError, error } = useInvoicesQuery({
console.log(criteria);
const { data, isLoading, isError, error } = useProformasQuery({
criteria,
});
const invoicesPageData = useMemo(() => {
const proformaPageData = useMemo(() => {
if (!data) return undefined;
return {
...data,
items: invoiceResumeDtoToFormAdapter.fromDto(data.items),
};
return ProformaResumeDtoAdapter.fromDto(data);
}, [data]);
const handlePageChange = (newPageIndex: number) => {
@ -57,12 +62,14 @@ export const ProformaListPage = () => {
setPageIndex(0);
};
if (isError || !invoicesPageData) {
console.log();
if (isError || !proformaPageData) {
return (
<AppContent>
<ErrorAlert
message={(error as Error)?.message || "Error al cargar el listado"}
title={t("pages.list.loadErrorTitle")}
title={t("pages.proformas.list.loadErrorTitle")}
/>
<BackHistoryButton />
</AppContent>
@ -73,28 +80,28 @@ export const ProformaListPage = () => {
<>
<AppHeader>
<PageHeader
description={t("pages.list.description")}
description={t("pages.proformas.list.description")}
rightSlot={
<div className="flex items-center space-x-2">
<Button
aria-label={t("pages.create.title")}
aria-label={t("pages.proformas.create.title")}
className="cursor-pointer"
onClick={() => navigate("/customer-invoices/create")}
onClick={() => navigate("/proformas/create")}
variant={"default"}
>
<PlusIcon aria-hidden className="mr-2 h-4 w-4" />
{t("pages.create.title")}
{t("pages.proformas.create.title")}
</Button>
</div>
}
title={t("pages.list.title")}
title={t("pages.proformas.list.title")}
/>
</AppHeader>
<AppContent>
<div className="flex flex-col w-full h-full py-3">
<div className={"flex-1"}>
<ProformasGrid
data={invoicesPageData}
data={proformaPageData}
loading={isLoading}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}

View File

@ -0,0 +1,196 @@
import { SimpleSearchInput } from "@erp/core/components";
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { FileDownIcon, FilterIcon } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { usePinnedPreviewSheet } from "../../../hooks";
import { useTranslation } from "../../../i18n";
import type { InvoiceSummaryFormData } from "../../../schemas";
import type { ProformaSummaryPageData } from "../../proforma-resume.form.schema";
import { useProformasGridColumns } from "./use-proformas-grid-columns";
export type ProformaGridProps = {
data: ProformaSummaryPageData;
loading?: boolean;
pageIndex: number;
pageSize: number;
onPageChange?: (pageNumber: number) => void;
onPageSizeChange?: (pageSize: number) => void;
searchValue: string;
onSearchChange: (value: string) => void;
onRowClick?: (
row: ProformaSummaryPageData,
index: number,
event: React.MouseEvent<HTMLTableRowElement>
) => void;
};
// Create new GridExample component
export const ProformasGrid = ({
data,
loading,
pageIndex,
pageSize,
onPageChange,
onPageSizeChange,
searchValue,
onSearchChange,
onRowClick,
}: ProformaGridProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { items, total_items } = data;
// Hook con Sheet de shadcn
const preview = usePinnedPreviewSheet<InvoiceSummaryFormData>({
persistKey: "invoice-preview-pin",
widthClass: "w-[500px]",
});
const [statusFilter, setStatusFilter] = useState("todas");
const columns = useProformasGridColumns({
onEdit: (proforma) => navigate(`/proformas/${proforma.id}/edit`),
onDuplicate: (proforma) => null, //duplicateInvoice(inv.id),
onDownloadPdf: (proforma) => null, //downloadInvoicePdf(inv.id),
onSendEmail: (proforma) => null, //sendInvoiceEmail(inv.id),
onDelete: (proforma) => null, //confirmDelete(inv.id),
});
// Navegación accesible (click o teclado)
/*const goToRow = useCallback(
(id: string, newTab = false) => {
const url = `/customer-invoices/${id}/edit`;
newTab ? window.open(url, "_blank", "noopener,noreferrer") : navigate(url);
},
[navigate]
);*/
/*const onRowClicked = useCallback(
(e: RowClickedEvent<any>) => {
if (!e.data) return;
const newTab = e.event instanceof MouseEvent && (e.event.metaKey || e.event.ctrlKey);
goToRow(e.data.id, newTab);
},
[goToRow]
);
const onCellKeyDown = useCallback(
(e: CellKeyDownEvent<any>) => {
if (!e.data) return;
const ev = e.event;
if (!(ev && ev instanceof KeyboardEvent)) return;
const key = ev.key;
if (key === "Enter" || key === " ") {
ev.preventDefault();
goToRow(e.data.id);
}
if ((ev.ctrlKey || ev.metaKey) && key === "Enter") {
ev.preventDefault();
goToRow(e.data.id, true);
}
},
[goToRow]
);
const handleRowClick = useCallback(
(invoice: InvoiceSummaryFormData, _i: number, e: React.MouseEvent) => {
const url = `/customer-invoices/${invoice.id}/edit`;
if (e.metaKey || e.ctrlKey) {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
preview.open(invoice);
},
[preview]
);*/
if (loading) {
return (
<div className="flex flex-col gap-4">
<SkeletonDataTable
columns={columns.length}
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
rows={Math.max(6, pageSize)}
showFooter
/>
</div>
);
}
// Render principal
return (
<div className="flex flex-col gap-4">
{/* Barra de filtros */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<SimpleSearchInput loading={loading} onSearchChange={onSearchChange} />
<Select onValueChange={setStatusFilter} value={statusFilter}>
<SelectTrigger className="w-full sm:w-48 bg-white border-gray-200 shadow-sm">
<FilterIcon className="mr-2 h-4 w-4" />
<SelectValue placeholder="Estado" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todas">Todas</SelectItem>
<SelectItem value="pagada">Borradores</SelectItem>
<SelectItem value="pendiente">Enviadas</SelectItem>
<SelectItem value="vencida">Aprobadas</SelectItem>
<SelectItem value="vencida">Rechazadas</SelectItem>
<SelectItem value="vencida">Emitidas</SelectItem>
</SelectContent>
</Select>
<Button
className="border-blue-200 text-blue-600 hover:bg-blue-50 bg-transparent"
variant="outline"
>
<FileDownIcon className="mr-2 h-4 w-4" />
Exportar
</Button>
</div>
<div className="relative flex">
<div className={preview.isPinned ? "flex-1 mr-[500px]" : "flex-1"}>
<DataTable
columns={columns}
data={items}
enablePagination
enableRowSelection
manualPagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
//onRowClick={handleRowClick}
pageIndex={pageIndex}
pageSize={pageSize}
readOnly
totalItems={total_items}
/>
</div>
{/*<preview.Preview>
{({ item, isPinned, close, togglePin }) => (
<InvoicePreviewPanel
invoice={item}
isPinned={isPinned}
onClose={close}
onTogglePin={togglePin}
/>
)}
</preview.Preview>*/}
</div>
</div>
);
};

View File

@ -0,0 +1,314 @@
import { formatDate } from "@erp/core/client";
import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import {
Avatar,
AvatarFallback,
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import {
Building2Icon,
CopyIcon,
DownloadIcon,
EditIcon,
MailIcon,
MoreVerticalIcon,
Trash2Icon,
User2Icon,
} from "lucide-react";
import * as React from "react";
import { CustomerInvoiceStatusBadge } from "../../../components";
import { useTranslation } from "../../../i18n";
import type { InvoiceSummaryFormData } from "../../../schemas";
type GridActionHandlers = {
onEdit?: (invoice: InvoiceSummaryFormData) => void;
onDuplicate?: (invoice: InvoiceSummaryFormData) => void;
onDownloadPdf?: (invoice: InvoiceSummaryFormData) => void;
onSendEmail?: (invoice: InvoiceSummaryFormData) => void;
onDelete?: (invoice: InvoiceSummaryFormData) => void;
};
function initials(name: string) {
const parts = name.trim().split(/\s+/).slice(0, 2);
return parts.map((p) => p[0]?.toUpperCase() ?? "").join("") || "?";
}
const KindBadge = ({ isCompany }: { isCompany: boolean }) => (
<Badge className="gap-1 tracking-wide text-xs text-foreground/70" variant="outline">
{isCompany ? <Building2Icon className="size-3.5" /> : <User2Icon className="size-3.5" />}
{isCompany ? "Company" : "Person"}
</Badge>
);
const Soft = ({ children }: { children: React.ReactNode }) => (
<span className="text-muted-foreground">{children}</span>
);
export function useProformasGridColumns(
actionHandlers: GridActionHandlers = {}
): ColumnDef<InvoiceSummaryFormData>[] {
const { t } = useTranslation();
const { onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete } = actionHandlers;
return React.useMemo<ColumnDef<InvoiceSummaryFormData>[]>(
() => [
// Nº
{
accessorKey: "invoice_number",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.invoice_number")}
/>
),
cell: ({ row }) => (
<div className="font-semibold text-left text-primary">{row.original.invoice_number}</div>
),
enableHiding: false,
enableSorting: false,
size: 160,
minSize: 120,
},
// Estado
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.status")}
/>
),
cell: ({ row }) => <CustomerInvoiceStatusBadge status={row.original.status} />,
enableSorting: false,
size: 140,
minSize: 120,
},
{
id: "recipient",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.list.grid_columns.recipient")}
/>
),
accessorFn: (row) => row.recipient.name, // para ordenar/buscar por nombre
enableHiding: false,
size: 140,
minSize: 120,
cell: ({ row }) => {
const c = row.original.recipient;
const isCompany = String(c.is_company).toLowerCase() === "true";
return (
<div className="flex items-start gap-1 my-1.5">
<Avatar className="size-10 hidden">
<AvatarFallback aria-label={c.name}>{initials(c.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0 grid gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium truncate text-primary">{c.name}</span>
{c.trade_name && <Soft>({c.trade_name})</Soft>}
</div>
<div className="flex flex-wrap items-center gap-2">
{c.tin && <span className="font-base truncate">{c.tin}</span>}
<KindBadge isCompany={isCompany} />
</div>
</div>
</div>
);
},
},
// Serie
{
accessorKey: "series",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.series")}
/>
),
cell: ({ row }) => <div className="font-normal text-left">{row.original.series}</div>,
enableSorting: false,
size: 120,
minSize: 100,
},
// Referencia
{
accessorKey: "reference",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.reference")}
/>
),
cell: ({ row }) => <div className="font-medium text-left">{row.original.reference}</div>,
enableSorting: false,
size: 120,
minSize: 100,
},
// Fecha factura
{
accessorKey: "invoice_date",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.invoice_date")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.original.invoice_date)}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
},
// Fecha operación
{
accessorKey: "operation_date",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.operation_date")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.original.operation_date)}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
},
// Total
{
accessorKey: "total_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.total_amount")}
/>
),
cell: ({ row }) => (
<div className="font-semibold text-right tabular-nums">
{row.original.total_amount_fmt}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
},
// ─────────────────────────────
// Acciones
// ─────────────────────────────
{
id: "actions",
header: () => <span className="sr-only">{t("common.actions")}</span>,
enableSorting: false,
enableHiding: false,
size: 110,
minSize: 96,
cell: ({ row }) => {
const invoice = row.original;
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
return (
<div className="flex items-center justify-end gap-1 pr-1">
{/* Editar (acción primaria) */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.edit")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={(e) => {
e.stopPropagation();
onEdit?.(invoice);
}}
size="sm"
type="button"
variant="ghost"
>
<EditIcon aria-hidden="true" className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.edit")}</TooltipContent>
</Tooltip>
{/* Menú demás acciones */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={t("common.more_actions")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={stop}
size="sm"
type="button"
variant="ghost"
>
<MoreVerticalIcon aria-hidden="true" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onDuplicate?.(invoice)}
>
<CopyIcon className="mr-2 size-4" />
{t("common.duplicate")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onDownloadPdf?.(invoice)}
>
<DownloadIcon className="mr-2 size-4" />
{t("common.download_pdf")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onSendEmail?.(invoice)}
>
<MailIcon className="mr-2 size-4" />
{t("common.send_email")}
</DropdownMenuItem>{" "}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive-foreground focus:bg-destructive cursor-pointer"
onClick={() => onDelete?.(invoice)}
>
<Trash2Icon className="mr-2 size-4 text-destructive focus:text-destructive-foreground" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
],
[t, onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete]
);
}

View File

@ -0,0 +1,68 @@
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
import type { ProformaSummaryPage } from "./proforma.api.schema";
import type { ProformaSummaryData, ProformaSummaryPageData } from "./proforma-resume.form.schema";
/**
* Convierte el DTO completo de API a datos numéricos para el formulario.
*/
export const ProformaResumeDtoAdapter = {
fromDto(pageDto: ProformaSummaryPage, context?: unknown): ProformaSummaryPageData {
return {
...pageDto,
items: pageDto.items.map(
(summaryDto) =>
({
...summaryDto,
subtotal_amount: MoneyDTOHelper.toNumber(summaryDto.subtotal_amount),
subtotal_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.subtotal_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
discount_percentage: PercentageDTOHelper.toNumber(summaryDto.discount_percentage),
discount_percentage_fmt: PercentageDTOHelper.toNumericString(
summaryDto.discount_percentage
),
discount_amount: MoneyDTOHelper.toNumber(summaryDto.discount_amount),
discount_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.discount_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
taxable_amount: MoneyDTOHelper.toNumber(summaryDto.taxable_amount),
taxable_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.taxable_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
taxes_amount: MoneyDTOHelper.toNumber(summaryDto.taxes_amount),
taxes_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.taxes_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
total_amount: MoneyDTOHelper.toNumber(summaryDto.total_amount),
total_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.total_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
//taxes: dto.taxes,
}) as unknown as ProformaSummaryData
),
};
},
};

View File

@ -0,0 +1,25 @@
import type { ProformaSummary, ProformaSummaryPage } from "./proforma.api.schema";
export type ProformaSummaryData = ProformaSummary & {
subtotal_amount_fmt: string;
subtotal_amount: number;
discount_percentage_fmt: string;
discount_percentage: number;
discount_amount_fmt: string;
discount_amount: number;
taxable_amount_fmt: string;
taxable_amount: number;
taxes_amoun_fmt: string;
taxes_amount: number;
total_amount_fmt: string;
total_amount: number;
};
export type ProformaSummaryPageData = ProformaSummaryPage & {
items: ProformaSummary[];
};

View File

@ -0,0 +1,31 @@
import {
CreateProformaRequestSchema,
GetProformaByIdResponseSchema,
ListProformasResponseSchema,
UpdateProformaByIdRequestSchema,
} from "@erp/customer-invoices/common";
import type { ArrayElement } from "@repo/rdx-utils";
import type { z } from "zod/v4";
// Proformas
export const ProformaSchema = GetProformaByIdResponseSchema.omit({
metadata: true,
});
export type Proforma = z.infer<typeof ProformaSchema>;
export type ProformaRecipient = Proforma["recipient"];
export type ProformaItem = ArrayElement<Proforma["items"]>;
export const CreateProformaSchema = CreateProformaRequestSchema;
export const UpdateProformaSchema = UpdateProformaByIdRequestSchema;
export type CreateProformaInput = z.infer<typeof CreateProformaSchema>; // Cuerpo para crear
export type UpdateProformaInput = z.infer<typeof UpdateProformaSchema>; // Cuerpo para actualizar
// Resultado de consulta con criteria (paginado, etc.)
export const ProformaSummaryPageSchema = ListProformasResponseSchema.omit({
metadata: true,
});
export type ProformaSummaryPage = z.infer<typeof ProformaSummaryPageSchema>;
export type ProformaSummary = Omit<ArrayElement<ProformaSummaryPage["items"]>, "metadata">;

View File

@ -1,35 +1,23 @@
import type { PaginationSchema } from "@erp/core";
import type { ArrayElement } from "@repo/rdx-utils";
import type { z } from "zod/v4";
import {
CreateCustomerInvoiceRequestSchema,
GetIssueInvoiceByIdResponseSchema,
ListIssueInvoicesResponseSchema,
UpdateCustomerInvoiceByIdRequestSchema,
} from "../../common";
import { GetIssueInvoiceByIdResponseSchema, ListIssueInvoicesResponseSchema } from "../../common";
export const CustomerInvoiceSchema = GetIssueInvoiceByIdResponseSchema.omit({
export const IssueInvoiceSchema = GetIssueInvoiceByIdResponseSchema.omit({
metadata: true,
});
export const CustomerInvoiceCreateSchema = CreateCustomerInvoiceRequestSchema;
export const CustomerInvoiceUpdateSchema = UpdateCustomerInvoiceByIdRequestSchema;
// Tipos (derivados de Zod o DTOs del backend)
export type CustomerInvoice = z.infer<typeof CustomerInvoiceSchema>;
export type CustomerInvoiceRecipient = CustomerInvoice["recipient"];
export type CustomerInvoiceItem = ArrayElement<CustomerInvoice["items"]>;
export type CustomerInvoiceCreateInput = z.infer<typeof CustomerInvoiceCreateSchema>; // Cuerpo para crear
export type CustomerInvoiceUpdateInput = z.infer<typeof CustomerInvoiceUpdateSchema>; // Cuerpo para actualizar
export type IssueInvoice = z.infer<typeof IssueInvoiceSchema>;
export type IssueInvoiceRecipient = IssueInvoice["recipient"];
export type IssueInvoiceItem = ArrayElement<IssueInvoice["items"]>;
// Resultado de consulta con criteria (paginado, etc.)
export const CustomerInvoicesPageSchema = ListIssueInvoicesResponseSchema.omit({
export const IssueInvoicesPageSchema = ListIssueInvoicesResponseSchema.omit({
metadata: true,
});
export type PaginatedResponse = z.infer<typeof PaginationSchema>;
export type CustomerInvoicesPage = z.infer<typeof CustomerInvoicesPageSchema>;
//export type PaginatedResponse = z.infer<typeof PaginationSchema>;
//export type CustomerInvoicesPage = z.infer<typeof CustomerInvoicesPageSchema>;
// Ítem simplificado dentro del listado (no toda la entidad)
export type CustomerInvoiceSummary = Omit<ArrayElement<CustomerInvoicesPage["items"]>, "metadata">;
//export type CustomerInvoiceSummary = Omit<ArrayElement<CustomerInvoicesPage["items"]>, "metadata">;

View File

@ -1,51 +1,43 @@
import { Badge } from "@repo/shadcn-ui/components";
import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { forwardRef } from "react";
import { useTranslation } from "../i18n";
import React from "react";
export type CustomerStatus = "active" | "inactive";
export type CustomerStatus = "active" | "inactive" | "error";
export type CustomerStatusBadgeProps = {
status: string; // permitir cualquier valor
className?: string;
};
const statusColorConfig: Record<CustomerStatus, { badge: string; dot: string }> = {
inactive: {
badge:
"bg-gray-600/10 dark:bg-gray-600/20 hover:bg-gray-600/10 text-gray-500 border-gray-600/60",
dot: "bg-gray-500",
},
active: {
badge:
"bg-emerald-600/10 dark:bg-emerald-600/20 hover:bg-emerald-600/10 text-emerald-500 border-emerald-600/60",
dot: "bg-emerald-500",
},
const statusColorConfig: Record<CustomerStatus, string> = {
inactive: "text-gray-400 bg-gray-100 dark:text-gray-500 dark:bg-gray-100/10",
active: "text-green-500 bg-green-500/10 dark:text-green-400 dark:bg-green-400/10",
error: "text-rose-500 bg-rose-500/10 dark:text-rose-400 dark:bg-rose-400/10",
};
export const CustomerStatusBadge = forwardRef<HTMLDivElement, CustomerStatusBadgeProps>(
({ status, className, ...props }, ref) => {
const { t } = useTranslation();
const normalizedStatus = status.toLowerCase() as CustomerStatus;
const config = statusColorConfig[normalizedStatus];
const commonClassName =
"transition-colors duration-200 cursor-pointer shadow-none rounded-full";
export const CustomerStatusBadge = ({ status }: { status: string }) => {
// Map visual simple; ajustar a tu catálogo real
const statusClass = React.useMemo(
() =>
status.toLowerCase() === "active" ? statusColorConfig.active : statusColorConfig.inactive,
[status]
);
const contentTxt = React.useMemo(
() =>
status.toLowerCase() === "active" ? "El cliente está activo" : "El cliente está inactivo",
[status]
);
if (!config) {
return (
<Badge ref={ref} className={cn(commonClassName, className)} {...props}>
{status}
</Badge>
);
}
return (
<Badge className={cn(commonClassName, config.badge, className)} {...props}>
<div className={cn("h-1.5 w-1.5 rounded-full mr-2", config.dot)} />
{t(`catalog.status.${status}`)}
</Badge>
);
}
);
return (
<Tooltip>
<TooltipTrigger asChild>
<div className={cn("flex-none rounded-full p-1", statusClass)}>
<div className="size-2 rounded-full bg-current" />
</div>
</TooltipTrigger>
<TooltipContent>{contentTxt}</TooltipContent>
</Tooltip>
);
};
CustomerStatusBadge.displayName = "CustomerStatusBadge";

View File

@ -1,5 +1,6 @@
export * from "./client-selector-modal";
export * from "./customer-modal-selector";
export * from "./customer-status-badge";
export * from "./customers-layout";
export * from "./editor";
export * from "./error-alert";

View File

@ -1,21 +1,30 @@
import { DataTableColumnHeader } from '@repo/rdx-ui/components';
import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import {
Avatar,
AvatarFallback,
Badge,
Button,
DropdownMenu, DropdownMenuContent,
DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
Tooltip,
TooltipContent,
TooltipTrigger
} from '@repo/shadcn-ui/components';
import { cn } from '@repo/shadcn-ui/lib/utils';
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import { Building2Icon, GlobeIcon, MailIcon, MoreHorizontalIcon, PencilIcon, PhoneIcon, User2Icon } from 'lucide-react';
import {
Building2Icon,
MailIcon,
MoreHorizontalIcon,
PencilIcon,
PhoneIcon,
User2Icon,
} from "lucide-react";
import * as React from "react";
import { useTranslation } from '../../i18n';
import { CustomerSummaryFormData } from '../../schemas';
import { CustomerStatusBadge } from "../../components";
import { useTranslation } from "../../i18n";
import type { CustomerSummaryFormData } from "../../schemas";
type CustomerActionHandlers = {
onEdit?: (customer: CustomerSummaryFormData) => void;
@ -27,33 +36,8 @@ function shortId(id: string) {
return id ? `${id.slice(0, 4)}_${id.slice(-4)}` : "-";
}
const statuses = {
inactive: 'text-gray-400 bg-gray-100 dark:text-gray-500 dark:bg-gray-100/10',
active: 'text-green-500 bg-green-500/10 dark:text-green-400 dark:bg-green-400/10',
error: 'text-rose-500 bg-rose-500/10 dark:text-rose-400 dark:bg-rose-400/10',
}
// ---- Helpers UI ----
const StatusBadge = ({ status }: { status: string }) => {
// Map visual simple; ajustar a tu catálogo real
const statusClass = React.useMemo(() => status.toLowerCase() === 'active' ? statuses.active : statuses.inactive, [status]);
const contentTxt = React.useMemo(() => status.toLowerCase() === 'active' ? 'El cliente está activo' : 'El cliente está inactivo', [status]);
return (
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('flex-none rounded-full p-1', statusClass)}>
<div className="size-2 rounded-full bg-current" />
</div>
</TooltipTrigger>
<TooltipContent>{contentTxt}
</TooltipContent>
</Tooltip>
)
};
const KindBadge = ({ isCompany }: { isCompany: boolean }) => (
<Badge variant="outline" className="gap-1 tracking-wide text-xs text-foreground/70">
<Badge className="gap-1 tracking-wide text-xs text-foreground/70" variant="outline">
{isCompany ? <Building2Icon className="size-3.5" /> : <User2Icon className="size-3.5" />}
{isCompany ? "Company" : "Person"}
</Badge>
@ -65,18 +49,20 @@ const Soft = ({ children }: { children: React.ReactNode }) => (
const ContactCell = ({ customer }: { customer: CustomerSummaryFormData }) => (
<div className="grid gap-1 text-foreground text-sm my-1.5">
{customer.email_primary && (
<div className='flex items-center gap-2'>
<MailIcon className='size-3.5' />
<a className='group' href={`mailto:${customer.email_primary}`}>
<div className="flex items-center gap-2">
<MailIcon className="size-3.5" />
<a className="group" href={`mailto:${customer.email_primary}`}>
{customer.email_primary}
</a>
</div>
)}
{customer.email_secondary && (
<div className="flex items-center gap-2"><MailIcon className="size-3.5" />{customer.email_secondary}</div>
<div className="flex items-center gap-2">
<MailIcon className="size-3.5" />
{customer.email_secondary}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
@ -84,16 +70,9 @@ const ContactCell = ({ customer }: { customer: CustomerSummaryFormData }) => (
<span>{customer.phone_primary || customer.mobile_primary || <Soft>-</Soft>}</span>
{customer.phone_secondary && <Soft> {customer.phone_secondary}</Soft>}
{customer.mobile_secondary && <Soft> {customer.mobile_secondary}</Soft>}
{false && customer.fax && <Soft> fax {customer.fax}</Soft>}
{false}
</div>
{false && customer.website && (
<div className="flex flex-wrap items-center gap-2">
<GlobeIcon className="size-3.5" />
<a className="underline underline-offset-2" href={safeHttp(customer.website)} target="_blank" rel="noreferrer">
{customer.website}
</a>
</div>
)}
{false}
</div>
);
@ -111,7 +90,7 @@ const AddressCell = ({ c }: { c: CustomerSummaryFormData }) => {
function initials(name: string) {
const parts = name.trim().split(/\s+/).slice(0, 2);
return parts.map(p => p[0]?.toUpperCase() ?? "").join("") || "?";
return parts.map((p) => p[0]?.toUpperCase() ?? "").join("") || "?";
}
function safeHttp(url: string) {
@ -120,127 +99,140 @@ function safeHttp(url: string) {
return `https://${url}`;
}
export function useCustomersListColumns(
handlers: CustomerActionHandlers = {}
): ColumnDef<CustomerSummaryFormData>[] {
const { t } = useTranslation();
const {
onEdit, onView, onDelete,
} = handlers;
const { onEdit, onView, onDelete } = handlers;
return React.useMemo<ColumnDef<CustomerSummaryFormData>[]>(() => [
// Identidad + estado + metadatos (columna compuesta)
{
id: "customer",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.customer")} className="text-left" />
),
accessorFn: (row) => row.name, // para ordenar/buscar por nombre
enableHiding: false,
size: 140,
minSize: 120,
cell: ({ row }) => {
const c = row.original;
const isCompany = String(c.is_company).toLowerCase() === "true";
return (
<div className="flex items-start gap-1 my-1.5">
<Avatar className="size-10 hidden">
<AvatarFallback aria-label={c.name}>{initials(c.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0 grid gap-1">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={c.status} /> <span className="font-medium truncate text-primary">{c.name}</span>
{c.trade_name && <Soft>({c.trade_name})</Soft>}
</div>
<div className="flex flex-wrap items-center gap-2">
{c.tin && <span className="font-base truncate">{c.tin}</span>}
<KindBadge isCompany={isCompany} />
return React.useMemo<ColumnDef<CustomerSummaryFormData>[]>(
() => [
// Identidad + estado + metadatos (columna compuesta)
{
id: "customer",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.list.grid_columns.customer")}
/>
),
accessorFn: (row) => row.name, // para ordenar/buscar por nombre
enableHiding: false,
size: 140,
minSize: 120,
cell: ({ row }) => {
const c = row.original;
const isCompany = String(c.is_company).toLowerCase() === "true";
return (
<div className="flex items-start gap-1 my-1.5">
<Avatar className="size-10 hidden">
<AvatarFallback aria-label={c.name}>{initials(c.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0 grid gap-1">
<div className="flex flex-wrap items-center gap-2">
<CustomerStatusBadge status={c.status} />{" "}
<span className="font-medium truncate text-primary">{c.name}</span>
{c.trade_name && <Soft>({c.trade_name})</Soft>}
</div>
<div className="flex flex-wrap items-center gap-2">
{c.tin && <span className="font-base truncate">{c.tin}</span>}
<KindBadge isCompany={isCompany} />
</div>
</div>
</div>
</div>
);
);
},
},
},
// Contacto (emails, teléfonos, web)
{
id: "contact",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.contact")} className="text-left" />
),
accessorFn: (r) => `${r.email_primary} ${r.phone_primary} ${r.mobile_primary} ${r.website}`,
size: 140,
minSize: 120,
cell: ({ row }) => <ContactCell customer={row.original} />,
},
// Contacto (emails, teléfonos, web)
{
id: "contact",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.list.grid_columns.contact")}
/>
),
accessorFn: (r) => `${r.email_primary} ${r.phone_primary} ${r.mobile_primary} ${r.website}`,
size: 140,
minSize: 120,
cell: ({ row }) => <ContactCell customer={row.original} />,
},
// Dirección (múltiples campos en bloque)
{
id: "address",
header: t("pages.list.grid_columns.address"),
accessorFn: (r) =>
`${r.street} ${r.street2} ${r.city} ${r.postal_code} ${r.province} ${r.country}`,
size: 140,
minSize: 120,
cell: ({ row }) => <AddressCell c={row.original} />,
},
// Dirección (múltiples campos en bloque)
{
id: "address",
header: t("pages.list.grid_columns.address"),
accessorFn: (r) =>
`${r.street} ${r.street2} ${r.city} ${r.postal_code} ${r.province} ${r.country}`,
size: 140,
minSize: 120,
cell: ({ row }) => <AddressCell c={row.original} />,
},
// Acciones
{
id: "actions",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.actions")} className="text-right" />
),
size: 64,
minSize: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => {
const customer = row.original;
const { website, email_primary } = customer;
return (
<div className="flex justify-end">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
aria-label="Edit customer"
onClick={() => onEdit?.(customer)}
>
<PencilIcon className="size-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="More actions">
<MoreHorizontalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onView?.(customer)}>Open</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit?.(customer)}>Edit</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => window.open(safeHttp(website), "_blank")}>
Visit website
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(email_primary)}>
Copy email
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete?.(customer)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
// Acciones
{
id: "actions",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right"
column={column}
title={t("pages.list.grid_columns.actions")}
/>
),
size: 64,
minSize: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => {
const customer = row.original;
const { website, email_primary } = customer;
return (
<div className="flex justify-end">
<div className="flex items-center gap-1">
<Button
aria-label="Edit customer"
onClick={() => onEdit?.(customer)}
size="icon"
variant="ghost"
>
<PencilIcon className="size-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button aria-label="More actions" size="icon" variant="ghost">
<MoreHorizontalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onView?.(customer)}>Open</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit?.(customer)}>Edit</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => window.open(safeHttp(website), "_blank")}>
Visit website
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(email_primary)}>
Copy email
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete?.(customer)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
);
);
},
},
},
], [t, onEdit, onView, onDelete]);
],
[t, onEdit, onView, onDelete]
);
}

View File

@ -17,7 +17,23 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
this.applyOrder(options, criteria, mappings, params);
this.applyPagination(options, criteria);
return options;
const normalizedOrder = Array.isArray(options.order)
? options.order
: options.order
? [options.order]
: [];
const normalizedInclude = Array.isArray(options.include)
? options.include
: options.include
? [options.include]
: [];
return {
...options,
order: normalizedOrder,
include: normalizedInclude,
};
}
/** Filtros simples (sin anidaciones complejas) */

View File

@ -1,4 +1,4 @@
import { FindOptions } from "sequelize";
import type { FindOptions } from "sequelize";
// orderItem puede ser: ['campo', 'ASC'|'DESC']
// o [Sequelize.literal('score'), 'DESC']

View File

@ -1,31 +1,3 @@
import {
AudioWaveform,
Command,
FileCheckIcon,
Frame,
GalleryVerticalEnd,
HomeIcon,
MapIcon,
PieChart,
} from "lucide-react";
import {
BarChartIcon,
CameraIcon,
ClipboardListIcon,
DatabaseIcon,
FileCodeIcon,
FileIcon,
FileTextIcon,
FolderIcon,
HelpCircleIcon,
LayoutDashboardIcon,
ListIcon,
SettingsIcon,
UsersIcon,
} from "lucide-react";
import * as React from "react";
import {
Sidebar,
SidebarContent,
@ -33,6 +5,31 @@ import {
SidebarHeader,
SidebarRail,
} from "@repo/shadcn-ui/components";
import {
AudioWaveform,
BarChartIcon,
CameraIcon,
ClipboardListIcon,
Command,
DatabaseIcon,
FileCheckIcon,
FileCodeIcon,
FileIcon,
FileTextIcon,
FolderIcon,
Frame,
GalleryVerticalEnd,
HelpCircleIcon,
HomeIcon,
LayoutDashboardIcon,
ListIcon,
MapIcon,
PieChart,
SettingsIcon,
UsersIcon,
} from "lucide-react";
import type * as React from "react";
import { NavMain } from "./nav-main.tsx";
import { NavSecondary } from "./nav-secondary.tsx";
import { NavUser } from "./nav-user.tsx";
@ -203,7 +200,7 @@ const data2 = {
items: [
{
title: "Listado de proformas",
url: "/customer-proforma",
url: "/proformas",
},
{
title: "Enviar a Veri*Factu",
@ -243,8 +240,8 @@ const data2 = {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible='icon' {...props}>
<SidebarHeader className='mb-3'>
<Sidebar collapsible="icon" {...props}>
<SidebarHeader className="mb-3">
<TeamSwitcher teams={data2.teams} />
<SearchForm />
</SidebarHeader>
@ -252,7 +249,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<NavMain items={data2.navMain} />
</SidebarContent>
<SidebarFooter>
<NavSecondary items={data.navSecondary} className='mt-auto' />
<NavSecondary className="mt-auto" items={data.navSecondary} />
<NavUser user={data.user} />
</SidebarFooter>
<SidebarRail />

View File

@ -65,7 +65,7 @@
--shadow-xl: 1px 1px 6px 0px hsl(0 0% 0% / 0.1), 1px 8px 10px -1px hsl(0 0% 0% / 0.1);
--shadow-2xl: 1px 1px 6px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0rem;
--spacing: 0.20rem;
--spacing: 0.22rem;
}
.dark {