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:
parent
c46769e71e
commit
2010eab897
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user