feat: add proforma status counting functionality

- Implemented `countByStatusInCompany` method in `SequelizeProformaRepositoryV2` and `ProformaRepository` to count proformas by status.
- Introduced `ProformaStatusCounts` type and `EMPTY_PROFORMA_STATUS_COUNTS` constant for managing status counts.
- Updated the `ProformasGrid` and `IssuedInvoicesGrid` components to support searching and fetching states.
- Enhanced the UI to display status counts in the segmented filter for proformas.
- Refactored the proforma list and issued invoices page to include search input and status filters with loading states.
- Added new entities and adapters for handling proforma status counts in the application layer.
This commit is contained in:
David Arranz 2026-07-30 14:26:04 +02:00
parent c46769e71e
commit 2010eab897
24 changed files with 788 additions and 131 deletions

View File

@ -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": {

View File

@ -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": {

View File

@ -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<string[]>([]);
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(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 (
<div className="relative flex flex-1 items-center gap-4">
<InputGroup className="bg-background">
<div className="relative flex flex-1 flex-col items-start gap-2">
<div className="flex w-full items-center gap-4">
<InputGroup
className={cn(
"bg-background transition-[box-shadow,border-color] duration-200",
isSearching && "ring-2 ring-primary/15"
)}
>
<InputGroupInput
autoComplete="off"
autoFocus={autoFocus}
@ -150,37 +173,85 @@ export const SimpleSearchInput = ({
/>
<InputGroupAddon>
<SearchIcon aria-hidden className="text-muted-foreground" />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
{loading && (
{isSearching ? (
<Spinner
aria-label={t("components.simple_search_input.loading", "Loading")}
className="text-muted-foreground"
/>
) : (
<SearchIcon aria-hidden className="text-muted-foreground" />
)}
</InputGroupAddon>
</InputGroup>
{!(searchValue || loading) && (
<Button onClick={() => handleImmediateSearch(searchValue)} type="button" variant="outline">
{t("components.simple_search_input.search_button", "Search")}
</Button>
)}
<InputGroupAddon align="inline-end">
{isTypingPendingSearch ? (
<span
aria-live="polite"
className="hidden text-xs font-medium text-muted-foreground sm:inline"
>
{t("components.simple_search_input.searching", "Searching...")}
</span>
) : null}
</InputGroupAddon>
</InputGroup>
{searchValue && !loading && (
<Button
aria-label={t("components.simple_search_input.clear_search", "Clear search")}
className="cursor-pointer"
onClick={handleClear}
type="button"
variant="outline"
{!(searchValue || isSearching) && (
<Button
onClick={() => handleImmediateSearch(searchValue)}
type="button"
variant="outline"
>
{t("components.simple_search_input.search_button", "Search")}
</Button>
)}
{searchValue && (
<Button
aria-label={t("components.simple_search_input.clear_search", "Clear search")}
className="cursor-pointer"
onClick={handleClear}
type="button"
variant="outline"
>
<XIcon aria-hidden className="size-4" />
<span>{t("components.simple_search_input.clear_search", "Clear")}</span>
</Button>
)}
</div>
{isSearching ? (
<p aria-live="polite" className="text-xs text-muted-foreground">
{loading
? t("components.simple_search_input.loading_results", "Waiting for results...")
: t("components.simple_search_input.searching", "Searching...")}
</p>
) : hasActiveSearch ? (
<div
aria-live="polite"
className="flex w-full"
>
<XIcon aria-hidden className="size-4" />
<span>{t("components.simple_search_input.clear_search", "Clear")}</span>
</Button>
)}
<p className="flex w-full items-center gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-background text-primary ring-1 ring-border">
<SearchIcon aria-hidden className="size-3.5" />
</span>
<span className="shrink-0">
{t(
"components.simple_search_input.filtered_results_prefix",
"Resultados filtrados por"
)}
</span>
<span className="max-w-[55%] truncate rounded-md bg-background px-2 py-0.5 font-medium text-foreground ring-1 ring-border">
{normalizedCommittedValue}
</span>
<span className="ml-auto hidden text-xs sm:inline">
{t(
"components.simple_search_input.filtered_results_hint",
"Borra o cambia la búsqueda para ver otro resultado."
)}
</span>
</p>
</div>
) : null}
</div>
);
};

View File

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

View File

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

View File

@ -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<Result<Collection<ProformaSummary>, Error>>;
countByStatusInCompany(
companyId: UniqueID,
scope: ProformaListScope,
criteria: Criteria,
transaction: unknown
): Promise<Result<ProformaStatusCounts, Error>>;
deleteByIdInCompany(
companyId: UniqueID,
id: UniqueID,

View File

@ -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<Result<Collection<ProformaSummary>, Error>>;
countProformasByStatus(
companyId: UniqueID,
scope: ProformaListScope,
criteria: Criteria,
transaction?: unknown
): Promise<Result<ProformaStatusCounts, Error>>;
}
export class ProformaFinder implements IProformaFinder {
@ -69,4 +76,13 @@ export class ProformaFinder implements IProformaFinder {
): Promise<Result<Collection<ProformaSummary>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, scope, criteria, transaction);
}
async countProformasByStatus(
companyId: UniqueID,
scope: ProformaListScope,
criteria: Criteria,
transaction?: unknown
): Promise<Result<ProformaStatusCounts, Error>> {
return this.repository.countByStatusInCompany(companyId, scope, criteria, transaction);
}
}

View File

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

View File

@ -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<InferAttributes<ProformaModel>> = {}
): Promise<Result<ProformaStatusCounts, Error>> {
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<InferAttributes<ProformaModel>> = {
[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<InferAttributes<ProformaModel>>;
case "deleted":
return {
deleted_at: {
[Op.ne]: null,
},
};
} as WhereOptions<InferAttributes<ProformaModel>>;
case "normal":
default:
return {
archived_at: null,
};
} as WhereOptions<InferAttributes<ProformaModel>>;
}
}
}

View File

@ -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<InferAttributes<CustomerInvoiceModel>> = {}
): Promise<Result<Proforma, Error>> {
return this.getByIdInCompany(companyId, id, transaction, options);
}
async archiveByIdInCompany(
_companyId: UniqueID,
_id: UniqueID,
_transaction: Transaction
): Promise<Result<boolean, Error>> {
return Result.fail(new Error("Archive is not supported by the legacy proforma repository."));
}
async unarchiveByIdInCompany(
_companyId: UniqueID,
_id: UniqueID,
_transaction: Transaction
): Promise<Result<boolean, Error>> {
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<InferAttributes<CustomerInvoiceModel>> = {}
): Promise<Result<ProformaStatusCounts, Error>> {
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<InferAttributes<CustomerInvoiceModel>> = {
[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));
}
}
}

View File

@ -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<IssuedInvoiceListRow, unknown>[];
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}
/>
);

View File

@ -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 && (
<>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<SimpleSearchInput
loading={listCtrl.isLoading}
onSearchChange={listCtrl.setSearchValue}
value={listCtrl.search}
/>
<Select
onValueChange={(value) => listCtrl.setStatusFilter(value ?? "all")}
value={listCtrl.statusFilter}
>
<SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("catalog.issued_invoices.status.all.label")}
</SelectItem>
<SelectItem value="Correcto">
{t("catalog.issued_invoices.status.correcto.label")}
</SelectItem>
<SelectItem value="Pendiente">
{t("catalog.issued_invoices.status.pendiente.label")}
</SelectItem>
<SelectItem value="aceptado_con_error">
{t("catalog.issued_invoices.status.aceptado_con_error.label")}
</SelectItem>
<SelectItem value="incorrecto">
{t("catalog.issued_invoices.status.incorrecto.label")}
</SelectItem>
<SelectItem value="duplicado">
{t("catalog.issued_invoices.status.duplicado.label")}
</SelectItem>
<SelectItem value="anulado">
{t("catalog.issued_invoices.status.anulado.label")}
</SelectItem>
<SelectItem value="factura_inexistente">
{t("catalog.issued_invoices.status.duplicado.label")}
</SelectItem>
<SelectItem value="rechazado">
{t("catalog.issued_invoices.status.rechazado.label")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<IssuedInvoicesGrid
columns={columns}
data={listCtrl.data}
fetching={listCtrl.isFetching}
loading={listCtrl.isLoading}
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}
@ -156,6 +113,56 @@ export const ListIssuedInvoicesPage = () => {
//onRowClick={(id) => navigate(`/issuedInvoices/${id}`)}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
searching={isSearchPending}
topContent={
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<SimpleSearchInput
loading={listCtrl.isLoading}
onPendingChange={setIsSearchPending}
onSearchChange={listCtrl.setSearchValue}
value={listCtrl.search}
/>
<Select
onValueChange={(value) => listCtrl.setStatusFilter(value ?? "all")}
value={listCtrl.statusFilter}
>
<SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("catalog.issued_invoices.status.all.label")}
</SelectItem>
<SelectItem value="Correcto">
{t("catalog.issued_invoices.status.correcto.label")}
</SelectItem>
<SelectItem value="Pendiente">
{t("catalog.issued_invoices.status.pendiente.label")}
</SelectItem>
<SelectItem value="aceptado_con_error">
{t("catalog.issued_invoices.status.aceptado_con_error.label")}
</SelectItem>
<SelectItem value="incorrecto">
{t("catalog.issued_invoices.status.incorrecto.label")}
</SelectItem>
<SelectItem value="duplicado">
{t("catalog.issued_invoices.status.duplicado.label")}
</SelectItem>
<SelectItem value="anulado">
{t("catalog.issued_invoices.status.anulado.label")}
</SelectItem>
<SelectItem value="factura_inexistente">
{t("catalog.issued_invoices.status.duplicado.label")}
</SelectItem>
<SelectItem value="rechazado">
{t("catalog.issued_invoices.status.rechazado.label")}
</SelectItem>
</SelectContent>
</Select>
</div>
}
/>
</div>
</>

View File

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

View File

@ -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<ProformaListRow, unknown>[];
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}
/>
);

View File

@ -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 (
<ToggleGroup
aria-label={t("pages.proformas.list.filters.status.label")}
className="w-full flex-wrap justify-start"
className="w-full flex-wrap justify-start gap-1"
disabled={disabled}
onValueChange={handleValueChange}
size="sm"
spacing={0}
spacing={1}
type="single"
value={value}
variant="outline"
@ -58,8 +114,10 @@ export const ProformaStatusSegmentedFilter = ({
{PROFORMA_STATUS_FILTER_OPTIONS.map((option) => (
<ToggleGroupItem
className={cn(
"min-w-fit px-3 text-xs sm:text-sm",
"data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:shadow-none"
"group h-auto min-w-fit rounded-full border-transparent px-3 py-2 text-xs sm:text-sm shadow-none transition-colors",
"text-muted-foreground hover:border-border hover:bg-muted/60 hover:text-foreground",
"data-[state=on]:shadow-none",
option.activeClassName
)}
key={option.value}
onPointerDown={(event) => {
@ -69,7 +127,20 @@ export const ProformaStatusSegmentedFilter = ({
}}
value={option.value}
>
{t(option.labelKey)}
<span className="flex items-center gap-2">
{option.icon ? <option.icon className="size-3.5" /> : null}
<span>{t(option.labelKey)}</span>
<Badge
className={cn(
"min-w-5 rounded-full px-1.5 text-[11px] font-semibold tabular-nums",
"bg-muted text-muted-foreground",
"group-data-[state=on]:bg-white/20 group-data-[state=on]:text-current"
)}
variant="secondary"
>
{counts[option.countKey]}
</Badge>
</span>
</ToggleGroupItem>
))}
</ToggleGroup>

View File

@ -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 = (
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<SimpleSearchInput
loading={listCtrl.isLoading}
onSearchChange={listCtrl.setSearchValue}
value={listCtrl.search}
/>
<ProformaStatusSegmentedFilter
disabled={listCtrl.isLoading}
onValueChange={listCtrl.setStatusFilter}
value={listCtrl.statusFilter}
/>
</div>
<div>
<ProformasGrid
columns={columns}
@ -188,7 +177,30 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
onSortChange={listCtrl.setSort}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
searching={isSearchPending}
sort={listCtrl.sort}
topContent={
<div className="space-y-5">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="w-full">
<SimpleSearchInput
loading={listCtrl.isLoading}
onPendingChange={setIsSearchPending}
onSearchChange={listCtrl.setSearchValue}
placeholder="Buscar por numero, cliente o CIF..."
value={listCtrl.search}
/>
</div>
</div>
<ProformaStatusSegmentedFilter
counts={listCtrl.data.statusCounts}
disabled={listCtrl.isLoading}
onValueChange={listCtrl.setStatusFilter}
value={listCtrl.statusFilter}
/>
</div>
}
/>
{listCtrl.data.totalItems === 0 ? (
<div className="rounded border border-dashed border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">

View File

@ -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<Record<keyof ProformaStatusCounts, unknown>> | 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:

View File

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

View File

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

View File

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

View File

@ -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<CustomerListRow, unknown>[];
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}
/>
);

View File

@ -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 = (
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<SimpleSearchInput
loading={listCtrl.isFetching}
onSearchChange={listCtrl.setSearchValue}
value={listCtrl.search}
/>
</div>
<div>
<CustomersGrid
columns={columns}
@ -76,7 +70,18 @@ export const ListCustomersPage = () => {
onSortChange={listCtrl.setSort}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
searching={isSearchPending}
sort={listCtrl.sort}
topContent={
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<SimpleSearchInput
loading={listCtrl.isFetching}
onPendingChange={setIsSearchPending}
onSearchChange={listCtrl.setSearchValue}
value={listCtrl.search}
/>
</div>
}
/>
</div>
</div>

View File

@ -75,7 +75,10 @@ export function DataTableToolbar<TData>({
// Render principal
return (
<div
className={cn("flex items-center justify-between gap-2 px-2 py-2 bg-background", className)}
className={cn(
"flex items-center justify-between px-4 py-4 sm:px-5 sm:py-5 bg-background",
className
)}
>
{/* IZQUIERDA: acciones + contador */}
<div className="flex flex-1 items-center gap-3 flex-wrap">

View File

@ -1,4 +1,5 @@
import {
Spinner,
TableBody,
TableCell,
Table as TableComp,
@ -76,6 +77,9 @@ export interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
meta?: DataTableMeta<TData>;
topContent?: React.ReactNode;
isFetching?: boolean;
isSearching?: boolean;
// Configuración
columnVisibility?: VisibilityState;
@ -112,6 +116,9 @@ export function DataTable<TData, TValue>({
columns,
data,
meta,
topContent,
isFetching = false,
isSearching = false,
columnVisibility,
onColumnVisibilityChange,
@ -136,6 +143,7 @@ export function DataTable<TData, TValue>({
onRowClick,
}: DataTableProps<TData, TValue>) {
const { t } = useTranslation();
const isBusy = isFetching || isSearching;
const [rowSelection, setRowSelection] = React.useState({});
const [internalSorting, setInternalSorting] = React.useState<DataTableSort | undefined>();
@ -258,17 +266,56 @@ export function DataTable<TData, TValue>({
// Render principal
return (
<div className="transition-[max-height] duration-300 ease-in-out">
<div className="flex flex-col rounded-lg border overflow-clip">
<div className="relative flex flex-col overflow-clip rounded-lg border">
{topContent ? (
<div className="border-b bg-background px-4 py-4 sm:px-5 sm:py-5">{topContent}</div>
) : null}
<DataTableToolbar showViewOptions={!readOnly} table={table} />
<div className="overflow-hidden rounded-md border-t">
<div className="relative overflow-hidden">
{isBusy ? (
<>
<div className="pointer-events-none absolute inset-0 z-20 bg-background/55 backdrop-blur-[1.5px]" />
<div className="pointer-events-none absolute inset-x-0 top-0 z-30 h-1 overflow-hidden bg-transparent">
<div className="h-full w-1/3 animate-pulse rounded-full bg-primary" />
</div>
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center p-6">
<div className="inline-flex min-w-[220px] max-w-[90%] items-center gap-3 rounded-2xl border bg-background/96 px-4 py-3 text-sm shadow-lg ring-1 ring-primary/10 backdrop-blur">
<div className="flex size-9 items-center justify-center rounded-full bg-primary/10 text-primary">
<Spinner className="size-4" />
</div>
<div className="flex flex-col">
<span aria-live="polite" className="font-medium text-foreground">
{isSearching
? t("components.datatable.searching_data", "Searching...")
: t("components.datatable.loading_data", "Updating data...")}
</span>
<span className="text-xs text-muted-foreground">
{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."
)}
</span>
</div>
</div>
</div>
</>
) : null}
<div className={cn("overflow-hidden transition-opacity", isBusy && "opacity-45")}>
<TableComp className="min-w-full sm:min-w-[820px] w-full">
{/* CABECERA */}
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow
className="bg-background hover:bg-muted/50 text-xs sm:text-sm"
key={hg.id}
className="bg-background hover:bg-muted/50 text-xs sm:text-sm"
key={hg.id}
>
{hg.headers.map((h) => {
/*
@ -289,9 +336,12 @@ export function DataTable<TData, TValue>({
return (
<TableHead
className={cn("whitespace-nowrap font-semibold", headerClassName)}
colSpan={h.colSpan}
key={h.id}
className={cn(
"whitespace-nowrap font-semibold first:pl-4 last:pr-4 sm:first:pl-5 sm:last:pr-5",
headerClassName
)}
colSpan={h.colSpan}
key={h.id}
>
{h.isPlaceholder
? null
@ -308,7 +358,7 @@ export function DataTable<TData, TValue>({
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row, rowIndex) => (
<TableRow
className={"group bg-background text-xs sm:text-sm"}
className={"group bg-background text-xs sm:text-sm px-4 sm:px-5"}
data-state={row.getIsSelected() && "selected"}
key={row.id}
onClick={(e) => onRowClick?.(row.original, rowIndex, e)}
@ -339,7 +389,13 @@ export function DataTable<TData, TValue>({
const cellClassName = cell.column.columnDef.meta?.cellClassName;
return (
<TableCell className={cn("whitespace-nowrap", cellClassName)} key={cell.id}>
<TableCell
className={cn(
"whitespace-nowrap first:pl-4 last:pr-4 sm:first:pl-5 sm:last:pr-5",
cellClassName
)}
key={cell.id}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
@ -360,15 +416,16 @@ export function DataTable<TData, TValue>({
{/* Paginación */}
{enablePagination && (
<TableFooter className="bg-background">
<TableRow>
<TableCell colSpan={100}>
<DataTablePagination table={table} />
</TableCell>
</TableRow>
<TableFooter className="bg-background">
<TableRow>
<TableCell className="px-4 py-4 sm:px-5" colSpan={100}>
<DataTablePagination table={table} />
</TableCell>
</TableRow>
</TableFooter>
)}
</TableComp>
</div>
</div>
</div>
</div>