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",
|
"search_button": "Search",
|
||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
"clear_search": "Clear search",
|
"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": {
|
"hooks": {
|
||||||
|
|||||||
@ -16,7 +16,17 @@
|
|||||||
"search_button": "Buscar",
|
"search_button": "Buscar",
|
||||||
"loading": "Buscando",
|
"loading": "Buscando",
|
||||||
"clear_search": "Limpiar búsqueda",
|
"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": {
|
"hooks": {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
InputGroupInput,
|
InputGroupInput,
|
||||||
Spinner,
|
Spinner,
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import { SearchIcon, XIcon } from "lucide-react";
|
import { SearchIcon, XIcon } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
@ -14,6 +15,7 @@ import { useTranslation } from "../../i18n";
|
|||||||
type SimpleSearchInputProps = {
|
type SimpleSearchInputProps = {
|
||||||
value: string;
|
value: string;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
|
onPendingChange?: (pending: boolean) => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
maxHistory?: number;
|
maxHistory?: number;
|
||||||
|
|
||||||
@ -28,6 +30,7 @@ const normalizeSearchValue = (value: string) => value.trim().replace(/\s+/g, " "
|
|||||||
export const SimpleSearchInput = ({
|
export const SimpleSearchInput = ({
|
||||||
value,
|
value,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
|
onPendingChange,
|
||||||
loading = false,
|
loading = false,
|
||||||
maxHistory = 8,
|
maxHistory = 8,
|
||||||
placeholder,
|
placeholder,
|
||||||
@ -39,16 +42,30 @@ export const SimpleSearchInput = ({
|
|||||||
const [history, setHistory] = useState<string[]>([]);
|
const [history, setHistory] = useState<string[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const onSearchChangeRef = useRef(onSearchChange);
|
||||||
|
|
||||||
// Evita que el siguiente debounce pise una búsqueda inmediata
|
// Evita que el siguiente debounce pise una búsqueda inmediata
|
||||||
const skipNextDebouncedEmitRef = useRef(false);
|
const skipNextDebouncedEmitRef = useRef(false);
|
||||||
|
|
||||||
const debouncedValue = useDebounce(searchValue, 300);
|
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(() => {
|
useEffect(() => {
|
||||||
setSearchValue(value);
|
setSearchValue(value);
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSearchChangeRef.current = onSearchChange;
|
||||||
|
}, [onSearchChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onPendingChange?.(isTypingPendingSearch);
|
||||||
|
}, [isTypingPendingSearch, onPendingChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = localStorage.getItem(SEARCH_HISTORY_KEY);
|
const stored = localStorage.getItem(SEARCH_HISTORY_KEY);
|
||||||
if (!stored) return;
|
if (!stored) return;
|
||||||
@ -67,8 +84,8 @@ export const SimpleSearchInput = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSearchChange(normalizeSearchValue(debouncedValue));
|
onSearchChangeRef.current(normalizeSearchValue(debouncedValue));
|
||||||
}, [debouncedValue, onSearchChange]);
|
}, [debouncedValue]);
|
||||||
|
|
||||||
const saveHistory = (term: string) => {
|
const saveHistory = (term: string) => {
|
||||||
const cleaned = normalizeSearchValue(term);
|
const cleaned = normalizeSearchValue(term);
|
||||||
@ -132,8 +149,14 @@ export const SimpleSearchInput = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-1 items-center gap-4">
|
<div className="relative flex flex-1 flex-col items-start gap-2">
|
||||||
<InputGroup className="bg-background">
|
<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
|
<InputGroupInput
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
@ -150,37 +173,85 @@ export const SimpleSearchInput = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<InputGroupAddon>
|
<InputGroupAddon>
|
||||||
<SearchIcon aria-hidden className="text-muted-foreground" />
|
{isSearching ? (
|
||||||
</InputGroupAddon>
|
|
||||||
|
|
||||||
<InputGroupAddon align="inline-end">
|
|
||||||
{loading && (
|
|
||||||
<Spinner
|
<Spinner
|
||||||
aria-label={t("components.simple_search_input.loading", "Loading")}
|
aria-label={t("components.simple_search_input.loading", "Loading")}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<SearchIcon aria-hidden className="text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
</InputGroup>
|
|
||||||
|
|
||||||
{!(searchValue || loading) && (
|
<InputGroupAddon align="inline-end">
|
||||||
<Button onClick={() => handleImmediateSearch(searchValue)} type="button" variant="outline">
|
{isTypingPendingSearch ? (
|
||||||
{t("components.simple_search_input.search_button", "Search")}
|
<span
|
||||||
</Button>
|
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 && (
|
{!(searchValue || isSearching) && (
|
||||||
<Button
|
<Button
|
||||||
aria-label={t("components.simple_search_input.clear_search", "Clear search")}
|
onClick={() => handleImmediateSearch(searchValue)}
|
||||||
className="cursor-pointer"
|
type="button"
|
||||||
onClick={handleClear}
|
variant="outline"
|
||||||
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" />
|
<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>{t("components.simple_search_input.clear_search", "Clear")}</span>
|
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-background text-primary ring-1 ring-border">
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export * from "./company-report-profile.model";
|
|||||||
export * from "./proforma-create-input.model";
|
export * from "./proforma-create-input.model";
|
||||||
export * from "./proforma-full-read.model";
|
export * from "./proforma-full-read.model";
|
||||||
export * from "./proforma-item-tax-codes-input.model";
|
export * from "./proforma-item-tax-codes-input.model";
|
||||||
|
export * from "./proforma-status-counts";
|
||||||
export * from "./proforma-summary";
|
export * from "./proforma-summary";
|
||||||
export * from "./proforma-update-input.model";
|
export * from "./proforma-update-input.model";
|
||||||
export * from "./report-localization.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 { Collection, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { InvoiceStatus, Proforma } from "../../../domain";
|
import type { InvoiceStatus, Proforma } from "../../../domain";
|
||||||
import type { ProformaSummary } from "../models";
|
import type { ProformaStatusCounts, ProformaSummary } from "../models";
|
||||||
import type { ProformaListScope } from "../use-cases";
|
import type { ProformaListScope } from "../use-cases";
|
||||||
|
|
||||||
export interface IProformaRepository {
|
export interface IProformaRepository {
|
||||||
@ -36,6 +36,13 @@ export interface IProformaRepository {
|
|||||||
transaction: unknown
|
transaction: unknown
|
||||||
): Promise<Result<Collection<ProformaSummary>, Error>>;
|
): Promise<Result<Collection<ProformaSummary>, Error>>;
|
||||||
|
|
||||||
|
countByStatusInCompany(
|
||||||
|
companyId: UniqueID,
|
||||||
|
scope: ProformaListScope,
|
||||||
|
criteria: Criteria,
|
||||||
|
transaction: unknown
|
||||||
|
): Promise<Result<ProformaStatusCounts, Error>>;
|
||||||
|
|
||||||
deleteByIdInCompany(
|
deleteByIdInCompany(
|
||||||
companyId: UniqueID,
|
companyId: UniqueID,
|
||||||
id: UniqueID,
|
id: UniqueID,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { UniqueID } from "@repo/rdx-ddd";
|
|||||||
import type { Collection, Result } from "@repo/rdx-utils";
|
import type { Collection, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { Proforma } from "../../../domain";
|
import type { Proforma } from "../../../domain";
|
||||||
import type { ProformaSummary } from "../models";
|
import type { ProformaStatusCounts, ProformaSummary } from "../models";
|
||||||
import type { IProformaRepository } from "../repositories";
|
import type { IProformaRepository } from "../repositories";
|
||||||
import type { ProformaListScope } from "../use-cases";
|
import type { ProformaListScope } from "../use-cases";
|
||||||
|
|
||||||
@ -32,6 +32,13 @@ export interface IProformaFinder {
|
|||||||
criteria: Criteria,
|
criteria: Criteria,
|
||||||
transaction?: unknown
|
transaction?: unknown
|
||||||
): Promise<Result<Collection<ProformaSummary>, Error>>;
|
): Promise<Result<Collection<ProformaSummary>, Error>>;
|
||||||
|
|
||||||
|
countProformasByStatus(
|
||||||
|
companyId: UniqueID,
|
||||||
|
scope: ProformaListScope,
|
||||||
|
criteria: Criteria,
|
||||||
|
transaction?: unknown
|
||||||
|
): Promise<Result<ProformaStatusCounts, Error>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProformaFinder implements IProformaFinder {
|
export class ProformaFinder implements IProformaFinder {
|
||||||
@ -69,4 +76,13 @@ export class ProformaFinder implements IProformaFinder {
|
|||||||
): Promise<Result<Collection<ProformaSummary>, Error>> {
|
): Promise<Result<Collection<ProformaSummary>, Error>> {
|
||||||
return this.repository.findByCriteriaInCompany(companyId, scope, criteria, transaction);
|
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 { ITransactionManager } from "@erp/core/api";
|
||||||
import type { Criteria } from "@repo/rdx-criteria/server";
|
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 type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
@ -26,17 +27,28 @@ export class ListProformasUseCase {
|
|||||||
|
|
||||||
return this.transactionManager.complete(async (transaction: unknown) => {
|
return this.transactionManager.complete(async (transaction: unknown) => {
|
||||||
try {
|
try {
|
||||||
|
const statusSummaryCriteria = this.buildStatusSummaryCriteria(criteria);
|
||||||
const result = await this.finder.findProformasByCriteria(
|
const result = await this.finder.findProformasByCriteria(
|
||||||
companyId,
|
companyId,
|
||||||
scope,
|
scope,
|
||||||
criteria,
|
criteria,
|
||||||
transaction
|
transaction
|
||||||
);
|
);
|
||||||
|
const statusCountsResult = await this.finder.countProformasByStatus(
|
||||||
|
companyId,
|
||||||
|
scope,
|
||||||
|
statusSummaryCriteria,
|
||||||
|
transaction
|
||||||
|
);
|
||||||
|
|
||||||
if (result.isFailure) {
|
if (result.isFailure) {
|
||||||
return Result.fail(result.error);
|
return Result.fail(result.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (statusCountsResult.isFailure) {
|
||||||
|
return Result.fail(statusCountsResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
const proformas = result.data;
|
const proformas = result.data;
|
||||||
const totalProformas = proformas.total();
|
const totalProformas = proformas.total();
|
||||||
|
|
||||||
@ -51,6 +63,7 @@ export class ListProformasUseCase {
|
|||||||
metadata: {
|
metadata: {
|
||||||
entity: "proformas",
|
entity: "proformas",
|
||||||
criteria: criteria.toJSON(),
|
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 FindOptions,
|
||||||
type Includeable,
|
type Includeable,
|
||||||
type InferAttributes,
|
type InferAttributes,
|
||||||
|
col,
|
||||||
|
fn,
|
||||||
Op,
|
Op,
|
||||||
type OrderItem,
|
type OrderItem,
|
||||||
type Sequelize,
|
type Sequelize,
|
||||||
@ -25,7 +27,11 @@ import {
|
|||||||
type WhereOptions,
|
type WhereOptions,
|
||||||
} from "sequelize";
|
} from "sequelize";
|
||||||
|
|
||||||
import type { IProformaRepository, ProformaSummary } from "../../../../../application";
|
import type {
|
||||||
|
IProformaRepository,
|
||||||
|
ProformaStatusCounts,
|
||||||
|
ProformaSummary,
|
||||||
|
} from "../../../../../application";
|
||||||
import type { ProformaListScope } from "../../../../../application";
|
import type { ProformaListScope } from "../../../../../application";
|
||||||
import { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../../../../../domain";
|
import { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../../../../../domain";
|
||||||
import {
|
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) {
|
private assertSupportedCriteriaFilters(criteria: Criteria) {
|
||||||
const { filters } = criteria.toPrimitives();
|
const { filters } = criteria.toPrimitives();
|
||||||
|
|
||||||
@ -552,18 +674,18 @@ export class SequelizeProformaRepositoryV2
|
|||||||
archived_at: {
|
archived_at: {
|
||||||
[Op.ne]: null,
|
[Op.ne]: null,
|
||||||
},
|
},
|
||||||
};
|
} as WhereOptions<InferAttributes<ProformaModel>>;
|
||||||
case "deleted":
|
case "deleted":
|
||||||
return {
|
return {
|
||||||
deleted_at: {
|
deleted_at: {
|
||||||
[Op.ne]: null,
|
[Op.ne]: null,
|
||||||
},
|
},
|
||||||
};
|
} as WhereOptions<InferAttributes<ProformaModel>>;
|
||||||
case "normal":
|
case "normal":
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
archived_at: null,
|
archived_at: null,
|
||||||
};
|
} as WhereOptions<InferAttributes<ProformaModel>>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,8 @@ import {
|
|||||||
type FindOptions,
|
type FindOptions,
|
||||||
type Includeable,
|
type Includeable,
|
||||||
type InferAttributes,
|
type InferAttributes,
|
||||||
|
col,
|
||||||
|
fn,
|
||||||
Op,
|
Op,
|
||||||
type OrderItem,
|
type OrderItem,
|
||||||
type Sequelize,
|
type Sequelize,
|
||||||
@ -25,7 +27,11 @@ import {
|
|||||||
type WhereOptions,
|
type WhereOptions,
|
||||||
} from "sequelize";
|
} 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 { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../../../../../domain";
|
||||||
import {
|
import {
|
||||||
CustomerInvoiceItemModel,
|
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).
|
* Consulta facturas usando un objeto Criteria (filtros, orden, paginación).
|
||||||
@ -615,4 +648,115 @@ export class ProformaRepository
|
|||||||
return Result.fail(translateSequelizeError(err));
|
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 { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../../i18n";
|
import { useTranslation } from "../../../../../i18n";
|
||||||
import type { IssuedInvoiceList, IssuedInvoiceListRow } from "../../../../shared";
|
import type { IssuedInvoiceList, IssuedInvoiceListRow } from "../../../../shared";
|
||||||
@ -8,8 +9,10 @@ interface IssuedInvoicesGridProps {
|
|||||||
data?: IssuedInvoiceList;
|
data?: IssuedInvoiceList;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
fetching?: boolean;
|
fetching?: boolean;
|
||||||
|
searching?: boolean;
|
||||||
|
|
||||||
columns: ColumnDef<IssuedInvoiceListRow, unknown>[];
|
columns: ColumnDef<IssuedInvoiceListRow, unknown>[];
|
||||||
|
topContent?: React.ReactNode;
|
||||||
|
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
@ -23,7 +26,9 @@ export const IssuedInvoicesGrid = ({
|
|||||||
data,
|
data,
|
||||||
loading,
|
loading,
|
||||||
fetching,
|
fetching,
|
||||||
|
searching,
|
||||||
columns,
|
columns,
|
||||||
|
topContent,
|
||||||
pageIndex,
|
pageIndex,
|
||||||
pageSize,
|
pageSize,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
@ -101,12 +106,15 @@ export const IssuedInvoicesGrid = ({
|
|||||||
data={items}
|
data={items}
|
||||||
enablePagination
|
enablePagination
|
||||||
enableRowSelection
|
enableRowSelection
|
||||||
|
isFetching={fetching}
|
||||||
|
isSearching={searching}
|
||||||
manualPagination
|
manualPagination
|
||||||
onPageChange={onPageChange}
|
onPageChange={onPageChange}
|
||||||
onPageSizeChange={onPageSizeChange}
|
onPageSizeChange={onPageSizeChange}
|
||||||
//onRowClick={(row) => onRowClick?.(row.id)}
|
//onRowClick={(row) => onRowClick?.(row.id)}
|
||||||
pageIndex={pageIndex}
|
pageIndex={pageIndex}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
|
topContent={topContent}
|
||||||
totalItems={totalItems}
|
totalItems={totalItems}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
import { FilterIcon, PlusIcon } from "lucide-react";
|
import { FilterIcon, PlusIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../../i18n";
|
||||||
@ -22,6 +23,7 @@ import { IssuedInvoicesEmptyGrid } from "../components";
|
|||||||
export const ListIssuedInvoicesPage = () => {
|
export const ListIssuedInvoicesPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [isSearchPending, setIsSearchPending] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
listCtrl,
|
listCtrl,
|
||||||
@ -99,56 +101,11 @@ export const ListIssuedInvoicesPage = () => {
|
|||||||
|
|
||||||
{listCtrl.data.totalItems !== 0 && (
|
{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">
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
<IssuedInvoicesGrid
|
<IssuedInvoicesGrid
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={listCtrl.data}
|
data={listCtrl.data}
|
||||||
|
fetching={listCtrl.isFetching}
|
||||||
loading={listCtrl.isLoading}
|
loading={listCtrl.isLoading}
|
||||||
onPageChange={listCtrl.setPageIndex}
|
onPageChange={listCtrl.setPageIndex}
|
||||||
onPageSizeChange={listCtrl.setPageSize}
|
onPageSizeChange={listCtrl.setPageSize}
|
||||||
@ -156,6 +113,56 @@ export const ListIssuedInvoicesPage = () => {
|
|||||||
//onRowClick={(id) => navigate(`/issuedInvoices/${id}`)}
|
//onRowClick={(id) => navigate(`/issuedInvoices/${id}`)}
|
||||||
pageIndex={listCtrl.pageIndex}
|
pageIndex={listCtrl.pageIndex}
|
||||||
pageSize={listCtrl.pageSize}
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
type ListProformasByCriteriaParams,
|
type ListProformasByCriteriaParams,
|
||||||
type ProformaList,
|
type ProformaList,
|
||||||
type ProformaStatus,
|
type ProformaStatus,
|
||||||
|
EMPTY_PROFORMA_STATUS_COUNTS,
|
||||||
useProformasListQuery,
|
useProformasListQuery,
|
||||||
} from "../../shared";
|
} from "../../shared";
|
||||||
import type { ProformaListScope, ProformaListStatusFilter } from "../types/proforma-list-filters";
|
import type { ProformaListScope, ProformaListStatusFilter } from "../types/proforma-list-filters";
|
||||||
@ -25,6 +26,7 @@ const EMPTY_PROFORMAS_LIST: ProformaList = {
|
|||||||
perPage: INITIAL_PAGE_SIZE,
|
perPage: INITIAL_PAGE_SIZE,
|
||||||
totalPages: 0,
|
totalPages: 0,
|
||||||
totalItems: 0,
|
totalItems: 0,
|
||||||
|
statusCounts: EMPTY_PROFORMA_STATUS_COUNTS,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Campos que se permiten ordenar para la lista de proformas (consulta).
|
// 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 { DataTable, type DataTableSort, SkeletonDataTable } from "@repo/rdx-ui/components";
|
||||||
import type { ColumnDef, OnChangeFn, VisibilityState } from "@tanstack/react-table";
|
import type { ColumnDef, OnChangeFn, VisibilityState } from "@tanstack/react-table";
|
||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../../i18n";
|
import { useTranslation } from "../../../../../i18n";
|
||||||
import type { ProformaList, ProformaListRow } from "../../../../shared";
|
import type { ProformaList, ProformaListRow } from "../../../../shared";
|
||||||
@ -8,8 +9,10 @@ interface ProformasGridProps {
|
|||||||
data?: ProformaList;
|
data?: ProformaList;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
fetching?: boolean;
|
fetching?: boolean;
|
||||||
|
searching?: boolean;
|
||||||
|
|
||||||
columns: ColumnDef<ProformaListRow, unknown>[];
|
columns: ColumnDef<ProformaListRow, unknown>[];
|
||||||
|
topContent?: React.ReactNode;
|
||||||
|
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
@ -28,9 +31,11 @@ interface ProformasGridProps {
|
|||||||
export const ProformasGrid = ({
|
export const ProformasGrid = ({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
|
topContent,
|
||||||
|
|
||||||
loading,
|
loading,
|
||||||
fetching,
|
fetching,
|
||||||
|
searching,
|
||||||
|
|
||||||
pageIndex,
|
pageIndex,
|
||||||
pageSize,
|
pageSize,
|
||||||
@ -66,6 +71,8 @@ export const ProformasGrid = ({
|
|||||||
data={items}
|
data={items}
|
||||||
enablePagination
|
enablePagination
|
||||||
enableRowSelection
|
enableRowSelection
|
||||||
|
isFetching={fetching}
|
||||||
|
isSearching={searching}
|
||||||
manualPagination
|
manualPagination
|
||||||
manualSorting
|
manualSorting
|
||||||
onColumnVisibilityChange={onColumnVisibilityChange}
|
onColumnVisibilityChange={onColumnVisibilityChange}
|
||||||
@ -76,6 +83,7 @@ export const ProformasGrid = ({
|
|||||||
pageIndex={pageIndex}
|
pageIndex={pageIndex}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
|
topContent={topContent}
|
||||||
totalItems={totalItems}
|
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 { 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 { useTranslation } from "../../../../i18n";
|
||||||
|
import type { ProformaStatusCounts } from "../../../shared";
|
||||||
import type { ProformaListStatusFilter } from "../../types/proforma-list-filters";
|
import type { ProformaListStatusFilter } from "../../types/proforma-list-filters";
|
||||||
|
|
||||||
export type ProformaStatusFilter = ProformaListStatusFilter;
|
export type ProformaStatusFilter = ProformaListStatusFilter;
|
||||||
@ -9,19 +18,65 @@ export type ProformaStatusFilter = ProformaListStatusFilter;
|
|||||||
export type ProformaStatusSegmentedFilterProps = {
|
export type ProformaStatusSegmentedFilterProps = {
|
||||||
value: ProformaStatusFilter;
|
value: ProformaStatusFilter;
|
||||||
onValueChange: (value: ProformaStatusFilter) => void;
|
onValueChange: (value: ProformaStatusFilter) => void;
|
||||||
|
counts: ProformaStatusCounts;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROFORMA_STATUS_FILTER_OPTIONS = [
|
const PROFORMA_STATUS_FILTER_OPTIONS = [
|
||||||
{ value: "all", labelKey: "catalog.proformas.status.all.label" },
|
{
|
||||||
{ value: "draft", labelKey: "catalog.proformas.status.draft.label" },
|
value: "all",
|
||||||
{ value: "sent", labelKey: "catalog.proformas.status.sent.label" },
|
labelKey: "catalog.proformas.status.all.label",
|
||||||
{ value: "approved", labelKey: "catalog.proformas.status.approved.label" },
|
icon: null,
|
||||||
{ value: "rejected", labelKey: "catalog.proformas.status.rejected.label" },
|
countKey: "all",
|
||||||
{ value: "issued", labelKey: "catalog.proformas.status.issued.label" },
|
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 {
|
] as const satisfies readonly {
|
||||||
value: ProformaStatusFilter;
|
value: ProformaStatusFilter;
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
|
icon: ElementType | null;
|
||||||
|
countKey: keyof ProformaStatusCounts;
|
||||||
|
activeClassName: string;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
const isProformaStatusFilter = (value: string): value is ProformaStatusFilter => {
|
const isProformaStatusFilter = (value: string): value is ProformaStatusFilter => {
|
||||||
@ -31,6 +86,7 @@ const isProformaStatusFilter = (value: string): value is ProformaStatusFilter =>
|
|||||||
export const ProformaStatusSegmentedFilter = ({
|
export const ProformaStatusSegmentedFilter = ({
|
||||||
value,
|
value,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
|
counts,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: ProformaStatusSegmentedFilterProps) => {
|
}: ProformaStatusSegmentedFilterProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -46,11 +102,11 @@ export const ProformaStatusSegmentedFilter = ({
|
|||||||
return (
|
return (
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
aria-label={t("pages.proformas.list.filters.status.label")}
|
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}
|
disabled={disabled}
|
||||||
onValueChange={handleValueChange}
|
onValueChange={handleValueChange}
|
||||||
size="sm"
|
size="sm"
|
||||||
spacing={0}
|
spacing={1}
|
||||||
type="single"
|
type="single"
|
||||||
value={value}
|
value={value}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -58,8 +114,10 @@ export const ProformaStatusSegmentedFilter = ({
|
|||||||
{PROFORMA_STATUS_FILTER_OPTIONS.map((option) => (
|
{PROFORMA_STATUS_FILTER_OPTIONS.map((option) => (
|
||||||
<ToggleGroupItem
|
<ToggleGroupItem
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-fit px-3 text-xs sm:text-sm",
|
"group h-auto min-w-fit rounded-full border-transparent px-3 py-2 text-xs sm:text-sm shadow-none transition-colors",
|
||||||
"data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:shadow-none"
|
"text-muted-foreground hover:border-border hover:bg-muted/60 hover:text-foreground",
|
||||||
|
"data-[state=on]:shadow-none",
|
||||||
|
option.activeClassName
|
||||||
)}
|
)}
|
||||||
key={option.value}
|
key={option.value}
|
||||||
onPointerDown={(event) => {
|
onPointerDown={(event) => {
|
||||||
@ -69,7 +127,20 @@ export const ProformaStatusSegmentedFilter = ({
|
|||||||
}}
|
}}
|
||||||
value={option.value}
|
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>
|
</ToggleGroupItem>
|
||||||
))}
|
))}
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
PlusIcon,
|
PlusIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { createSearchParams, useNavigate } from "react-router-dom";
|
import { createSearchParams, useNavigate } from "react-router-dom";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../../i18n";
|
||||||
import { ChangeProformaStatusDialog } from "../../../change-status";
|
import { ChangeProformaStatusDialog } from "../../../change-status";
|
||||||
@ -82,6 +83,7 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const scopeConfig = SCOPE_CONFIG[scope];
|
const scopeConfig = SCOPE_CONFIG[scope];
|
||||||
|
const [isSearchPending, setIsSearchPending] = useState(false);
|
||||||
|
|
||||||
const { currentReturnTo } = useReturnToNavigation({
|
const { currentReturnTo } = useReturnToNavigation({
|
||||||
fallbackPath: scopeConfig.currentPath,
|
fallbackPath: scopeConfig.currentPath,
|
||||||
@ -157,19 +159,6 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
|
|||||||
|
|
||||||
const listContent = (
|
const listContent = (
|
||||||
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
|
<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>
|
<div>
|
||||||
<ProformasGrid
|
<ProformasGrid
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@ -188,7 +177,30 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
|
|||||||
onSortChange={listCtrl.setSort}
|
onSortChange={listCtrl.setSort}
|
||||||
pageIndex={listCtrl.pageIndex}
|
pageIndex={listCtrl.pageIndex}
|
||||||
pageSize={listCtrl.pageSize}
|
pageSize={listCtrl.pageSize}
|
||||||
|
searching={isSearchPending}
|
||||||
sort={listCtrl.sort}
|
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 ? (
|
{listCtrl.data.totalItems === 0 ? (
|
||||||
<div className="rounded border border-dashed border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
<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 { ListProformasResponseDTO } from "../../../../common";
|
||||||
import type { ListProformasResult } from "../api";
|
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
|
* Adaptador para transformar los datos de la API de ListProformasResponseDTO
|
||||||
@ -23,11 +29,37 @@ export const ListProformasAdapter = {
|
|||||||
perPage: dto.per_page,
|
perPage: dto.per_page,
|
||||||
totalPages: dto.total_pages,
|
totalPages: dto.total_pages,
|
||||||
totalItems: dto.total_items,
|
totalItems: dto.total_items,
|
||||||
|
statusCounts: ProformaStatusCountsAdapter.fromDto(dto.metadata?.status_counts),
|
||||||
items: dto.items.map((rowDto) => ProformaListRowAdapter.fromDto(rowDto)),
|
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.
|
* Adaptador para transformar los items de la API de ListProformasResult a la entidad ProformaListRow.
|
||||||
* Reglas de adaptación:
|
* Reglas de adaptación:
|
||||||
|
|||||||
@ -6,4 +6,5 @@ export * from "./proforma-list.entity";
|
|||||||
export * from "./proforma-list-row.entity";
|
export * from "./proforma-list-row.entity";
|
||||||
export * from "./proforma-recipient.entity";
|
export * from "./proforma-recipient.entity";
|
||||||
export * from "./proforma-status.entity";
|
export * from "./proforma-status.entity";
|
||||||
|
export * from "./proforma-status-counts.entity";
|
||||||
export * from "./proforma-tax-summary.entity";
|
export * from "./proforma-tax-summary.entity";
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { ProformaListRow } from "./proforma-list-row.entity";
|
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,
|
* Interface que representa la respuesta paginada de una lista de proformas,
|
||||||
@ -11,4 +12,5 @@ export interface ProformaList {
|
|||||||
totalItems: number;
|
totalItems: number;
|
||||||
page: number;
|
page: number;
|
||||||
perPage: 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 { DataTable, type DataTableSort, SkeletonDataTable } from "@repo/rdx-ui/components";
|
||||||
import type { ColumnDef, OnChangeFn, VisibilityState } from "@tanstack/react-table";
|
import type { ColumnDef, OnChangeFn, VisibilityState } from "@tanstack/react-table";
|
||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../../i18n";
|
||||||
import type { CustomerList, CustomerListRow } from "../../../../shared";
|
import type { CustomerList, CustomerListRow } from "../../../../shared";
|
||||||
@ -8,8 +9,10 @@ interface CustomersGridProps {
|
|||||||
data?: CustomerList;
|
data?: CustomerList;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
fetching?: boolean;
|
fetching?: boolean;
|
||||||
|
searching?: boolean;
|
||||||
|
|
||||||
columns: ColumnDef<CustomerListRow, unknown>[];
|
columns: ColumnDef<CustomerListRow, unknown>[];
|
||||||
|
topContent?: React.ReactNode;
|
||||||
|
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
@ -28,9 +31,11 @@ interface CustomersGridProps {
|
|||||||
export const CustomersGrid = ({
|
export const CustomersGrid = ({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
|
topContent,
|
||||||
|
|
||||||
loading,
|
loading,
|
||||||
fetching,
|
fetching,
|
||||||
|
searching,
|
||||||
|
|
||||||
pageIndex,
|
pageIndex,
|
||||||
pageSize,
|
pageSize,
|
||||||
@ -66,6 +71,8 @@ export const CustomersGrid = ({
|
|||||||
data={items}
|
data={items}
|
||||||
enablePagination
|
enablePagination
|
||||||
enableRowSelection
|
enableRowSelection
|
||||||
|
isFetching={fetching}
|
||||||
|
isSearching={searching}
|
||||||
manualPagination
|
manualPagination
|
||||||
manualSorting
|
manualSorting
|
||||||
onColumnVisibilityChange={onColumnVisibilityChange}
|
onColumnVisibilityChange={onColumnVisibilityChange}
|
||||||
@ -76,6 +83,7 @@ export const CustomersGrid = ({
|
|||||||
pageIndex={pageIndex}
|
pageIndex={pageIndex}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
|
topContent={topContent}
|
||||||
totalItems={totalItems}
|
totalItems={totalItems}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { createSearchParams, useNavigate } from "react-router-dom";
|
import { createSearchParams, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
@ -22,6 +23,7 @@ export const ListCustomersPage = () => {
|
|||||||
const { currentReturnTo } = useReturnToNavigation({
|
const { currentReturnTo } = useReturnToNavigation({
|
||||||
fallbackPath: "/customers",
|
fallbackPath: "/customers",
|
||||||
});
|
});
|
||||||
|
const [isSearchPending, setIsSearchPending] = useState(false);
|
||||||
|
|
||||||
const { listCtrl, panelCtrl } = useListCustomersPageController();
|
const { listCtrl, panelCtrl } = useListCustomersPageController();
|
||||||
|
|
||||||
@ -54,14 +56,6 @@ export const ListCustomersPage = () => {
|
|||||||
|
|
||||||
const listContent = (
|
const listContent = (
|
||||||
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
|
<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>
|
<div>
|
||||||
<CustomersGrid
|
<CustomersGrid
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@ -76,7 +70,18 @@ export const ListCustomersPage = () => {
|
|||||||
onSortChange={listCtrl.setSort}
|
onSortChange={listCtrl.setSort}
|
||||||
pageIndex={listCtrl.pageIndex}
|
pageIndex={listCtrl.pageIndex}
|
||||||
pageSize={listCtrl.pageSize}
|
pageSize={listCtrl.pageSize}
|
||||||
|
searching={isSearchPending}
|
||||||
sort={listCtrl.sort}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -75,7 +75,10 @@ export function DataTableToolbar<TData>({
|
|||||||
// Render principal
|
// Render principal
|
||||||
return (
|
return (
|
||||||
<div
|
<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 */}
|
{/* IZQUIERDA: acciones + contador */}
|
||||||
<div className="flex flex-1 items-center gap-3 flex-wrap">
|
<div className="flex flex-1 items-center gap-3 flex-wrap">
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Spinner,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
Table as TableComp,
|
Table as TableComp,
|
||||||
@ -76,6 +77,9 @@ export interface DataTableProps<TData, TValue> {
|
|||||||
columns: ColumnDef<TData, TValue>[];
|
columns: ColumnDef<TData, TValue>[];
|
||||||
data: TData[];
|
data: TData[];
|
||||||
meta?: DataTableMeta<TData>;
|
meta?: DataTableMeta<TData>;
|
||||||
|
topContent?: React.ReactNode;
|
||||||
|
isFetching?: boolean;
|
||||||
|
isSearching?: boolean;
|
||||||
|
|
||||||
// Configuración
|
// Configuración
|
||||||
columnVisibility?: VisibilityState;
|
columnVisibility?: VisibilityState;
|
||||||
@ -112,6 +116,9 @@ export function DataTable<TData, TValue>({
|
|||||||
columns,
|
columns,
|
||||||
data,
|
data,
|
||||||
meta,
|
meta,
|
||||||
|
topContent,
|
||||||
|
isFetching = false,
|
||||||
|
isSearching = false,
|
||||||
|
|
||||||
columnVisibility,
|
columnVisibility,
|
||||||
onColumnVisibilityChange,
|
onColumnVisibilityChange,
|
||||||
@ -136,6 +143,7 @@ export function DataTable<TData, TValue>({
|
|||||||
onRowClick,
|
onRowClick,
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isBusy = isFetching || isSearching;
|
||||||
|
|
||||||
const [rowSelection, setRowSelection] = React.useState({});
|
const [rowSelection, setRowSelection] = React.useState({});
|
||||||
const [internalSorting, setInternalSorting] = React.useState<DataTableSort | undefined>();
|
const [internalSorting, setInternalSorting] = React.useState<DataTableSort | undefined>();
|
||||||
@ -258,17 +266,56 @@ export function DataTable<TData, TValue>({
|
|||||||
// Render principal
|
// Render principal
|
||||||
return (
|
return (
|
||||||
<div className="transition-[max-height] duration-300 ease-in-out">
|
<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} />
|
<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">
|
<TableComp className="min-w-full sm:min-w-[820px] w-full">
|
||||||
{/* CABECERA */}
|
{/* CABECERA */}
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((hg) => (
|
{table.getHeaderGroups().map((hg) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
className="bg-background hover:bg-muted/50 text-xs sm:text-sm"
|
className="bg-background hover:bg-muted/50 text-xs sm:text-sm"
|
||||||
key={hg.id}
|
key={hg.id}
|
||||||
>
|
>
|
||||||
{hg.headers.map((h) => {
|
{hg.headers.map((h) => {
|
||||||
/*
|
/*
|
||||||
@ -289,9 +336,12 @@ export function DataTable<TData, TValue>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableHead
|
<TableHead
|
||||||
className={cn("whitespace-nowrap font-semibold", headerClassName)}
|
className={cn(
|
||||||
colSpan={h.colSpan}
|
"whitespace-nowrap font-semibold first:pl-4 last:pr-4 sm:first:pl-5 sm:last:pr-5",
|
||||||
key={h.id}
|
headerClassName
|
||||||
|
)}
|
||||||
|
colSpan={h.colSpan}
|
||||||
|
key={h.id}
|
||||||
>
|
>
|
||||||
{h.isPlaceholder
|
{h.isPlaceholder
|
||||||
? null
|
? null
|
||||||
@ -308,7 +358,7 @@ export function DataTable<TData, TValue>({
|
|||||||
{table.getRowModel().rows.length ? (
|
{table.getRowModel().rows.length ? (
|
||||||
table.getRowModel().rows.map((row, rowIndex) => (
|
table.getRowModel().rows.map((row, rowIndex) => (
|
||||||
<TableRow
|
<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"}
|
data-state={row.getIsSelected() && "selected"}
|
||||||
key={row.id}
|
key={row.id}
|
||||||
onClick={(e) => onRowClick?.(row.original, rowIndex, e)}
|
onClick={(e) => onRowClick?.(row.original, rowIndex, e)}
|
||||||
@ -339,7 +389,13 @@ export function DataTable<TData, TValue>({
|
|||||||
const cellClassName = cell.column.columnDef.meta?.cellClassName;
|
const cellClassName = cell.column.columnDef.meta?.cellClassName;
|
||||||
|
|
||||||
return (
|
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())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
);
|
);
|
||||||
@ -360,15 +416,16 @@ export function DataTable<TData, TValue>({
|
|||||||
|
|
||||||
{/* Paginación */}
|
{/* Paginación */}
|
||||||
{enablePagination && (
|
{enablePagination && (
|
||||||
<TableFooter className="bg-background">
|
<TableFooter className="bg-background">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={100}>
|
<TableCell className="px-4 py-4 sm:px-5" colSpan={100}>
|
||||||
<DataTablePagination table={table} />
|
<DataTablePagination table={table} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableFooter>
|
</TableFooter>
|
||||||
)}
|
)}
|
||||||
</TableComp>
|
</TableComp>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user