feat: add verifactu status management and enhance issued invoices UI components

This commit is contained in:
David Arranz 2026-07-30 19:20:00 +02:00
parent 19452c3187
commit 451a1013d0
11 changed files with 335 additions and 90 deletions

View File

@ -182,6 +182,7 @@ export class SequelizeIssuedInvoiceRepositoryV2
reference: { type: "root", column: "reference" },
description: { type: "root", column: "description" },
recipient_name: { type: "root", column: "customer_name" },
verifactu_status: { type: "association", association: "verifactu", column: "estado" },
},
sortableFields: ["recipient_name", "invoice_number", "invoice_date", "id", "created_at", "serie"],
fullTextTableAlias: "IssuedInvoiceModel",

View File

@ -292,6 +292,11 @@ export class IssuedInvoiceRepository
type: "root",
column: "recipient_name",
},
verifactu_status: {
type: "association",
association: "verifactu",
column: "estado",
},
},
sortableFields: [
"recipient_name",

View File

@ -1,12 +1,18 @@
import { getUserErrorMessage } from "@erp/core/client";
import { useDebounce } from "@erp/core/hooks";
import { useDataSource, useDebounce } from "@erp/core/hooks";
import { useQueries } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import type {
IssuedInvoiceList,
IssuedInvoiceStatusCounts,
ListIssuedInvoicesByCriteriaParams,
VerifactuRecordStatus,
} from "../../shared";
import {
EMPTY_ISSUED_INVOICE_STATUS_COUNTS,
getListIssuedInvoicesByCriteria,
} from "../../shared";
import { useIssuedInvoiceListQuery } from "../../shared/";
type VerifactuRecordListStatusFilter = "all" | VerifactuRecordStatus;
@ -17,9 +23,26 @@ const EMPTY_ISSUED_INVOICES_LIST: IssuedInvoiceList = {
perPage: 5,
totalPages: 0,
totalItems: 0,
statusCounts: EMPTY_ISSUED_INVOICE_STATUS_COUNTS,
};
const STATUS_COUNT_OPTIONS: ReadonlyArray<{
value: VerifactuRecordStatus;
countKey: keyof Omit<IssuedInvoiceStatusCounts, "all">;
}> = [
{ value: "Pendiente", countKey: "pendiente" },
{ value: "Correcto", countKey: "correcto" },
{ value: "Aceptado con errores", countKey: "aceptadoConErrores" },
{ value: "Incorrecto", countKey: "incorrecto" },
{ value: "Duplicado", countKey: "duplicado" },
{ value: "Anulado", countKey: "anulado" },
{ value: "Factura inexistente", countKey: "facturaInexistente" },
{ value: "No registrado", countKey: "rechazado" },
{ value: "Error servidor AEAT", countKey: "errorServidorAeat" },
];
export const useIssuedInvoiceListController = () => {
const dataSource = useDataSource();
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(5);
const [search, setSearch] = useState("");
@ -38,14 +61,50 @@ export const useIssuedInvoiceListController = () => {
filters:
verifactuStatusFilter === "all"
? []
: [{ field: "verifactu.status", operator: "EQUALS", value: verifactuStatusFilter }],
: [{ field: "verifactu_status", operator: "EQUALS", value: verifactuStatusFilter }],
}),
[debouncedSearch, pageIndex, pageSize, verifactuStatusFilter]
);
const query = useIssuedInvoiceListQuery({ criteria });
const statusCountQueries = useQueries({
queries: STATUS_COUNT_OPTIONS.map((statusOption) => ({
queryKey: ["issued_invoices", "status_count", debouncedSearch || "", statusOption.value],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const response = await getListIssuedInvoicesByCriteria(dataSource, {
signal,
criteria: {
q: debouncedSearch || "",
pageNumber: 0,
pageSize: 1,
filters: [
{
field: "verifactu_status",
operator: "EQUALS",
value: statusOption.value,
},
],
},
});
return response.total_items;
},
})),
});
const errorMessage = query.error ? getUserErrorMessage(query.error) : null;
const statusCounts = useMemo<IssuedInvoiceStatusCounts>(() => {
const counts = {
...EMPTY_ISSUED_INVOICE_STATUS_COUNTS,
all: query.data?.totalItems ?? 0,
};
STATUS_COUNT_OPTIONS.forEach((statusOption, index) => {
counts[statusOption.countKey] = statusCountQueries[index]?.data ?? 0;
});
return counts;
}, [query.data?.totalItems, statusCountQueries]);
const setStatusFilterValue = (value: string) => {
const nextValue = (value || "all") as VerifactuRecordListStatusFilter;
@ -85,7 +144,10 @@ export const useIssuedInvoiceListController = () => {
};
return {
data: query.data ?? EMPTY_ISSUED_INVOICES_LIST,
data: {
...(query.data ?? EMPTY_ISSUED_INVOICES_LIST),
statusCounts,
},
isLoading: query.isLoading,
isFetching: query.isFetching,

View File

@ -1,2 +1,3 @@
export * from "./issued-invoices-grid-empty";
export * from "./issued-invoices-status-segmented-filter";
export * from "./verifactu-status-badge";

View File

@ -1,17 +1,19 @@
import { Card, CardContent } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ChartNoAxesColumnIncreasingIcon } from "lucide-react";
export const IssuedInvoicesEmptyGrid = ({ className }: React.ComponentProps<"div">) => {
return (
<Card className={cn("w-full max-w-lg", className)}>
<CardContent>
<div className="rounded-md border border-dashed p-6 text-center">
<ChartNoAxesColumnIncreasingIcon className="text-muted-foreground mx-auto size-12" />
<p className="mt-2 text-sm font-medium">No data to show</p>
<p className="text-muted-foreground mt-1 text-sm">May take 15 minutes for data to load</p>
<div
className={cn(
"rounded border border-dashed border-border bg-muted/30 px-4 py-10 text-center",
className
)}
>
<ChartNoAxesColumnIncreasingIcon className="mx-auto size-12 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">Todavia no hay facturas emitidas para mostrar</p>
<p className="mt-1 text-sm text-muted-foreground">
Cuando emitas proformas aprobadas, apareceran aqui con su estado en Verifactu.
</p>
</div>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,183 @@
import { Badge, ToggleGroup, ToggleGroupItem } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
AlertTriangleIcon,
BanIcon,
BugIcon,
CheckCheckIcon,
CheckIcon,
Clock3Icon,
CopyIcon,
FileQuestionIcon,
XCircleIcon,
} from "lucide-react";
import type { ElementType } from "react";
import { useTranslation } from "../../../../i18n";
import type { IssuedInvoiceStatusCounts, VerifactuRecordStatus } from "../../../shared";
export type IssuedInvoiceStatusFilter = "all" | VerifactuRecordStatus;
export type IssuedInvoicesStatusSegmentedFilterProps = {
value: IssuedInvoiceStatusFilter;
onValueChange: (value: IssuedInvoiceStatusFilter) => void;
counts: IssuedInvoiceStatusCounts;
disabled?: boolean;
};
const ISSUED_INVOICE_STATUS_FILTER_OPTIONS = [
{
value: "all",
label: "Todas",
icon: null,
countKey: "all",
activeClassName:
"data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground",
},
{
value: "Pendiente",
label: "Pendiente",
icon: Clock3Icon,
countKey: "pendiente",
activeClassName:
"data-[state=on]:border-slate-300 data-[state=on]:bg-slate-100 data-[state=on]:text-slate-700",
},
{
value: "Correcto",
label: "Correcto",
icon: CheckCheckIcon,
countKey: "correcto",
activeClassName:
"data-[state=on]:border-blue-300 data-[state=on]:bg-blue-50 data-[state=on]:text-blue-700",
},
{
value: "Aceptado con errores",
label: "Aceptado con errores",
icon: CheckIcon,
countKey: "aceptadoConErrores",
activeClassName:
"data-[state=on]:border-amber-300 data-[state=on]:bg-amber-50 data-[state=on]:text-amber-700",
},
{
value: "Incorrecto",
label: "Incorrecto",
icon: XCircleIcon,
countKey: "incorrecto",
activeClassName:
"data-[state=on]:border-red-300 data-[state=on]:bg-red-50 data-[state=on]:text-red-700",
},
{
value: "Duplicado",
label: "Duplicado",
icon: CopyIcon,
countKey: "duplicado",
activeClassName:
"data-[state=on]:border-yellow-300 data-[state=on]:bg-yellow-50 data-[state=on]:text-yellow-700",
},
{
value: "Anulado",
label: "Anulado",
icon: BanIcon,
countKey: "anulado",
activeClassName:
"data-[state=on]:border-emerald-300 data-[state=on]:bg-emerald-50 data-[state=on]:text-emerald-700",
},
{
value: "Factura inexistente",
label: "Factura inexistente",
icon: FileQuestionIcon,
countKey: "facturaInexistente",
activeClassName:
"data-[state=on]:border-rose-300 data-[state=on]:bg-rose-50 data-[state=on]:text-rose-700",
},
{
value: "No registrado",
label: "No registrado",
icon: AlertTriangleIcon,
countKey: "rechazado",
activeClassName:
"data-[state=on]:border-orange-300 data-[state=on]:bg-orange-50 data-[state=on]:text-orange-700",
},
{
value: "Error servidor AEAT",
label: "Error servidor AEAT",
icon: BugIcon,
countKey: "errorServidorAeat",
activeClassName:
"data-[state=on]:border-red-300 data-[state=on]:bg-red-50 data-[state=on]:text-red-700",
},
] as const satisfies readonly {
value: IssuedInvoiceStatusFilter;
label: string;
icon: ElementType | null;
countKey: keyof IssuedInvoiceStatusCounts;
activeClassName: string;
}[];
const isIssuedInvoiceStatusFilter = (value: string): value is IssuedInvoiceStatusFilter => {
return ISSUED_INVOICE_STATUS_FILTER_OPTIONS.some((option) => option.value === value);
};
export const IssuedInvoicesStatusSegmentedFilter = ({
value,
onValueChange,
counts,
disabled = false,
}: IssuedInvoicesStatusSegmentedFilterProps) => {
const { t } = useTranslation();
const handleValueChange = (nextValue: string) => {
if (!(nextValue && isIssuedInvoiceStatusFilter(nextValue)) || nextValue === value) {
return;
}
onValueChange(nextValue);
};
return (
<ToggleGroup
aria-label={t("pages.issued_invoices.list.filters.status.label", "Filtrar por estado")}
className="flex w-full flex-wrap justify-start gap-1"
disabled={disabled}
onValueChange={handleValueChange}
size="sm"
spacing={1}
type="single"
value={value}
variant="outline"
>
{ISSUED_INVOICE_STATUS_FILTER_OPTIONS.map((option) => (
<ToggleGroupItem
className={cn(
"group h-auto min-w-fit rounded-full border-transparent px-3 py-2 text-xs shadow-none transition-colors sm:text-sm",
"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) => {
if (option.value === value) {
event.preventDefault();
}
}}
value={option.value}
>
<span className="flex items-center gap-2">
{option.icon ? <option.icon className="size-3.5" /> : null}
<span>{t(`catalog.issued_invoices.status.${option.value.toLowerCase()}.label`, option.label)}</span>
<Badge
className={cn(
"min-w-5 rounded-full px-1.5 text-[11px] font-semibold tabular-nums",
"bg-muted text-muted-foreground",
"group-data-[state=on]:bg-white/20 group-data-[state=on]:text-current"
)}
variant="secondary"
>
{counts[option.countKey]}
</Badge>
</span>
</ToggleGroupItem>
))}
</ToggleGroup>
);
};

View File

@ -1,24 +1,17 @@
import { ErrorState, PageHeader, SimpleSearchInput } from "@erp/core/components";
import { LogoVerifactu } from "@repo/rdx-ui/components";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { FilterIcon, PlusIcon } from "lucide-react";
import { Alert, AlertDescription, AlertTitle, Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
import { useIssuedInvoiceListPageController } from "../../controllers";
import { IssuedInvoicesGrid, useIssuedInvoicesGridColumns } from "../blocks";
import { IssuedInvoicesEmptyGrid } from "../components";
import {
IssuedInvoicesEmptyGrid,
IssuedInvoicesStatusSegmentedFilter,
} from "../components";
export const ListIssuedInvoicesPage = () => {
const { t } = useTranslation();
@ -96,11 +89,6 @@ export const ListIssuedInvoicesPage = () => {
<LogoVerifactu className="text-green-800 w-50" color="" />
</Alert>
{/* Search and filters */}
{listCtrl.data.totalItems === 0 && <IssuedInvoicesEmptyGrid className="mx-auto" />}
{listCtrl.data.totalItems !== 0 && (
<>
<div className="min-h-0 flex-1 overflow-auto">
<IssuedInvoicesGrid
columns={columns}
@ -115,58 +103,31 @@ export const ListIssuedInvoicesPage = () => {
pageSize={listCtrl.pageSize}
searching={isSearchPending}
topContent={
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<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>
<Select
onValueChange={(value) => listCtrl.setStatusFilter(value ?? "all")}
<IssuedInvoicesStatusSegmentedFilter
counts={listCtrl.data.statusCounts}
disabled={listCtrl.isLoading}
onValueChange={listCtrl.setStatusFilter}
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>
</>
)}
{listCtrl.data.totalItems === 0 ? <IssuedInvoicesEmptyGrid /> : null}
</div>
</div>
);

View File

@ -2,6 +2,7 @@ import { MoneyDTOHelper } from "@erp/core";
import { MoneyHelper } from "@repo/rdx-utils";
import type { ListIssuedInvoicesResponseDTO } from "../../../../common";
import { EMPTY_ISSUED_INVOICE_STATUS_COUNTS } from "../entities";
import type { IssuedInvoiceList, IssuedInvoiceListRow, IssuedInvoiceStatus } from "../entities";
import type { VerifactuRecordStatus } from "../entities/verifactu-record-status.entity";
@ -13,6 +14,7 @@ export const ListIssuedInvoicesAdapter = {
perPage: dto.per_page,
totalPages: dto.total_pages,
totalItems: dto.total_items,
statusCounts: EMPTY_ISSUED_INVOICE_STATUS_COUNTS,
};
},
};

View File

@ -1,4 +1,5 @@
export * from "./issued-invoice-list.entity";
export * from "./issued-invoice-list-row.entity";
export * from "./issued-invoice-status.entity";
export * from "./issued-invoice-status-counts.entity";
export * from "./verifactu-record-status.entity";

View File

@ -1,4 +1,5 @@
import type { IssuedInvoiceListRow } from "./issued-invoice-list-row.entity";
import type { IssuedInvoiceStatusCounts } from "./issued-invoice-status-counts.entity";
/**
* Interface que representa la respuesta paginada de una lista de proformas,
@ -11,4 +12,5 @@ export interface IssuedInvoiceList {
totalItems: number;
page: number;
perPage: number;
statusCounts: IssuedInvoiceStatusCounts;
}

View File

@ -0,0 +1,25 @@
export interface IssuedInvoiceStatusCounts {
all: number;
pendiente: number;
correcto: number;
aceptadoConErrores: number;
incorrecto: number;
duplicado: number;
anulado: number;
facturaInexistente: number;
rechazado: number;
errorServidorAeat: number;
}
export const EMPTY_ISSUED_INVOICE_STATUS_COUNTS: IssuedInvoiceStatusCounts = {
all: 0,
pendiente: 0,
correcto: 0,
aceptadoConErrores: 0,
incorrecto: 0,
duplicado: 0,
anulado: 0,
facturaInexistente: 0,
rechazado: 0,
errorServidorAeat: 0,
};