diff --git a/modules/core/src/common/locales/en.json b/modules/core/src/common/locales/en.json index 757108a3..7facb87b 100644 --- a/modules/core/src/common/locales/en.json +++ b/modules/core/src/common/locales/en.json @@ -16,7 +16,17 @@ "search_button": "Search", "loading": "Loading", "clear_search": "Clear search", - "search_placeholder": "Search..." + "search_placeholder": "Search...", + "searching": "Searching...", + "loading_results": "Waiting for results...", + "filtered_results_prefix": "Filtered results for", + "filtered_results_hint": "Clear or change the search to see another result." + }, + "datatable": { + "loading_data": "Updating data...", + "loading_data_description": "Please wait while the table refreshes.", + "searching_data": "Searching...", + "searching_data_description": "Please wait while the results update." } }, "hooks": { diff --git a/modules/core/src/common/locales/es.json b/modules/core/src/common/locales/es.json index 35a206e7..c4ca809c 100644 --- a/modules/core/src/common/locales/es.json +++ b/modules/core/src/common/locales/es.json @@ -16,7 +16,17 @@ "search_button": "Buscar", "loading": "Buscando", "clear_search": "Limpiar búsqueda", - "search_placeholder": "Escribe aquí para buscar..." + "search_placeholder": "Escribe aquí para buscar...", + "searching": "Buscando...", + "loading_results": "Esperando resultados...", + "filtered_results_prefix": "Resultados filtrados por", + "filtered_results_hint": "Borra o cambia la búsqueda para ver otro resultado." + }, + "datatable": { + "loading_data": "Actualizando datos...", + "loading_data_description": "Espera mientras se refresca la tabla.", + "searching_data": "Buscando...", + "searching_data_description": "Espera mientras se actualizan los resultados." } }, "hooks": { diff --git a/modules/core/src/web/components/form/simple-search-input.tsx b/modules/core/src/web/components/form/simple-search-input.tsx index 74dba947..6d3d5d8f 100644 --- a/modules/core/src/web/components/form/simple-search-input.tsx +++ b/modules/core/src/web/components/form/simple-search-input.tsx @@ -5,6 +5,7 @@ import { InputGroupInput, Spinner, } from "@repo/shadcn-ui/components"; +import { cn } from "@repo/shadcn-ui/lib/utils"; import { SearchIcon, XIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -14,6 +15,7 @@ import { useTranslation } from "../../i18n"; type SimpleSearchInputProps = { value: string; onSearchChange: (value: string) => void; + onPendingChange?: (pending: boolean) => void; loading?: boolean; maxHistory?: number; @@ -28,6 +30,7 @@ const normalizeSearchValue = (value: string) => value.trim().replace(/\s+/g, " " export const SimpleSearchInput = ({ value, onSearchChange, + onPendingChange, loading = false, maxHistory = 8, placeholder, @@ -39,16 +42,30 @@ export const SimpleSearchInput = ({ const [history, setHistory] = useState([]); const [open, setOpen] = useState(false); const inputRef = useRef(null); + const onSearchChangeRef = useRef(onSearchChange); // Evita que el siguiente debounce pise una búsqueda inmediata const skipNextDebouncedEmitRef = useRef(false); const debouncedValue = useDebounce(searchValue, 300); + const normalizedSearchValue = normalizeSearchValue(searchValue); + const normalizedCommittedValue = normalizeSearchValue(value); + const isTypingPendingSearch = normalizedSearchValue !== normalizedCommittedValue; + const isSearching = loading || isTypingPendingSearch; + const hasActiveSearch = Boolean(normalizedCommittedValue); useEffect(() => { setSearchValue(value); }, [value]); + useEffect(() => { + onSearchChangeRef.current = onSearchChange; + }, [onSearchChange]); + + useEffect(() => { + onPendingChange?.(isTypingPendingSearch); + }, [isTypingPendingSearch, onPendingChange]); + useEffect(() => { const stored = localStorage.getItem(SEARCH_HISTORY_KEY); if (!stored) return; @@ -67,8 +84,8 @@ export const SimpleSearchInput = ({ return; } - onSearchChange(normalizeSearchValue(debouncedValue)); - }, [debouncedValue, onSearchChange]); + onSearchChangeRef.current(normalizeSearchValue(debouncedValue)); + }, [debouncedValue]); const saveHistory = (term: string) => { const cleaned = normalizeSearchValue(term); @@ -132,8 +149,14 @@ export const SimpleSearchInput = ({ }; return ( -
- +
+
+ - - - - - {loading && ( + {isSearching ? ( + ) : ( + )} - - {!(searchValue || loading) && ( - - )} + + {isTypingPendingSearch ? ( + + {t("components.simple_search_input.searching", "Searching...")} + + ) : null} + + - {searchValue && !loading && ( - + )} + + {searchValue && ( + + )} +
+ + {isSearching ? ( +

+ {loading + ? t("components.simple_search_input.loading_results", "Waiting for results...") + : t("components.simple_search_input.searching", "Searching...")} +

+ ) : hasActiveSearch ? ( +
- - {t("components.simple_search_input.clear_search", "Clear")} - - )} +

+ + + + + {t( + "components.simple_search_input.filtered_results_prefix", + "Resultados filtrados por" + )} + + + {normalizedCommittedValue} + + + {t( + "components.simple_search_input.filtered_results_hint", + "Borra o cambia la búsqueda para ver otro resultado." + )} + +

+
+ ) : null}
); }; diff --git a/modules/customer-invoices/src/api/application/proformas/models/index.ts b/modules/customer-invoices/src/api/application/proformas/models/index.ts index 6f2e629a..7d82ffa5 100644 --- a/modules/customer-invoices/src/api/application/proformas/models/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/models/index.ts @@ -2,6 +2,7 @@ export * from "./company-report-profile.model"; export * from "./proforma-create-input.model"; export * from "./proforma-full-read.model"; export * from "./proforma-item-tax-codes-input.model"; +export * from "./proforma-status-counts"; export * from "./proforma-summary"; export * from "./proforma-update-input.model"; export * from "./report-localization.model"; diff --git a/modules/customer-invoices/src/api/application/proformas/models/proforma-status-counts.ts b/modules/customer-invoices/src/api/application/proformas/models/proforma-status-counts.ts new file mode 100644 index 00000000..f2283d40 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/models/proforma-status-counts.ts @@ -0,0 +1,17 @@ +export type ProformaStatusCounts = { + all: number; + draft: number; + sent: number; + approved: number; + rejected: number; + issued: number; +}; + +export const EMPTY_PROFORMA_STATUS_COUNTS: ProformaStatusCounts = { + all: 0, + draft: 0, + sent: 0, + approved: 0, + rejected: 0, + issued: 0, +}; diff --git a/modules/customer-invoices/src/api/application/proformas/repositories/proforma-repository.interface.ts b/modules/customer-invoices/src/api/application/proformas/repositories/proforma-repository.interface.ts index 552be46b..f33be546 100644 --- a/modules/customer-invoices/src/api/application/proformas/repositories/proforma-repository.interface.ts +++ b/modules/customer-invoices/src/api/application/proformas/repositories/proforma-repository.interface.ts @@ -3,7 +3,7 @@ import type { UniqueID } from "@repo/rdx-ddd"; import type { Collection, Result } from "@repo/rdx-utils"; import type { InvoiceStatus, Proforma } from "../../../domain"; -import type { ProformaSummary } from "../models"; +import type { ProformaStatusCounts, ProformaSummary } from "../models"; import type { ProformaListScope } from "../use-cases"; export interface IProformaRepository { @@ -36,6 +36,13 @@ export interface IProformaRepository { transaction: unknown ): Promise, Error>>; + countByStatusInCompany( + companyId: UniqueID, + scope: ProformaListScope, + criteria: Criteria, + transaction: unknown + ): Promise>; + deleteByIdInCompany( companyId: UniqueID, id: UniqueID, diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-finder.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-finder.ts index b44d1a14..4e20be57 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-finder.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-finder.ts @@ -3,7 +3,7 @@ import type { UniqueID } from "@repo/rdx-ddd"; import type { Collection, Result } from "@repo/rdx-utils"; import type { Proforma } from "../../../domain"; -import type { ProformaSummary } from "../models"; +import type { ProformaStatusCounts, ProformaSummary } from "../models"; import type { IProformaRepository } from "../repositories"; import type { ProformaListScope } from "../use-cases"; @@ -32,6 +32,13 @@ export interface IProformaFinder { criteria: Criteria, transaction?: unknown ): Promise, Error>>; + + countProformasByStatus( + companyId: UniqueID, + scope: ProformaListScope, + criteria: Criteria, + transaction?: unknown + ): Promise>; } export class ProformaFinder implements IProformaFinder { @@ -69,4 +76,13 @@ export class ProformaFinder implements IProformaFinder { ): Promise, Error>> { return this.repository.findByCriteriaInCompany(companyId, scope, criteria, transaction); } + + async countProformasByStatus( + companyId: UniqueID, + scope: ProformaListScope, + criteria: Criteria, + transaction?: unknown + ): Promise> { + return this.repository.countByStatusInCompany(companyId, scope, criteria, transaction); + } } diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/list-proformas.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/list-proformas.use-case.ts index a278b67e..74727caa 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/list-proformas.use-case.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/list-proformas.use-case.ts @@ -1,5 +1,6 @@ import type { ITransactionManager } from "@erp/core/api"; import type { Criteria } from "@repo/rdx-criteria/server"; +import { Criteria as CriteriaModule } from "@repo/rdx-criteria/server"; import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; @@ -26,17 +27,28 @@ export class ListProformasUseCase { return this.transactionManager.complete(async (transaction: unknown) => { try { + const statusSummaryCriteria = this.buildStatusSummaryCriteria(criteria); const result = await this.finder.findProformasByCriteria( companyId, scope, criteria, transaction ); + const statusCountsResult = await this.finder.countProformasByStatus( + companyId, + scope, + statusSummaryCriteria, + transaction + ); if (result.isFailure) { return Result.fail(result.error); } + if (statusCountsResult.isFailure) { + return Result.fail(statusCountsResult.error); + } + const proformas = result.data; const totalProformas = proformas.total(); @@ -51,6 +63,7 @@ export class ListProformasUseCase { metadata: { entity: "proformas", criteria: criteria.toJSON(), + status_counts: statusCountsResult.data, }, }; @@ -60,4 +73,17 @@ export class ListProformasUseCase { } }); } + + private buildStatusSummaryCriteria(criteria: Criteria): Criteria { + const { q, filters, orderBy, orderType, pageSize, pageNumber } = criteria.toPrimitives(); + + return CriteriaModule.fromPrimitives( + filters.filter((filter) => filter.field !== "status"), + orderBy, + orderType, + pageSize, + pageNumber, + q + ); + } } diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts index a1ec16b1..7b6dfc4b 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts @@ -18,6 +18,8 @@ import { type FindOptions, type Includeable, type InferAttributes, + col, + fn, Op, type OrderItem, type Sequelize, @@ -25,7 +27,11 @@ import { type WhereOptions, } from "sequelize"; -import type { IProformaRepository, ProformaSummary } from "../../../../../application"; +import type { + IProformaRepository, + ProformaStatusCounts, + ProformaSummary, +} from "../../../../../application"; import type { ProformaListScope } from "../../../../../application"; import { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../../../../../domain"; import { @@ -524,6 +530,122 @@ export class SequelizeProformaRepositoryV2 } } + public async countByStatusInCompany( + companyId: UniqueID, + scope: ProformaListScope, + criteria: Criteria, + transaction: Transaction, + options: FindOptions> = {} + ): Promise> { + try { + this.assertSupportedCriteriaFilters(criteria); + + const criteriaConverter = new CriteriaToSequelizeConverter(); + const criteriaQuery = criteriaConverter.convert(criteria, { + searchableFields: ["proforma_reference", "reference", "description"], + mappings: { + archived_at: { + type: "root", + column: "archived_at", + }, + proforma_date: { + type: "root", + column: "proforma_date", + }, + proforma_reference: { + type: "root", + column: "proforma_reference", + }, + reference: { + type: "root", + column: "reference", + }, + description: { + type: "root", + column: "description", + }, + status: { + type: "root", + column: "status", + }, + recipient_name: { + type: "association", + association: "current_customer", + column: "name", + }, + }, + sortableFields: [ + "recipient_name", + "proforma_reference", + "proforma_date", + "id", + "created_at", + "serie", + ], + fullTextTableAlias: "ProformaModel", + enableFullText: true, + database: this.database, + strictMode: true, + }); + + const where: WhereOptions> = { + [Op.and]: [ + { company_id: companyId.toString() }, + this.buildScopeWhere(scope), + ...(options.where ? [options.where] : []), + ...(criteriaQuery.where ? [criteriaQuery.where] : []), + ], + }; + + const rows = (await ProformaModel.findAll({ + attributes: ["status", [fn("COUNT", col("ProformaModel.id")), "count"]], + group: ["status"], + paranoid: scope !== "deleted", + raw: true, + transaction, + where, + })) as unknown as Array<{ status: string; count: string | number }>; + + const counts: ProformaStatusCounts = { + all: 0, + draft: 0, + sent: 0, + approved: 0, + rejected: 0, + issued: 0, + }; + + for (const row of rows) { + const value = Number(row.count); + + switch (row.status) { + case INVOICE_STATUS.DRAFT: + counts.draft = value; + break; + case INVOICE_STATUS.SENT: + counts.sent = value; + break; + case INVOICE_STATUS.APPROVED: + counts.approved = value; + break; + case INVOICE_STATUS.REJECTED: + counts.rejected = value; + break; + case INVOICE_STATUS.ISSUED: + counts.issued = value; + break; + } + } + + counts.all = + counts.draft + counts.sent + counts.approved + counts.rejected + counts.issued; + + return Result.ok(counts); + } catch (err: unknown) { + return Result.fail(translateSequelizeError(err)); + } + } + private assertSupportedCriteriaFilters(criteria: Criteria) { const { filters } = criteria.toPrimitives(); @@ -552,18 +674,18 @@ export class SequelizeProformaRepositoryV2 archived_at: { [Op.ne]: null, }, - }; + } as WhereOptions>; case "deleted": return { deleted_at: { [Op.ne]: null, }, - }; + } as WhereOptions>; case "normal": default: return { archived_at: null, - }; + } as WhereOptions>; } } } diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma.repository.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma.repository.ts index d7d3562f..ce981b2a 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma.repository.ts @@ -18,6 +18,8 @@ import { type FindOptions, type Includeable, type InferAttributes, + col, + fn, Op, type OrderItem, type Sequelize, @@ -25,7 +27,11 @@ import { type WhereOptions, } from "sequelize"; -import type { IProformaRepository, ProformaSummary } from "../../../../../application"; +import type { + IProformaRepository, + ProformaStatusCounts, + ProformaSummary, +} from "../../../../../application"; import { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../../../../../domain"; import { CustomerInvoiceItemModel, @@ -399,6 +405,33 @@ export class ProformaRepository } } + async getByIdIncludingArchivedInCompany( + companyId: UniqueID, + id: UniqueID, + transaction: Transaction, + options: FindOptions> = {} + ): Promise> { + return this.getByIdInCompany(companyId, id, transaction, options); + } + + async archiveByIdInCompany( + _companyId: UniqueID, + _id: UniqueID, + _transaction: Transaction + ): Promise> { + return Result.fail(new Error("Archive is not supported by the legacy proforma repository.")); + } + + async unarchiveByIdInCompany( + _companyId: UniqueID, + _id: UniqueID, + _transaction: Transaction + ): Promise> { + return Result.fail( + new Error("Unarchive is not supported by the legacy proforma repository.") + ); + } + /** * * Consulta facturas usando un objeto Criteria (filtros, orden, paginación). @@ -615,4 +648,115 @@ export class ProformaRepository return Result.fail(translateSequelizeError(err)); } } + + public async countByStatusInCompany( + companyId: UniqueID, + _scope: ProformaListScope, + criteria: Criteria, + transaction: Transaction, + options: FindOptions> = {} + ): Promise> { + try { + const criteriaConverter = new CriteriaToSequelizeConverter(); + const criteriaQuery = criteriaConverter.convert(criteria, { + searchableFields: ["invoice_number", "reference", "description"], + mappings: { + invoice_date: { + type: "root", + column: "invoice_date", + }, + invoice_number: { + type: "root", + column: "invoice_number", + }, + reference: { + type: "root", + column: "reference", + }, + description: { + type: "root", + column: "description", + }, + status: { + type: "root", + column: "status", + }, + recipient_name: { + type: "association", + association: "current_customer", + column: "name", + }, + }, + sortableFields: [ + "recipient_name", + "invoice_number", + "invoice_date", + "id", + "created_at", + "serie", + ], + fullTextTableAlias: "CustomerInvoiceModel", + enableFullText: true, + database: this.database, + strictMode: true, + }); + + const where: WhereOptions> = { + [Op.and]: [ + { + is_proforma: true, + company_id: companyId.toString(), + }, + ...(options.where ? [options.where] : []), + ...(criteriaQuery.where ? [criteriaQuery.where] : []), + ], + }; + + const rows = (await CustomerInvoiceModel.findAll({ + attributes: ["status", [fn("COUNT", col("CustomerInvoiceModel.id")), "count"]], + group: ["status"], + raw: true, + transaction, + where, + })) as unknown as Array<{ status: string; count: string | number }>; + + const counts: ProformaStatusCounts = { + all: 0, + draft: 0, + sent: 0, + approved: 0, + rejected: 0, + issued: 0, + }; + + for (const row of rows) { + const value = Number(row.count); + + switch (row.status) { + case INVOICE_STATUS.DRAFT: + counts.draft = value; + break; + case INVOICE_STATUS.SENT: + counts.sent = value; + break; + case INVOICE_STATUS.APPROVED: + counts.approved = value; + break; + case INVOICE_STATUS.REJECTED: + counts.rejected = value; + break; + case INVOICE_STATUS.ISSUED: + counts.issued = value; + break; + } + } + + counts.all = + counts.draft + counts.sent + counts.approved + counts.rejected + counts.issued; + + return Result.ok(counts); + } catch (err: unknown) { + return Result.fail(translateSequelizeError(err)); + } + } } diff --git a/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/issued-invoices-grid.tsx b/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/issued-invoices-grid.tsx index d2e9c1b4..bfe57bf5 100644 --- a/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/issued-invoices-grid.tsx +++ b/modules/customer-invoices/src/web/issued-invoices/list/ui/blocks/issued-invoices-grid/issued-invoices-grid.tsx @@ -1,5 +1,6 @@ import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components"; import type { ColumnDef } from "@tanstack/react-table"; +import type * as React from "react"; import { useTranslation } from "../../../../../i18n"; import type { IssuedInvoiceList, IssuedInvoiceListRow } from "../../../../shared"; @@ -8,8 +9,10 @@ interface IssuedInvoicesGridProps { data?: IssuedInvoiceList; loading: boolean; fetching?: boolean; + searching?: boolean; columns: ColumnDef[]; + topContent?: React.ReactNode; pageIndex: number; pageSize: number; @@ -23,7 +26,9 @@ export const IssuedInvoicesGrid = ({ data, loading, fetching, + searching, columns, + topContent, pageIndex, pageSize, onPageChange, @@ -101,12 +106,15 @@ export const IssuedInvoicesGrid = ({ data={items} enablePagination enableRowSelection + isFetching={fetching} + isSearching={searching} manualPagination onPageChange={onPageChange} onPageSizeChange={onPageSizeChange} //onRowClick={(row) => onRowClick?.(row.id)} pageIndex={pageIndex} pageSize={pageSize} + topContent={topContent} totalItems={totalItems} /> ); diff --git a/modules/customer-invoices/src/web/issued-invoices/list/ui/pages/list-issued-invoices-page.tsx b/modules/customer-invoices/src/web/issued-invoices/list/ui/pages/list-issued-invoices-page.tsx index 22a8a26c..7383df13 100644 --- a/modules/customer-invoices/src/web/issued-invoices/list/ui/pages/list-issued-invoices-page.tsx +++ b/modules/customer-invoices/src/web/issued-invoices/list/ui/pages/list-issued-invoices-page.tsx @@ -12,6 +12,7 @@ import { SelectValue, } from "@repo/shadcn-ui/components"; import { FilterIcon, PlusIcon } from "lucide-react"; +import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "../../../../i18n"; @@ -22,6 +23,7 @@ import { IssuedInvoicesEmptyGrid } from "../components"; export const ListIssuedInvoicesPage = () => { const { t } = useTranslation(); const navigate = useNavigate(); + const [isSearchPending, setIsSearchPending] = useState(false); const { listCtrl, @@ -99,56 +101,11 @@ export const ListIssuedInvoicesPage = () => { {listCtrl.data.totalItems !== 0 && ( <> -
- - - -
{ //onRowClick={(id) => navigate(`/issuedInvoices/${id}`)} pageIndex={listCtrl.pageIndex} pageSize={listCtrl.pageSize} + searching={isSearchPending} + topContent={ +
+ + + +
+ } />
diff --git a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts index 7a5665b8..ddf67515 100644 --- a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts +++ b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts @@ -13,6 +13,7 @@ import { type ListProformasByCriteriaParams, type ProformaList, type ProformaStatus, + EMPTY_PROFORMA_STATUS_COUNTS, useProformasListQuery, } from "../../shared"; import type { ProformaListScope, ProformaListStatusFilter } from "../types/proforma-list-filters"; @@ -25,6 +26,7 @@ const EMPTY_PROFORMAS_LIST: ProformaList = { perPage: INITIAL_PAGE_SIZE, totalPages: 0, totalItems: 0, + statusCounts: EMPTY_PROFORMA_STATUS_COUNTS, }; // Campos que se permiten ordenar para la lista de proformas (consulta). diff --git a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/proformas-grid.tsx b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/proformas-grid.tsx index d130eadb..452c61c7 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/proformas-grid.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/proformas-grid.tsx @@ -1,5 +1,6 @@ import { DataTable, type DataTableSort, SkeletonDataTable } from "@repo/rdx-ui/components"; import type { ColumnDef, OnChangeFn, VisibilityState } from "@tanstack/react-table"; +import type * as React from "react"; import { useTranslation } from "../../../../../i18n"; import type { ProformaList, ProformaListRow } from "../../../../shared"; @@ -8,8 +9,10 @@ interface ProformasGridProps { data?: ProformaList; loading: boolean; fetching?: boolean; + searching?: boolean; columns: ColumnDef[]; + topContent?: React.ReactNode; pageIndex: number; pageSize: number; @@ -28,9 +31,11 @@ interface ProformasGridProps { export const ProformasGrid = ({ data, columns, + topContent, loading, fetching, + searching, pageIndex, pageSize, @@ -66,6 +71,8 @@ export const ProformasGrid = ({ data={items} enablePagination enableRowSelection + isFetching={fetching} + isSearching={searching} manualPagination manualSorting onColumnVisibilityChange={onColumnVisibilityChange} @@ -76,6 +83,7 @@ export const ProformasGrid = ({ pageIndex={pageIndex} pageSize={pageSize} sort={sort} + topContent={topContent} totalItems={totalItems} /> ); diff --git a/modules/customer-invoices/src/web/proformas/list/ui/components/proforma-status-segmented-filter.tsx b/modules/customer-invoices/src/web/proformas/list/ui/components/proforma-status-segmented-filter.tsx index e14e6e4d..98f406c2 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/components/proforma-status-segmented-filter.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/components/proforma-status-segmented-filter.tsx @@ -1,7 +1,16 @@ -import { ToggleGroup, ToggleGroupItem } from "@repo/shadcn-ui/components"; +import { Badge, ToggleGroup, ToggleGroupItem } from "@repo/shadcn-ui/components"; import { cn } from "@repo/shadcn-ui/lib/utils"; +import { + CheckCircle2Icon, + FileTextIcon, + ReceiptTextIcon, + SendIcon, + XCircleIcon, +} from "lucide-react"; +import type { ElementType } from "react"; import { useTranslation } from "../../../../i18n"; +import type { ProformaStatusCounts } from "../../../shared"; import type { ProformaListStatusFilter } from "../../types/proforma-list-filters"; export type ProformaStatusFilter = ProformaListStatusFilter; @@ -9,19 +18,65 @@ export type ProformaStatusFilter = ProformaListStatusFilter; export type ProformaStatusSegmentedFilterProps = { value: ProformaStatusFilter; onValueChange: (value: ProformaStatusFilter) => void; + counts: ProformaStatusCounts; disabled?: boolean; }; const PROFORMA_STATUS_FILTER_OPTIONS = [ - { value: "all", labelKey: "catalog.proformas.status.all.label" }, - { value: "draft", labelKey: "catalog.proformas.status.draft.label" }, - { value: "sent", labelKey: "catalog.proformas.status.sent.label" }, - { value: "approved", labelKey: "catalog.proformas.status.approved.label" }, - { value: "rejected", labelKey: "catalog.proformas.status.rejected.label" }, - { value: "issued", labelKey: "catalog.proformas.status.issued.label" }, + { + value: "all", + labelKey: "catalog.proformas.status.all.label", + icon: null, + countKey: "all", + activeClassName: + "data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground", + }, + { + value: "draft", + labelKey: "catalog.proformas.status.draft.label", + icon: FileTextIcon, + countKey: "draft", + activeClassName: + "data-[state=on]:border-slate-300 data-[state=on]:bg-slate-100 data-[state=on]:text-slate-700", + }, + { + value: "sent", + labelKey: "catalog.proformas.status.sent.label", + icon: SendIcon, + countKey: "sent", + activeClassName: + "data-[state=on]:border-amber-300 data-[state=on]:bg-amber-50 data-[state=on]:text-amber-700", + }, + { + value: "approved", + labelKey: "catalog.proformas.status.approved.label", + icon: CheckCircle2Icon, + countKey: "approved", + activeClassName: + "data-[state=on]:border-emerald-300 data-[state=on]:bg-emerald-50 data-[state=on]:text-emerald-700", + }, + { + value: "rejected", + labelKey: "catalog.proformas.status.rejected.label", + icon: XCircleIcon, + countKey: "rejected", + activeClassName: + "data-[state=on]:border-red-300 data-[state=on]:bg-red-50 data-[state=on]:text-red-700", + }, + { + value: "issued", + labelKey: "catalog.proformas.status.issued.label", + icon: ReceiptTextIcon, + countKey: "issued", + activeClassName: + "data-[state=on]:border-sky-300 data-[state=on]:bg-sky-50 data-[state=on]:text-sky-700", + }, ] as const satisfies readonly { value: ProformaStatusFilter; labelKey: string; + icon: ElementType | null; + countKey: keyof ProformaStatusCounts; + activeClassName: string; }[]; const isProformaStatusFilter = (value: string): value is ProformaStatusFilter => { @@ -31,6 +86,7 @@ const isProformaStatusFilter = (value: string): value is ProformaStatusFilter => export const ProformaStatusSegmentedFilter = ({ value, onValueChange, + counts, disabled = false, }: ProformaStatusSegmentedFilterProps) => { const { t } = useTranslation(); @@ -46,11 +102,11 @@ export const ProformaStatusSegmentedFilter = ({ return ( ( { @@ -69,7 +127,20 @@ export const ProformaStatusSegmentedFilter = ({ }} value={option.value} > - {t(option.labelKey)} + + {option.icon ? : null} + {t(option.labelKey)} + + {counts[option.countKey]} + + ))} diff --git a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx index f72c60f4..0f85dd25 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx @@ -18,6 +18,7 @@ import { PlusIcon, } from "lucide-react"; import { createSearchParams, useNavigate } from "react-router-dom"; +import { useState } from "react"; import { useTranslation } from "../../../../i18n"; import { ChangeProformaStatusDialog } from "../../../change-status"; @@ -82,6 +83,7 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => { const { t } = useTranslation(); const navigate = useNavigate(); const scopeConfig = SCOPE_CONFIG[scope]; + const [isSearchPending, setIsSearchPending] = useState(false); const { currentReturnTo } = useReturnToNavigation({ fallbackPath: scopeConfig.currentPath, @@ -157,19 +159,6 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => { const listContent = (
-
- - - -
{ onSortChange={listCtrl.setSort} pageIndex={listCtrl.pageIndex} pageSize={listCtrl.pageSize} + searching={isSearchPending} sort={listCtrl.sort} + topContent={ +
+
+
+ +
+
+ + +
+ } /> {listCtrl.data.totalItems === 0 ? (
diff --git a/modules/customer-invoices/src/web/proformas/shared/adapters/list-proformas.adapter.ts b/modules/customer-invoices/src/web/proformas/shared/adapters/list-proformas.adapter.ts index 4ba3a04f..6ef470f6 100644 --- a/modules/customer-invoices/src/web/proformas/shared/adapters/list-proformas.adapter.ts +++ b/modules/customer-invoices/src/web/proformas/shared/adapters/list-proformas.adapter.ts @@ -3,7 +3,13 @@ import { MoneyHelper } from "@repo/rdx-utils"; import type { ListProformasResponseDTO } from "../../../../common"; import type { ListProformasResult } from "../api"; -import type { ProformaList, ProformaListRow, ProformaStatus } from "../entities"; +import { + EMPTY_PROFORMA_STATUS_COUNTS, + type ProformaList, + type ProformaListRow, + type ProformaStatus, + type ProformaStatusCounts, +} from "../entities"; /** * Adaptador para transformar los datos de la API de ListProformasResponseDTO @@ -23,11 +29,37 @@ export const ListProformasAdapter = { perPage: dto.per_page, totalPages: dto.total_pages, totalItems: dto.total_items, + statusCounts: ProformaStatusCountsAdapter.fromDto(dto.metadata?.status_counts), items: dto.items.map((rowDto) => ProformaListRowAdapter.fromDto(rowDto)), }; }, }; +type ProformaStatusCountsDto = Partial> | undefined; + +const ProformaStatusCountsAdapter = { + fromDto(dto: ProformaStatusCountsDto): ProformaStatusCounts { + return { + all: toSafeCount(dto?.all), + draft: toSafeCount(dto?.draft), + sent: toSafeCount(dto?.sent), + approved: toSafeCount(dto?.approved), + rejected: toSafeCount(dto?.rejected), + issued: toSafeCount(dto?.issued), + }; + }, +}; + +function toSafeCount(value: unknown): number { + const parsed = typeof value === "number" ? value : Number(value); + + if (!Number.isFinite(parsed) || parsed < 0) { + return 0; + } + + return Math.trunc(parsed); +} + /** * Adaptador para transformar los items de la API de ListProformasResult a la entidad ProformaListRow. * Reglas de adaptación: diff --git a/modules/customer-invoices/src/web/proformas/shared/entities/index.ts b/modules/customer-invoices/src/web/proformas/shared/entities/index.ts index cc7e58f7..dd9a9d03 100644 --- a/modules/customer-invoices/src/web/proformas/shared/entities/index.ts +++ b/modules/customer-invoices/src/web/proformas/shared/entities/index.ts @@ -6,4 +6,5 @@ export * from "./proforma-list.entity"; export * from "./proforma-list-row.entity"; export * from "./proforma-recipient.entity"; export * from "./proforma-status.entity"; +export * from "./proforma-status-counts.entity"; export * from "./proforma-tax-summary.entity"; diff --git a/modules/customer-invoices/src/web/proformas/shared/entities/proforma-list.entity.ts b/modules/customer-invoices/src/web/proformas/shared/entities/proforma-list.entity.ts index ecaf64ef..6b3f7620 100644 --- a/modules/customer-invoices/src/web/proformas/shared/entities/proforma-list.entity.ts +++ b/modules/customer-invoices/src/web/proformas/shared/entities/proforma-list.entity.ts @@ -1,4 +1,5 @@ import type { ProformaListRow } from "./proforma-list-row.entity"; +import type { ProformaStatusCounts } from "./proforma-status-counts.entity"; /** * Interface que representa la respuesta paginada de una lista de proformas, @@ -11,4 +12,5 @@ export interface ProformaList { totalItems: number; page: number; perPage: number; + statusCounts: ProformaStatusCounts; } diff --git a/modules/customer-invoices/src/web/proformas/shared/entities/proforma-status-counts.entity.ts b/modules/customer-invoices/src/web/proformas/shared/entities/proforma-status-counts.entity.ts new file mode 100644 index 00000000..82a94203 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/entities/proforma-status-counts.entity.ts @@ -0,0 +1,17 @@ +export interface ProformaStatusCounts { + all: number; + draft: number; + sent: number; + approved: number; + rejected: number; + issued: number; +} + +export const EMPTY_PROFORMA_STATUS_COUNTS: ProformaStatusCounts = { + all: 0, + draft: 0, + sent: 0, + approved: 0, + rejected: 0, + issued: 0, +}; diff --git a/modules/customers/src/web/list/ui/blocks/customers-grid/customers-grid.tsx b/modules/customers/src/web/list/ui/blocks/customers-grid/customers-grid.tsx index 8a23e9b1..5148d246 100644 --- a/modules/customers/src/web/list/ui/blocks/customers-grid/customers-grid.tsx +++ b/modules/customers/src/web/list/ui/blocks/customers-grid/customers-grid.tsx @@ -1,5 +1,6 @@ import { DataTable, type DataTableSort, SkeletonDataTable } from "@repo/rdx-ui/components"; import type { ColumnDef, OnChangeFn, VisibilityState } from "@tanstack/react-table"; +import type * as React from "react"; import { useTranslation } from "../../../../i18n"; import type { CustomerList, CustomerListRow } from "../../../../shared"; @@ -8,8 +9,10 @@ interface CustomersGridProps { data?: CustomerList; loading: boolean; fetching?: boolean; + searching?: boolean; columns: ColumnDef[]; + topContent?: React.ReactNode; pageIndex: number; pageSize: number; @@ -28,9 +31,11 @@ interface CustomersGridProps { export const CustomersGrid = ({ data, columns, + topContent, loading, fetching, + searching, pageIndex, pageSize, @@ -66,6 +71,8 @@ export const CustomersGrid = ({ data={items} enablePagination enableRowSelection + isFetching={fetching} + isSearching={searching} manualPagination manualSorting onColumnVisibilityChange={onColumnVisibilityChange} @@ -76,6 +83,7 @@ export const CustomersGrid = ({ pageIndex={pageIndex} pageSize={pageSize} sort={sort} + topContent={topContent} totalItems={totalItems} /> ); diff --git a/modules/customers/src/web/list/ui/pages/list-customers-page.tsx b/modules/customers/src/web/list/ui/pages/list-customers-page.tsx index fd0aac29..a5fb2886 100644 --- a/modules/customers/src/web/list/ui/pages/list-customers-page.tsx +++ b/modules/customers/src/web/list/ui/pages/list-customers-page.tsx @@ -8,6 +8,7 @@ import { ResizablePanelGroup, } from "@repo/shadcn-ui/components"; import { PlusIcon } from "lucide-react"; +import { useState } from "react"; import { createSearchParams, useNavigate } from "react-router-dom"; import { useTranslation } from "../../../i18n"; @@ -22,6 +23,7 @@ export const ListCustomersPage = () => { const { currentReturnTo } = useReturnToNavigation({ fallbackPath: "/customers", }); + const [isSearchPending, setIsSearchPending] = useState(false); const { listCtrl, panelCtrl } = useListCustomersPageController(); @@ -54,14 +56,6 @@ export const ListCustomersPage = () => { const listContent = (
-
- -
-
{ onSortChange={listCtrl.setSort} pageIndex={listCtrl.pageIndex} pageSize={listCtrl.pageSize} + searching={isSearchPending} sort={listCtrl.sort} + topContent={ +
+ +
+ } />
diff --git a/packages/rdx-ui/src/components/datatable/data-table-toolbar.tsx b/packages/rdx-ui/src/components/datatable/data-table-toolbar.tsx index 3ddb79e8..b732ab5d 100644 --- a/packages/rdx-ui/src/components/datatable/data-table-toolbar.tsx +++ b/packages/rdx-ui/src/components/datatable/data-table-toolbar.tsx @@ -75,7 +75,10 @@ export function DataTableToolbar({ // Render principal return (
{/* IZQUIERDA: acciones + contador */}
diff --git a/packages/rdx-ui/src/components/datatable/data-table.tsx b/packages/rdx-ui/src/components/datatable/data-table.tsx index 3b496b66..e59030a7 100644 --- a/packages/rdx-ui/src/components/datatable/data-table.tsx +++ b/packages/rdx-ui/src/components/datatable/data-table.tsx @@ -1,4 +1,5 @@ import { + Spinner, TableBody, TableCell, Table as TableComp, @@ -76,6 +77,9 @@ export interface DataTableProps { columns: ColumnDef[]; data: TData[]; meta?: DataTableMeta; + topContent?: React.ReactNode; + isFetching?: boolean; + isSearching?: boolean; // Configuración columnVisibility?: VisibilityState; @@ -112,6 +116,9 @@ export function DataTable({ columns, data, meta, + topContent, + isFetching = false, + isSearching = false, columnVisibility, onColumnVisibilityChange, @@ -136,6 +143,7 @@ export function DataTable({ onRowClick, }: DataTableProps) { const { t } = useTranslation(); + const isBusy = isFetching || isSearching; const [rowSelection, setRowSelection] = React.useState({}); const [internalSorting, setInternalSorting] = React.useState(); @@ -258,17 +266,56 @@ export function DataTable({ // Render principal return (
-
+
+ {topContent ? ( +
{topContent}
+ ) : null} + -
+
+ {isBusy ? ( + <> +
+
+
+
+
+
+
+ +
+
+ + {isSearching + ? t("components.datatable.searching_data", "Searching...") + : t("components.datatable.loading_data", "Updating data...")} + + + {isSearching + ? t( + "components.datatable.searching_data_description", + "Please wait while the search is prepared." + ) + : t( + "components.datatable.loading_data_description", + "Please wait while the table refreshes." + )} + +
+
+
+ + ) : null} + +
{/* CABECERA */} {table.getHeaderGroups().map((hg) => ( {hg.headers.map((h) => { /* @@ -289,9 +336,12 @@ export function DataTable({ return ( {h.isPlaceholder ? null @@ -308,7 +358,7 @@ export function DataTable({ {table.getRowModel().rows.length ? ( table.getRowModel().rows.map((row, rowIndex) => ( onRowClick?.(row.original, rowIndex, e)} @@ -339,7 +389,13 @@ export function DataTable({ const cellClassName = cell.column.columnDef.meta?.cellClassName; return ( - + {flexRender(cell.column.columnDef.cell, cell.getContext())} ); @@ -360,15 +416,16 @@ export function DataTable({ {/* Paginación */} {enablePagination && ( - - - - - - + + + + + + )} +