Facturas de cliente

This commit is contained in:
David Arranz 2025-11-23 11:46:35 +01:00
parent 6e0900afae
commit c1399cd67d
10 changed files with 41 additions and 265 deletions

View File

@ -1,3 +0,0 @@
export * from "../proformas/adapters/proforma-dto.adapter";
export * from "./invoice-resume-dto.adapter";

View File

@ -1,63 +0,0 @@
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
import type { InvoiceSummaryFormData } from "../schemas/invoice-resume.form.schema";
import type { CustomerInvoiceSummary } from "../schemas/invoices.api.schema";
/**
* Convierte el DTO completo de API a datos numéricos para el formulario.
*/
export const invoiceResumeDtoToFormAdapter = {
fromDto(dtos: CustomerInvoiceSummary[], context?: any) {
return dtos.map(
(dto) =>
({
...dto,
subtotal_amount: MoneyDTOHelper.toNumber(dto.subtotal_amount),
subtotal_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(dto.subtotal_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
dto.language_code
),
discount_percentage: PercentageDTOHelper.toNumber(dto.discount_percentage),
discount_percentage_fmt: PercentageDTOHelper.toNumericString(dto.discount_percentage),
discount_amount: MoneyDTOHelper.toNumber(dto.discount_amount),
discount_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(dto.discount_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
dto.language_code
),
taxable_amount: MoneyDTOHelper.toNumber(dto.taxable_amount),
taxable_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(dto.taxable_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
dto.language_code
),
taxes_amount: MoneyDTOHelper.toNumber(dto.taxes_amount),
taxes_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(dto.taxes_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
dto.language_code
),
total_amount: MoneyDTOHelper.toNumber(dto.total_amount),
total_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(dto.total_amount),
Number(dto.total_amount.scale || 2),
dto.currency_code,
dto.language_code
),
//taxes: dto.taxes,
}) as unknown as InvoiceSummaryFormData
);
},
};

View File

@ -18,9 +18,8 @@ export const useInvoiceQuery = (invoiceId?: string, options?: InvoiceQueryOption
queryKey: CUSTOMER_INVOICE_QUERY_KEY(invoiceId ?? "unknown"), queryKey: CUSTOMER_INVOICE_QUERY_KEY(invoiceId ?? "unknown"),
queryFn: async (context) => { queryFn: async (context) => {
const { signal } = context; const { signal } = context;
if (!invoiceId) {
if (!invoiceId) throw new Error("invoiceId is required"); if (!invoiceId) throw new Error("invoiceId is required");
}
return await dataSource.getOne<Proforma>("customer-invoices", invoiceId, { return await dataSource.getOne<Proforma>("customer-invoices", invoiceId, {
signal, signal,
}); });

View File

@ -49,5 +49,6 @@ export function useDownloadInvoicePDFController() {
return { return {
download, // (id, reference) => void download, // (id, reference) => void
isLoading: isFetching, isLoading: isFetching,
loadingId: pending?.id,
}; };
} }

View File

@ -19,6 +19,10 @@ export function useIssuedInvoiceListPageController() {
return { return {
listCtrl, listCtrl,
downloadPDFCtrl,
handleDownloadPDF, handleDownloadPDF,
pdfDownloadingId: downloadPDFCtrl.loadingId,
isPDFDownloading: downloadPDFCtrl.isLoading,
}; };
} }

View File

@ -7,12 +7,13 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
Spinner,
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { DownloadIcon, MailIcon, MoreVerticalIcon, QrCodeIcon } from "lucide-react"; import { DownloadIcon, FileDownIcon, MailIcon, MoreVerticalIcon, QrCodeIcon } from "lucide-react";
import * as React from "react"; import * as React from "react";
import QrCode from "react-qr-code"; import QrCode from "react-qr-code";
@ -21,7 +22,8 @@ import type { IssuedInvoiceSummaryData } from "../../../../types";
type GridActionHandlers = { type GridActionHandlers = {
onDownloadPdf?: (issuedInvoice: IssuedInvoiceSummaryData) => void; onDownloadPdf?: (issuedInvoice: IssuedInvoiceSummaryData) => void;
onSendEmail?: (issuedInvoice: IssuedInvoiceSummaryData) => void; pdfDownloadingId?: string | null;
isPdfDownloading?: boolean;
}; };
export function useIssuedInvoicesGridColumns( export function useIssuedInvoicesGridColumns(
@ -321,28 +323,37 @@ export function useIssuedInvoicesGridColumns(
minSize: 64, minSize: 64,
cell: ({ row }) => { cell: ({ row }) => {
const issuedInvoice = row.original; const issuedInvoice = row.original;
const isCompleted = issuedInvoice.verifactu.status === "Correcto";
const isPDFLoading =
actionHandlers.isPdfDownloading && actionHandlers.pdfDownloadingId === issuedInvoice.id;
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation(); const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
return ( return (
<ButtonGroup> <ButtonGroup>
{/* Descargar en PDF */} {/* Descargar en PDF */}
{/* Descargar en PDF */}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
aria-label={t("common.download_pdf")} className="size-8"
className="cursor-pointer text-muted-foreground hover:text-primary" disabled={isPDFLoading || !isCompleted}
onClick={(e) => { onClick={(e) => {
stop(e); stop(e);
actionHandlers.onDownloadPdf?.(issuedInvoice); isCompleted ? actionHandlers.onDownloadPdf?.(issuedInvoice) : null;
}} }}
size="icon-sm" size="icon"
type="button"
variant="ghost" variant="ghost"
> >
<DownloadIcon aria-hidden="true" className="size-4" /> {isPDFLoading ? (
<Spinner className="size-4" />
) : (
<FileDownIcon className="size-4" />
)}
<span className="sr-only">Descargar PDF</span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{t("common.download_pdf")}</TooltipContent> <TooltipContent>Descargar PDF</TooltipContent>
</Tooltip> </Tooltip>
{/* Menú demás acciones */} {/* Menú demás acciones */}
@ -370,10 +381,7 @@ export function useIssuedInvoicesGridColumns(
<DownloadIcon className="mr-2 size-4" /> <DownloadIcon className="mr-2 size-4" />
{t("common.download_pdf")} {t("common.download_pdf")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem className="cursor-pointer" onClick={stop}>
className="cursor-pointer"
onClick={() => actionHandlers.onSendEmail?.(issuedInvoice)}
>
<MailIcon className="mr-2 size-4" /> <MailIcon className="mr-2 size-4" />
{t("common.send_email")} {t("common.send_email")}
</DropdownMenuItem>{" "} </DropdownMenuItem>{" "}

View File

@ -22,11 +22,19 @@ import { IssuedInvoicesGrid, useIssuedInvoicesGridColumns } from "../blocks";
export const IssuedInvoiceListPage = () => { export const IssuedInvoiceListPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { listCtrl, handleDownloadPDF } = useIssuedInvoiceListPageController(); const {
listCtrl,
handleDownloadPDF,
isPDFDownloading: isPdfDownloading,
pdfDownloadingId,
} = useIssuedInvoiceListPageController();
const columns = useIssuedInvoicesGridColumns({ const columns = useIssuedInvoicesGridColumns({
onDownloadPdf: handleDownloadPDF, onDownloadPdf: handleDownloadPDF,
onSendEmail: () => null, pdfDownloadingId,
isPdfDownloading,
//onSendEmail: () => null,
}); });
// Hook con Sheet de shadcn // Hook con Sheet de shadcn

View File

@ -1,178 +0,0 @@
import { SimpleSearchInput } from "@erp/core/components";
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import { useCallback, useState } from "react";
import { useNavigate } from "react-router-dom";
import { usePinnedPreviewSheet } from "../../hooks";
import { useTranslation } from "../../i18n";
import type { InvoiceSummaryFormData, InvoicesPageFormData } from "../../schemas";
export type InvoiceUpdateCompProps = {
invoicesPage: InvoicesPageFormData;
loading?: boolean;
pageIndex: number;
pageSize: number;
onPageChange?: (pageNumber: number) => void;
onPageSizeChange?: (pageSize: number) => void;
searchValue: string;
onSearchChange: (value: string) => void;
onRowClick?: (
row: InvoiceSummaryFormData,
index: number,
event: React.MouseEvent<HTMLTableRowElement>
) => void;
};
// Create new GridExample component
export const InvoicesListGrid = ({
invoicesPage,
loading,
pageIndex,
pageSize,
onPageChange,
onPageSizeChange,
searchValue,
onSearchChange,
onRowClick,
}: InvoiceUpdateCompProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { items, total_items } = invoicesPage;
// Hook con Sheet de shadcn
const preview = usePinnedPreviewSheet<InvoiceSummaryFormData>({
persistKey: "invoice-preview-pin",
widthClass: "w-[500px]",
});
const [statusFilter, setStatusFilter] = useState("todas");
const columns = useProformasGridColumns({
onEdit: (invoice) => navigate(`/customer-invoices/${invoice.id}/edit`),
onDuplicate: (invoice) => null, //duplicateInvoice(inv.id),
onDownloadPdf: (invoice) => null, //downloadInvoicePdf(inv.id),
onSendEmail: (invoice) => null, //sendInvoiceEmail(inv.id),
onDelete: (invoice) => null, //confirmDelete(inv.id),
});
// Navegación accesible (click o teclado)
const goToRow = useCallback(
(id: string, newTab = false) => {
const url = `/customer-invoices/${id}/edit`;
newTab ? window.open(url, "_blank", "noopener,noreferrer") : navigate(url);
},
[navigate]
);
const onRowClicked = useCallback(
(e: RowClickedEvent<any>) => {
if (!e.data) return;
const newTab = e.event instanceof MouseEvent && (e.event.metaKey || e.event.ctrlKey);
goToRow(e.data.id, newTab);
},
[goToRow]
);
const onCellKeyDown = useCallback(
(e: CellKeyDownEvent<any>) => {
if (!e.data) return;
const ev = e.event;
if (!(ev && ev instanceof KeyboardEvent)) return;
const key = ev.key;
if (key === "Enter" || key === " ") {
ev.preventDefault();
goToRow(e.data.id);
}
if ((ev.ctrlKey || ev.metaKey) && key === "Enter") {
ev.preventDefault();
goToRow(e.data.id, true);
}
},
[goToRow]
);
const handleRowClick = useCallback(
(invoice: InvoiceSummaryFormData, _i: number, e: React.MouseEvent) => {
const url = `/customer-invoices/${invoice.id}/edit`;
if (e.metaKey || e.ctrlKey) {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
preview.open(invoice);
},
[preview]
);
if (loading) {
return (
<div className="flex flex-col gap-4">
<SkeletonDataTable
columns={columns.length}
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
rows={Math.max(6, pageSize)}
showFooter
/>
</div>
);
}
// Render principal
return (
<div className="flex flex-col gap-4">
{/* Barra de filtros */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<SimpleSearchInput loading={loading} onSearchChange={onSearchChange} />
{/*<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-48 bg-white border-gray-200 shadow-sm">
<FilterIcon className="mr-2 h-4 w-4" />
<SelectValue placeholder="Estado" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todas">Todas</SelectItem>
<SelectItem value="pagada">Pagadas</SelectItem>
<SelectItem value="pendiente">Pendientes</SelectItem>
<SelectItem value="vencida">Vencidas</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" className="border-blue-200 text-blue-600 hover:bg-blue-50 bg-transparent">
<FileDownIcon className="mr-2 h-4 w-4" />
Exportar
</Button>*/}
</div>
<div className="relative flex">
<div className={preview.isPinned ? "flex-1 mr-[500px]" : "flex-1"}>
<DataTable
columns={columns}
data={items}
enablePagination
enableRowSelection
manualPagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onRowClick={handleRowClick}
pageIndex={pageIndex}
pageSize={pageSize}
readOnly
totalItems={total_items}
/>
</div>
{/*<preview.Preview>
{({ item, isPinned, close, togglePin }) => (
<InvoicePreviewPanel
invoice={item}
isPinned={isPinned}
onClose={close}
onTogglePin={togglePin}
/>
)}
</preview.Preview>*/}
</div>
</div>
);
};

View File

@ -6,9 +6,9 @@ import { PlusIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { invoiceResumeDtoToFormAdapter } from "../../adapters/invoice-resume-dto.adapter";
import { useInvoicesQuery } from "../../hooks"; import { useInvoicesQuery } from "../../hooks";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { issuedInvoiceResumeDtoToFormAdapter } from "../../issued-invoices/adapters/issued-invoice-resume-dto.adapter";
export const InvoiceListPage = () => { export const InvoiceListPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -37,7 +37,7 @@ export const InvoiceListPage = () => {
if (!data) return undefined; if (!data) return undefined;
return { return {
...data, ...data,
items: invoiceResumeDtoToFormAdapter.fromDto(data.items), items: issuedInvoiceResumeDtoToFormAdapter.fromDto(data.items),
}; };
}, [data]); }, [data]);

View File

@ -1,4 +1,4 @@
export * from "../adapters/invoice-resume-dto.adapter"; export * from "../issued-invoices/adapters/issued-invoice-resume-dto.adapter";
export * from "../proformas/adapters/proforma-dto.adapter"; export * from "../proformas/adapters/proforma-dto.adapter";
export * from "./invoice.form.schema"; export * from "./invoice.form.schema";