Facturas de cliente

This commit is contained in:
David Arranz 2025-11-16 22:11:46 +01:00
parent f19ab6022b
commit 99f8b5fb8e
25 changed files with 839 additions and 130 deletions

View File

@ -45,7 +45,7 @@
"title": "Customer proformas",
"description": "List all customer proformas",
"grid_columns": {
"invoice_number": "Inv. number",
"invoice_number": "#",
"series": "Serie",
"reference": "Reference",
"status": "Status",
@ -82,6 +82,33 @@
"title": "View customer proforma",
"description": "View the details of the selected customer proforma"
}
},
"issued_invoices": {
"title": "Customer Invoices",
"description": "Manage your customer invoices",
"list": {
"title": "Customer invoices",
"description": "List all customer invoices",
"grid_columns": {
"invoice_number": "Inv. number",
"series": "Serie",
"reference": "Reference",
"invoice_date": "Invoice date",
"operation_date": "Operation date",
"recipient": "Customer",
"recipient_tin": "TIN",
"recipient_name": "Customer name",
"recipient_street": "Street",
"recipient_city": "City",
"recipient_province": "Province",
"recipient_postal_code": "Postal code",
"recipient_country": "Country",
"subtotal_amount": "Subtotal",
"discount_amount": "Discount",
"taxes_amount": "Taxes",
"total_amount": "Total"
}
}
}
},
"form_groups": {

View File

@ -44,7 +44,7 @@
"title": "Proformas",
"description": "Lista todas las proformas",
"grid_columns": {
"invoice_number": "Nº proforma",
"invoice_number": "#",
"series": "Serie",
"reference": "Reference",
"status": "Estado",
@ -81,6 +81,33 @@
"title": "Ver proforma",
"description": "Ver los detalles de la proforma seleccionada"
}
},
"issued_invoices": {
"title": "Facturas de cliente",
"description": "Gestiona tus facturas de cliente",
"list": {
"title": "Facturas de cliente",
"description": "Lista todas las facturas de cliente",
"grid_columns": {
"invoice_number": "Nº factura",
"series": "Serie",
"reference": "Reference",
"invoice_date": "Fecha de proforma",
"operation_date": "Fecha de operación",
"recipient": "Cliente",
"recipient_tin": "NIF/CIF",
"recipient_name": "Cliente",
"recipient_street": "Dirección",
"recipient_city": "Ciudad",
"recipient_province": "Provincia",
"recipient_postal_code": "Código postal",
"recipient_country": "País",
"subtotal_amount": "Subtotal",
"discount_amount": "Descuentos",
"taxes_amount": "Impuestos",
"total_amount": "Importe total"
}
}
}
},

View File

@ -2,6 +2,8 @@ import type { ModuleClientParams } from "@erp/core/client";
import { lazy } from "react";
import { Outlet, type RouteObject } from "react-router-dom";
import { IssuedInvoiceListPage } from "./issued-invoices/pages/list/issued-invoice-list-page";
// Lazy load components
const InvoicesLayout = lazy(() =>
import("./shared/ui").then((m) => ({ default: m.CustomerInvoicesLayout }))
@ -34,7 +36,7 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
{ path: ":id/edit", element: <InvoiceUpdatePage /> },
],
},
/*{
{
path: "customer-invoices",
element: (
<InvoicesLayout>
@ -42,9 +44,9 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
</InvoicesLayout>
),
children: [
//{ path: "", index: true, element: <InvoiceListPage /> }, // index
//{ path: "list", element: <InvoiceListPage /> },
//
{ path: "", index: true, element: <IssuedInvoiceListPage /> }, // index
{ path: "list", element: <IssuedInvoiceListPage /> },
/*
{ path: "create", element: <CustomerInvoicesList /> },
{ path: ":id", element: <CustomerInvoicesList /> },
{ path: ":id/edit", element: <CustomerInvoicesList /> },
@ -55,7 +57,8 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
{ path: ":id/download", element: <CustomerInvoicesList /> },
{ path: ":id/duplicate", element: <CustomerInvoicesList /> },
{ path: ":id/preview", element: <CustomerInvoicesList /> },
*/
],
},*/
},
];
};

View File

@ -0,0 +1 @@
export * from "./issued-invoice-summary-dto.adapter";

View File

@ -1,16 +1,16 @@
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
import type { IssuedInvoicesummaryPage } from "./issued-invoice.api.schema";
import type {
IssuedInvoicesummaryData,
IssuedInvoicesummaryPageData,
} from "./issued-invoice-resume.form.schema";
IssuedInvoiceSummaryData,
IssuedInvoiceSummaryPage,
IssuedInvoiceSummaryPageData,
} from "../schema";
/**
* Convierte el DTO completo de API a datos numéricos para el formulario.
*/
export const IssueInvoiceResumeDtoAdapter = {
fromDto(pageDto: IssuedInvoicesummaryPage, context?: unknown): IssuedInvoicesummaryPageData {
export const IssuedInvoiceSummaryDtoAdapter = {
fromDto(pageDto: IssuedInvoiceSummaryPage, context?: unknown): IssuedInvoiceSummaryPageData {
return {
...pageDto,
items: pageDto.items.map(
@ -64,7 +64,7 @@ export const IssueInvoiceResumeDtoAdapter = {
),
//taxes: dto.taxes,
}) as unknown as IssuedInvoicesummaryData
}) as unknown as IssuedInvoiceSummaryData
),
};
},

View File

@ -1 +1 @@
export * from "./hooks";
export * from "./pages";

View File

@ -1,26 +0,0 @@
import {
GetIssuedInvoiceByIdResponseSchema,
ListIssuedInvoicesResponseSchema,
} from "@erp/customer-invoices/common";
import type { ArrayElement } from "@repo/rdx-utils";
import type { z } from "zod/v4";
// IssuedInvoices
export const IssuedInvoiceschema = GetIssuedInvoiceByIdResponseSchema.omit({
metadata: true,
});
export type IssueInvoice = z.infer<typeof IssuedInvoiceschema>;
export type IssueInvoiceRecipient = IssueInvoice["recipient"];
export type IssueInvoiceItem = ArrayElement<IssueInvoice["items"]>;
// Resultado de consulta con criteria (paginado, etc.)
export const IssuedInvoicesummaryPageSchema = ListIssuedInvoicesResponseSchema.omit({
metadata: true,
});
export type IssuedInvoicesummaryPage = z.infer<typeof IssuedInvoicesummaryPageSchema>;
export type IssuedInvoicesummary = Omit<
ArrayElement<IssuedInvoicesummaryPage["items"]>,
"metadata"
>;

View File

@ -0,0 +1 @@
export * from "./list";

View File

@ -0,0 +1,2 @@
export * from "./use-issued-invoices-grid-columns";
export * from "./use-issued-invoices-list";

View File

@ -0,0 +1,348 @@
import { formatDate } from "@erp/core/client";
import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import {
Button,
ButtonGroup,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import { DownloadIcon, MailIcon, MoreVerticalIcon } from "lucide-react";
import * as React from "react";
import { useTranslation } from "../../../../i18n";
import type { IssuedInvoiceSummaryData } from "../../../schema";
type GridActionHandlers = {
onDownloadPdf?: (proforma: IssuedInvoiceSummaryData) => void;
onSendEmail?: (proforma: IssuedInvoiceSummaryData) => void;
};
export function useIssuedInvoicesGridColumns(
actionHandlers: GridActionHandlers = {}
): ColumnDef<IssuedInvoiceSummaryData, unknown>[] {
const { t } = useTranslation();
const { onDownloadPdf, onSendEmail } = actionHandlers;
return React.useMemo<ColumnDef<IssuedInvoiceSummaryData>[]>(
() => [
// Nº
{
accessorKey: "invoice_number",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums justify-end"
column={column}
title={t("pages.issued_invoices.list.grid_columns.invoice_number")}
/>
),
cell: ({ row }) => (
<div className="text-right tabular-nums">{row.original.invoice_number}</div>
),
enableHiding: false,
enableSorting: false,
maxSize: 48,
size: 48,
minSize: 48,
meta: {
title: t("pages.issued_invoices.list.grid_columns.invoice_number"),
},
},
{
id: "recipient",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.issued_invoices.list.grid_columns.recipient")}
/>
),
accessorFn: (row) => row.recipient.name, // para ordenar/buscar por nombre
enableHiding: false,
size: 140,
minSize: 120,
cell: ({ row }) => {
const c = row.original.recipient;
return (
<div className="flex items-start gap-1">
<div className="min-w-0 grid gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold truncate text-primary">{c.name}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{c.tin && <span className="font-base truncate">{c.tin}</span>}
</div>
</div>
</div>
);
},
meta: {
title: t("pages.issued_invoices.list.grid_columns.recipient"),
},
},
// Serie
{
accessorKey: "series",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.issued_invoices.list.grid_columns.series")}
/>
),
cell: ({ row }) => <div className="font-normal text-left">{row.original.series}</div>,
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.issued_invoices.list.grid_columns.series"),
},
},
// Referencia
{
accessorKey: "reference",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.issued_invoices.list.grid_columns.reference")}
/>
),
cell: ({ row }) => <div className="font-medium text-left">{row.original.reference}</div>,
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.issued_invoices.list.grid_columns.reference"),
},
},
// Fecha factura
{
accessorKey: "invoice_date",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left tabular-nums"
column={column}
title={t("pages.issued_invoices.list.grid_columns.invoice_date")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.original.invoice_date)}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
meta: {
title: t("pages.issued_invoices.list.grid_columns.invoice_date"),
},
},
// Fecha operación
{
accessorKey: "operation_date",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left tabular-nums"
column={column}
title={t("pages.issued_invoices.list.grid_columns.operation_date")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.original.operation_date)}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
meta: {
title: t("pages.issued_invoices.list.grid_columns.operation_date"),
},
},
// Subtotal amount
{
accessorKey: "subtotal_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.issued_invoices.list.grid_columns.subtotal_amount")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">
{row.original.subtotal_amount_fmt}
</div>
),
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.issued_invoices.list.grid_columns.subtotal_amount"),
},
},
// Discount amount
{
accessorKey: "discount_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.issued_invoices.list.grid_columns.discount_amount")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">
{row.original.discount_amount_fmt}
</div>
),
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.issued_invoices.list.grid_columns.discount_amount"),
},
},
// Taxes amount
{
accessorKey: "taxes_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.issued_invoices.list.grid_columns.taxes_amount")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">{row.original.taxes_amount_fmt}</div>
),
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.issued_invoices.list.grid_columns.taxes_amount"),
},
},
// Total amount
{
accessorKey: "total_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.issued_invoices.list.grid_columns.total_amount")}
/>
),
cell: ({ row }) => (
<div className="font-semibold text-right tabular-nums">
{row.original.total_amount_fmt}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
meta: {
title: t("pages.issued_invoices.list.grid_columns.total_amount"),
},
},
// ─────────────────────────────
// Acciones
// ─────────────────────────────
{
id: "actions",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("common.actions")}
/>
),
enableSorting: false,
enableHiding: false,
size: 64,
minSize: 64,
cell: ({ row }) => {
const proforma = row.original;
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
return (
<ButtonGroup>
{/* Descargar en PDF */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.download_pdf")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={(e) => {
e.stopPropagation();
onDownloadPdf?.(proforma);
}}
size="icon-sm"
type="button"
variant="ghost"
>
<DownloadIcon aria-hidden="true" className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.download_pdf")}</TooltipContent>
</Tooltip>
{/* Menú demás acciones */}
{/** biome-ignore lint/suspicious/noSelfCompare: <Desactivado por ahora> */}
{false !== false && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={t("common.more_actions")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={stop}
size="sm"
type="button"
variant="ghost"
>
<MoreVerticalIcon aria-hidden="true" className="size-4" />
<span className="sr-only">{t("common.more_actions")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onDownloadPdf?.(proforma)}
>
<DownloadIcon className="mr-2 size-4" />
{t("common.download_pdf")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onSendEmail?.(proforma)}
>
<MailIcon className="mr-2 size-4" />
{t("common.send_email")}
</DropdownMenuItem>{" "}
</DropdownMenuContent>
</DropdownMenu>
)}
</ButtonGroup>
);
},
meta: {
title: t("common.actions"),
},
},
],
[t, onDownloadPdf, onSendEmail]
);
}

View File

@ -0,0 +1,53 @@
// src/modules/issued-invoices/hooks/use-proformas-list.ts
import type { CriteriaDTO } from "@erp/core";
import { useDebounce } from "@repo/rdx-ui/components";
import { useMemo, useState } from "react";
import { IssuedInvoiceSummaryDtoAdapter } from "../../../adapters/issued-invoice-summary-dto.adapter";
import { useIssuedInvoicesQuery } from "../../../hooks";
export const useIssuedInvoicesList = () => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("all");
const debouncedQ = useDebounce(search, 300);
const criteria = useMemo<CriteriaDTO>(() => {
const baseFilters =
status !== "all" ? [{ field: "status", operator: "CONTAINS", value: status }] : [];
return {
q: debouncedQ || "",
pageSize,
pageNumber: pageIndex,
order: "desc",
orderBy: "invoice_date",
filters: baseFilters,
};
}, [pageSize, pageIndex, debouncedQ, status]);
const query = useIssuedInvoicesQuery({ criteria });
const data = useMemo(
() => (query.data ? IssuedInvoiceSummaryDtoAdapter.fromDto(query.data) : undefined),
[query.data]
);
const setSearchValue = (value: string) => setSearch(value.trim().replace(/\s+/g, " "));
const setStatusFilter = (newStatus: string) => setStatus(newStatus);
return {
...query,
data,
pageIndex,
pageSize,
search,
setPageIndex,
setPageSize,
setSearchValue,
setStatusFilter,
};
};

View File

@ -0,0 +1 @@
export * from "./issued-invoice-list-page";

View File

@ -0,0 +1,62 @@
import { PageHeader } from "@erp/core/components";
import { ErrorAlert } from "@erp/customers/components";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useIssuedInvoicesList } from "./hooks";
import { IssuedInvoicesGrid } from "./ui/proformas-grid";
export const IssuedInvoiceListPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const list = useIssuedInvoicesList();
if (list.isError || !list.data) {
return (
<AppContent>
<ErrorAlert
message={(list.error as Error)?.message || "Error al cargar el listado"}
title={t("pages.issued_invoices.list.loadErrorTitle")}
/>
<BackHistoryButton />
</AppContent>
);
}
return (
<>
<AppHeader>
<PageHeader
description={t("pages.issued_invoices.list.description")}
rightSlot={
<Button
aria-label={t("pages.issued_invoices.create.title")}
onClick={() => navigate("/issued-invoices/create")}
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.issued_invoices.create.title")}
</Button>
}
title={t("pages.issued_invoices.list.title")}
/>
</AppHeader>
<AppContent>
<IssuedInvoicesGrid
data={list.data}
loading={list.isLoading}
onPageChange={list.setPageIndex}
onPageSizeChange={list.setPageSize}
onSearchChange={list.setSearchValue}
onStatusFilterChange={list.setStatusFilter}
pageIndex={list.pageIndex}
pageSize={list.pageSize}
searchValue={list.search}
/>
</AppContent>
</>
);
};

View File

@ -0,0 +1,2 @@
export * from "./issued-invoice-status-badge";
export * from "./issued-invoices-grid";

View File

@ -0,0 +1,68 @@
import { Badge } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { forwardRef } from "react";
import { useTranslation } from "../../../../i18n";
export type IssuedInvoiceStatus = "draft" | "sent" | "approved" | "rejected" | "issued";
export type IssuedInvoiceStatusBadgeProps = {
status: string | IssuedInvoiceStatus; // permitir cualquier valor
dotVisible?: boolean;
className?: string;
};
const statusColorConfig: Record<IssuedInvoiceStatus, { badge: string; dot: string }> = {
draft: {
badge:
"bg-gray-500/10 dark:bg-gray-500/20 hover:bg-gray-500/10 text-gray-600 border-gray-400/60",
dot: "bg-gray-500",
},
sent: {
badge:
"bg-amber-500/10 dark:bg-amber-500/20 hover:bg-amber-500/10 text-amber-500 border-amber-600/60",
dot: "bg-amber-500",
},
approved: {
badge:
"bg-emerald-500/10 dark:bg-emerald-500/20 hover:bg-emerald-500/10 text-emerald-500 border-emerald-600/60",
dot: "bg-emerald-500",
},
rejected: {
badge: "bg-red-500/10 dark:bg-red-500/20 hover:bg-red-500/10 text-red-500 border-red-600/60",
dot: "bg-red-500",
},
issued: {
badge:
"bg-blue-600/10 dark:bg-blue-600/20 hover:bg-blue-600/10 text-blue-500 border-blue-600/60",
dot: "bg-blue-500",
},
};
export const IssuedInvoiceStatusBadge = forwardRef<HTMLDivElement, IssuedInvoiceStatusBadgeProps>(
({ status, dotVisible, className, ...props }, ref) => {
const { t } = useTranslation();
const normalizedStatus = status.toLowerCase() as IssuedInvoiceStatus;
const config = statusColorConfig[normalizedStatus];
const commonClassName =
"transition-colors duration-200 cursor-pointer shadow-none rounded-full";
if (!config) {
return (
<Badge className={cn(commonClassName, className)} ref={ref} {...props}>
{status}
</Badge>
);
}
return (
<Badge className={cn(commonClassName, config.badge, className)} {...props}>
{dotVisible && <div className={cn("h-1.5 w-1.5 rounded-full mr-2", config.dot)} />}
{t(`catalog.proformas.status.${normalizedStatus}`, { defaultValue: status })}
</Badge>
);
}
);
IssuedInvoiceStatusBadge.displayName = "IssuedInvoiceStatusBadge";

View File

@ -0,0 +1,105 @@
import { SimpleSearchInput } from "@erp/core/components";
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { FilterIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
import type { IssuedInvoiceSummaryPageData } from "../../../schema/issued-invoice-summary.web.schema";
import { useIssuedInvoicesGridColumns } from "../hooks";
interface IssuedInvoicesGridProps {
data: IssuedInvoiceSummaryPageData;
loading?: boolean;
pageIndex: number;
pageSize: number;
searchValue: string;
onSearchChange: (v: string) => void;
onPageChange: (p: number) => void;
onPageSizeChange: (s: number) => void;
onRowClick?: (id: string) => void;
onExportClick?: () => void;
onStatusFilterChange?: (newStatus: string) => void;
}
export const IssuedInvoicesGrid = ({
data,
loading,
pageIndex,
pageSize,
searchValue,
onSearchChange,
onPageChange,
onPageSizeChange,
onRowClick,
onExportClick,
onStatusFilterChange,
}: IssuedInvoicesGridProps) => {
const navigate = useNavigate();
const { t } = useTranslation();
const { items, total_items } = data;
const columns = useIssuedInvoicesGridColumns({
onEdit: (proforma) => navigate(`/issued-invoices/${proforma.id}/edit`),
onDuplicate: (proforma) => null, //duplicateInvoice(inv.id),
onDownloadPdf: (proforma) => null, //downloadInvoicePdf(inv.id),
onSendEmail: (proforma) => null, //sendInvoiceEmail(inv.id),
onDelete: (proforma) => null, //confirmDelete(inv.id),
});
if (loading)
return (
<SkeletonDataTable
columns={columns.length}
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
rows={Math.max(6, pageSize)}
showFooter
/>
);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row gap-4">
<SimpleSearchInput loading={loading} onSearchChange={onSearchChange} />
<Select defaultValue="all" onValueChange={onStatusFilterChange}>
<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.proformas.status.all")}</SelectItem>
<SelectItem value="draft">{t("catalog.proformas.status.draft")}</SelectItem>
<SelectItem value="sent">{t("catalog.proformas.status.sent")}</SelectItem>
<SelectItem value="approved">{t("catalog.proformas.status.approved")}</SelectItem>
<SelectItem value="rejected">{t("catalog.proformas.status.rejected")}</SelectItem>
<SelectItem value="issued">{t("catalog.proformas.status.issued")}</SelectItem>
</SelectContent>
</Select>
</div>
<DataTable
columns={columns}
columnVisibility={{
subtotal_amount_fmt: false,
discount_amount_fmt: false,
taxes_amount_fmt: false,
}}
data={items}
enablePagination
manualPagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onRowClick={(row, _index) => onRowClick?.(row.id)}
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={total_items}
/>
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from "./issued-invoice.api.schema";
export * from "./issued-invoice-summary.web.schema";

View File

@ -1,6 +1,6 @@
import type { IssuedInvoicesummary, IssuedInvoicesummaryPage } from "./issued-invoice.api.schema";
import type { IssuedInvoiceSummary, IssuedInvoiceSummaryPage } from "./issued-invoice.api.schema";
export type IssuedInvoicesummaryData = IssuedInvoicesummary & {
export type IssuedInvoiceSummaryData = IssuedInvoiceSummary & {
subtotal_amount_fmt: string;
subtotal_amount: number;
@ -13,13 +13,13 @@ export type IssuedInvoicesummaryData = IssuedInvoicesummary & {
taxable_amount_fmt: string;
taxable_amount: number;
taxes_amoun_fmt: string;
taxes_amount_fmt: string;
taxes_amount: number;
total_amount_fmt: string;
total_amount: number;
};
export type IssuedInvoicesummaryPageData = IssuedInvoicesummaryPage & {
items: IssuedInvoicesummary[];
export type IssuedInvoiceSummaryPageData = IssuedInvoiceSummaryPage & {
items: IssuedInvoiceSummaryData[];
};

View File

@ -0,0 +1,22 @@
import {
GetIssuedInvoiceByIdResponseSchema,
type ListIssuedInvoicesResponseDTO,
} from "@erp/customer-invoices/common";
import type { ArrayElement } from "@repo/rdx-utils";
import type { z } from "zod/v4";
// IssuedInvoices
export const IssuedInvoiceSchema = GetIssuedInvoiceByIdResponseSchema.omit({
metadata: true,
});
export type IssuedInvoice = z.infer<typeof IssuedInvoiceSchema>;
export type IssuedInvoiceRecipient = IssuedInvoice["recipient"];
export type IssuedInvoiceItem = ArrayElement<IssuedInvoice["items"]>;
// Resultado de consulta con criteria (paginado, etc.)
export type IssuedInvoiceSummaryPage = Omit<ListIssuedInvoicesResponseDTO, "metadata">;
export type IssuedInvoiceSummary = Omit<
ArrayElement<IssuedInvoiceSummaryPage["items"]>,
"metadata"
>;

View File

@ -48,18 +48,18 @@ export function useProformasGridColumns(
accessorKey: "invoice_number",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
className="text-right tabular-nums justify-end"
column={column}
title={t("pages.proformas.list.grid_columns.invoice_number")}
/>
),
cell: ({ row }) => (
<div className="font-semibold text-left text-primary">{row.original.invoice_number}</div>
<div className="text-right tabular-nums">{row.original.invoice_number}</div>
),
enableHiding: false,
enableSorting: false,
size: 160,
minSize: 120,
maxSize: 48,
size: 48,
minSize: 48,
meta: {
title: t("pages.proformas.list.grid_columns.invoice_number"),
},
@ -74,10 +74,10 @@ export function useProformasGridColumns(
title={t("pages.proformas.list.grid_columns.status")}
/>
),
cell: ({ row }) => <ProformaStatusBadge status={row.original.status} />,
cell: ({ row }) => <ProformaStatusBadge className="my-0.5" status={row.original.status} />,
enableSorting: false,
size: 140,
minSize: 120,
size: 64,
minSize: 64,
meta: {
title: t("pages.proformas.list.grid_columns.status"),
},
@ -93,15 +93,14 @@ export function useProformasGridColumns(
),
accessorFn: (row) => row.recipient.name, // para ordenar/buscar por nombre
enableHiding: false,
size: 140,
minSize: 120,
cell: ({ row }) => {
const c = row.original.recipient;
return (
<div className="flex items-start gap-1 my-1.5">
<div className="flex items-start gap-1">
<div className="min-w-0 grid gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium truncate text-primary">{c.name}</span>
<span className="font-semibold truncate text-primary">{c.name}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{c.tin && <span className="font-base truncate">{c.tin}</span>}
@ -126,8 +125,8 @@ export function useProformasGridColumns(
),
cell: ({ row }) => <div className="font-normal text-left">{row.original.series}</div>,
enableSorting: false,
size: 120,
minSize: 100,
size: 64,
minSize: 64,
meta: {
title: t("pages.proformas.list.grid_columns.series"),
},
@ -166,9 +165,8 @@ export function useProformasGridColumns(
{formatDate(row.original.invoice_date)}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
size: 96,
minSize: 96,
meta: {
title: t("pages.proformas.list.grid_columns.invoice_date"),
},
@ -188,9 +186,8 @@ export function useProformasGridColumns(
{formatDate(row.original.operation_date)}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
size: 96,
minSize: 96,
meta: {
title: t("pages.proformas.list.grid_columns.operation_date"),
},
@ -291,7 +288,13 @@ export function useProformasGridColumns(
// ─────────────────────────────
{
id: "actions",
header: () => <span className="sr-only">{t("common.actions")}</span>,
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("common.actions")}
/>
),
enableSorting: false,
enableHiding: false,
size: 110,
@ -381,7 +384,7 @@ export function useProformasGridColumns(
{/* Menú demás acciones */}
{/** biome-ignore lint/suspicious/noSelfCompare: <Desactivado por ahora> */}
{false === false && (
{false !== false && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button

View File

@ -1,21 +1,19 @@
import { Column } from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from "lucide-react";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { Column } from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react";
import { useTranslation } from "../../locales/i18n.ts";
import {
Button, DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@repo/shadcn-ui/components';
import { cn } from '@repo/shadcn-ui/lib/utils';
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
title: string
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>;
title: string;
}
export function DataTableColumnHeader<TData, TValue>({
@ -26,7 +24,16 @@ export function DataTableColumnHeader<TData, TValue>({
const { t } = useTranslation();
if (!column.getCanSort()) {
return <div className={cn("text-xs text-muted-foreground text-nowrap cursor-default", className)}>{title}</div>
return (
<div
className={cn(
"text-xs text-muted-foreground text-nowrap cursor-default font-semibold",
className
)}
>
{title}
</div>
);
}
return (
@ -34,10 +41,10 @@ export function DataTableColumnHeader<TData, TValue>({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="data-[state=open]:bg-accent -ml-3 h-8 text-xs text-muted-foreground font-semibold text-nowrap cursor-pointer"
size="sm"
type="button"
variant="ghost"
size="sm"
className="data-[state=open]:bg-accent -ml-3 h-8 text-xs text-muted-foreground text-nowrap cursor-pointer"
>
<span>{title}</span>
{column.getIsSorted() === "desc" ? (
@ -52,19 +59,14 @@ export function DataTableColumnHeader<TData, TValue>({
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUp />
{t("components.datatabla.asc")}
{t("components.datatable.asc")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDown />
{t("components.datatabla.desc")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeOff />
{t("components.datatabla.hide")}
{t("components.datatable.desc")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
);
}

View File

@ -1,23 +1,26 @@
import { Table } from "@tanstack/react-table";
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon
} from "lucide-react";
import {
Pagination, PaginationContent,
PaginationItem, PaginationLink,
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@repo/shadcn-ui/components';
import { cn } from '@repo/shadcn-ui/lib/utils';
import { useTranslation } from '../../locales/i18n.ts';
import { DataTableMeta } from './data-table.tsx';
SelectValue,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { Table } from "@tanstack/react-table";
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
} from "lucide-react";
import { useTranslation } from "../../locales/i18n.ts";
import type { DataTableMeta } from "./data-table.tsx";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
@ -28,7 +31,11 @@ interface DataTablePaginationProps<TData> {
}
export function DataTablePagination<TData>({
table, onPageChange, onPageSizeChange, className }: DataTablePaginationProps<TData>) {
table,
onPageChange,
onPageSizeChange,
className,
}: DataTablePaginationProps<TData>) {
const { t } = useTranslation();
const { pageIndex: rawIndex, pageSize: rawSize } = table.getState().pagination;
@ -50,10 +57,10 @@ export function DataTablePagination<TData>({
const notify = (next: Partial<{ pageIndex: number; pageSize: number }>) =>
table.options.onPaginationChange?.({ pageIndex, pageSize, ...next });
const gotoPage = (index: number) => onPageChange ? onPageChange(index) : notify({ pageIndex: index });
const handlePageSizeChange = (size: string) => onPageSizeChange ?
onPageSizeChange(Number(size)) :
notify({ pageSize: Number(size) });
const gotoPage = (index: number) =>
onPageChange ? onPageChange(index) : notify({ pageIndex: index });
const handlePageSizeChange = (size: string) =>
onPageSizeChange ? onPageSizeChange(Number(size)) : notify({ pageSize: Number(size) });
const gotoPreviousPage = () => gotoPage(pageIndex - 1);
const gotoNextPage = () => gotoPage(pageIndex + 1);
@ -79,7 +86,7 @@ export function DataTablePagination<TData>({
<div className="flex items-center gap-2">
<span>{t("components.datatable.pagination.rows_per_page")}</span>
<Select value={String(pageSize)} onValueChange={handlePageSizeChange}>
<Select onValueChange={handlePageSizeChange} value={String(pageSize)}>
<SelectTrigger className="w-20 h-8 bg-white border-gray-200">
<SelectValue placeholder={String(pageSize)} />
</SelectTrigger>
@ -91,7 +98,6 @@ export function DataTablePagination<TData>({
))}
</SelectContent>
</Select>
{pageIndex + 1} / {pageCount}
</div>
</div>
@ -102,12 +108,12 @@ export function DataTablePagination<TData>({
<PaginationItem>
<PaginationLink
aria-label={t("components.datatable.pagination.goto_first_page")}
className="px-2.5 cursor-pointer"
isActive={pageIndex > 0}
onClick={() => {
if (pageIndex > 0) gotoFirstPage();
}}
isActive={pageIndex > 0}
size="sm"
className="px-2.5 cursor-pointer"
>
<ChevronsLeftIcon className="size-4" />
</PaginationLink>
@ -116,30 +122,30 @@ export function DataTablePagination<TData>({
<PaginationItem>
<PaginationLink
aria-label={t("components.datatable.pagination.goto_previous_page")}
className="px-2.5 cursor-pointer"
isActive={pageIndex > 0}
onClick={() => {
if (pageIndex > 0) gotoPreviousPage();
}}
isActive={pageIndex > 0}
size="sm"
className="px-2.5 cursor-pointer"
>
<ChevronLeftIcon className="size-4" />
</PaginationLink>
</PaginationItem>
<span className="text-sm text-muted-foreground px-2" aria-live="polite">
<span aria-live="polite" className="text-sm text-muted-foreground px-2">
{t("components.datatable.pagination.page_of", { page: pageIndex + 1, of: pageCount })}
</span>
<PaginationItem>
<PaginationLink
aria-label={t("components.datatable.pagination.goto_next_page")}
className="px-2.5 cursor-pointer"
isActive={pageIndex < pageCount - 1}
onClick={() => {
if (pageIndex < pageCount - 1) gotoNextPage();
}}
isActive={pageIndex < pageCount - 1}
size="sm"
className="px-2.5 cursor-pointer"
>
<ChevronRightIcon className="size-4" />
</PaginationLink>
@ -148,12 +154,12 @@ export function DataTablePagination<TData>({
<PaginationItem>
<PaginationLink
aria-label={t("components.datatable.pagination.goto_last_page")}
className="px-2.5 cursor-pointer"
isActive={pageIndex < pageCount - 1}
onClick={() => {
if (pageIndex < pageCount - 1) gotoLastPage();
}}
isActive={pageIndex < pageCount - 1}
size="sm"
className="px-2.5 cursor-pointer"
>
<ChevronsRightIcon className="size-4" />
</PaginationLink>
@ -163,4 +169,4 @@ export function DataTablePagination<TData>({
</div>
</div>
);
}
}

View File

@ -21,7 +21,7 @@ export function DataTableViewOptions<TData>({ table }: { table: Table<TData> })
<DropdownMenuTrigger asChild>
<Button className="ml-auto hidden h-8 lg:flex" size="sm" type="button" variant="outline">
<Settings2 />
{t("components.datatable_view_options.view_button")}
{t("components.datatable_view_options.columns_button")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">

View File

@ -36,7 +36,7 @@
}
},
"datatable_view_options": {
"view_button": "View",
"columns_button": "Columns",
"toggle_columns": "Toggle columns"
},
"loading_indicator": {

View File

@ -11,8 +11,8 @@
},
"components": {
"datatable": {
"asc": "Asc",
"desc": "Desc",
"asc": "Ascendente",
"desc": "Descendente",
"hide": "Ocultar",
"empty": "No hay resultados",
"selection_summary": "{{count}} filas seleccionadas de {{total}}",
@ -39,7 +39,7 @@
}
},
"datatable_view_options": {
"view_button": "Ver",
"columns_button": "Columnas",
"toggle_columns": "Alternar columnas"
},
"loading_indicator": {