Facturas de cliente

This commit is contained in:
David Arranz 2025-11-24 14:05:10 +01:00
parent fe1faaab4d
commit 220cc4c3a4
18 changed files with 381 additions and 148 deletions

View File

@ -1,7 +1,8 @@
import cors, { CorsOptions } from "cors";
import express, { Application } from "express";
import cors, { type CorsOptions } from "cors";
import express, { type Application } from "express";
import helmet from "helmet";
import responseTime from "response-time";
// ❗️ No cargamos dotenv aquí. Debe hacerse en el entrypoint o en ./config.
// dotenv.config();
import { ENV } from "./config";
@ -9,22 +10,6 @@ import { logger } from "./lib/logger";
export function createApp(): Application {
const app = express();
app.set("port", process.env.SERVER_PORT ?? 3002);
// Oculta la cabecera x-powered-by
app.disable("x-powered-by");
// Desactiva ETag correctamente a nivel de Express
app.set("etag", false);
// ───────────────────────────────────────────────────────────────────────────
// Parsers
app.use(express.json());
app.use(express.text());
app.use(express.urlencoded({ extended: true }));
// Métrica de tiempo de respuesta
app.use(responseTime());
// ───────────────────────────────────────────────────────────────────────────
// CORS
@ -60,6 +45,24 @@ export function createApp(): Application {
};
app.use(cors(ENV.NODE_ENV === "development" ? devCors : prodCors));
app.options("*", cors(ENV.NODE_ENV === "development" ? devCors : prodCors));
app.set("port", process.env.SERVER_PORT ?? 3002);
// Oculta la cabecera x-powered-by
app.disable("x-powered-by");
// Desactiva ETag correctamente a nivel de Express
app.set("etag", false);
// ───────────────────────────────────────────────────────────────────────────
// Parsers
app.use(express.json());
app.use(express.text());
app.use(express.urlencoded({ extended: true }));
// Métrica de tiempo de respuesta
app.use(responseTime());
// ───────────────────────────────────────────────────────────────────────────
// Seguridad HTTP

View File

@ -248,7 +248,7 @@ process.on("uncaughtException", async (error: Error) => {
logger.info("✅ Server is READY (readiness=true)");
logger.info(`startup_duration_ms=${DateTime.now().diff(currentState.launchedAt).toMillis()}`);
server.listen(currentState.port, () => {
server.listen(currentState.port, "0.0.0.0", () => {
server.emit("listening");
const networkInterfaces = os.networkInterfaces();

View File

@ -52,15 +52,46 @@
},
"issued_invoices": {
"status": {
"all": "Todos",
"pendiente": "Pendiente",
"aceptado_con_error": "Aceptado con error",
"incorrecto": "Incorrecto",
"duplicado": "Duplicado",
"anulado": "Anulado",
"factura_inexistente": "Factura inexistente",
"rechazado": "Rechazado",
"error": "Error"
"all": {
"label": "All",
"description": "Proforma in preparation"
},
"pendiente": {
"label": "Pending",
"description": "Queued record not yet processed"
},
"correcto": {
"label": "Correct",
"description": "Record successfully processed by the AEAT"
},
"aceptado_con_error": {
"label": "Accepted with errors",
"description": "Record accepted with errors by the AEAT. A correction record or a rectifying invoice is required"
},
"incorrecto": {
"label": "Incorrect",
"description": "Record considered incorrect by the AEAT. A correction record with rechazo_previo=S or rechazo_previo=X, or a rectifying invoice, is required"
},
"duplicado": {
"label": "Duplicate",
"description": "Record not accepted by the AEAT because another record with the same (series, number, issue_date) already exists"
},
"anulado": {
"label": "Cancelled",
"description": "Cancellation record successfully processed by the AEAT"
},
"factura_inexistente": {
"label": "Non-existent invoice",
"description": "Cancellation record not accepted by the AEAT because the invoice does not exist"
},
"rechazado": {
"label": "Not registered",
"description": "Record rejected by the AEAT"
},
"error": {
"label": "Error",
"description": "AEAT server error. The invoice record will be retried"
}
}
}
},

View File

@ -52,15 +52,46 @@
},
"issued_invoices": {
"status": {
"all": "All",
"pendiente": "Pendiente",
"aceptado_con_error": "Aceptado con error",
"incorrecto": "Incorrecto",
"duplicado": "Duplicado",
"anulado": "Anulado",
"factura_inexistente": "Factura inexistente",
"rechazado": "Rechazado",
"error": "Error"
"all": {
"label": "All",
"description": "Proforma en preparación"
},
"pendiente": {
"label": "Pendiente",
"description": "Registro encolado y no procesado aún"
},
"correcto": {
"label": "Correcto",
"description": "Registro procesado correctamente por la AEAT"
},
"aceptado_con_error": {
"label": "Aceptado con errores",
"description": "Registro aceptado con errores por la AEAT. Se requiere enviar un registro de subsanación o emitir una rectificativa"
},
"incorrecto": {
"label": "Incorrecto",
"description": "Registro considerado incorrecto por la AEAT. Se requiere enviar un registro de subsanación con rechazo_previo=S o rechazo_previo=X o emitir una rectificativa"
},
"duplicado": {
"label": "Duplicado",
"description": "Registro no aceptado por la AEAT por existir un registro con el mismo (serie, numero, fecha_expedicion)"
},
"anulado": {
"label": "Anulado",
"description": "Registro de anulación procesado correctamente por la AEAT"
},
"factura_inexistente": {
"label": "Factura inexistente",
"description": "Registro de anulación no aceptado por la AEAT por no existir la factura"
},
"rechazado": {
"label": "No registrado",
"description": "Registro rechazado por la AEAT"
},
"error": {
"label": "Error",
"description": "Error en el servidor de la AEAT. Se intentará reenviar el registro de facturación de nuevo"
}
}
}
},

View File

@ -33,6 +33,7 @@ export const useIssuedInvoiceListQuery = (options?: IssuedInvoicesQueryOptions)
queryKey: ISSUED_INVOICES_QUERY_KEY(criteria),
queryFn: async ({ signal }) => getIssuedInvoiceListApi(dataSource, signal, criteria),
enabled,
staleTime: 5000,
placeholderData: (previousData, _previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
});
};

View File

@ -19,6 +19,7 @@ import QrCode from "react-qr-code";
import { useTranslation } from "../../../../../i18n";
import type { IssuedInvoiceSummaryData } from "../../../../types";
import { VerifactuStatusBadge } from "../../components";
type GridActionHandlers = {
onDownloadPdf?: (issuedInvoice: IssuedInvoiceSummaryData) => void;
@ -68,6 +69,15 @@ export function useIssuedInvoicesGridColumns(
/>
),
accessorFn: (row) => row.verifactu.status, // para ordenar/buscar por nombre
cell: ({ row }) => {
const invoice = row.original;
return (
<div className="flex items-center gap-2">
<VerifactuStatusBadge status={invoice.verifactu.status} />
</div>
);
},
enableHiding: false,
enableSorting: false,
size: 140,

View File

@ -0,0 +1 @@
export * from "./verifactu-status-badge";

View File

@ -0,0 +1,46 @@
import { Badge, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { useTranslation } from "../../../../i18n";
import {
type VerifactuRecordStatus,
getVerifactuRecordStatusButtonVariant,
getVerifactuRecordStatusColor,
getVerifactuRecordStatusIcon,
} from "../../../types";
export type VerifactuStatusBadgeProps = {
status: string | VerifactuRecordStatus; // permitir cualquier valor
className?: string;
};
export const VerifactuStatusBadge = ({ status, className }: VerifactuStatusBadgeProps) => {
const { t } = useTranslation();
const normalizedStatus = status as VerifactuRecordStatus;
const Icon = getVerifactuRecordStatusIcon(normalizedStatus);
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge
className={cn(
getVerifactuRecordStatusColor(normalizedStatus),
"font-semibold",
className
)}
variant={getVerifactuRecordStatusButtonVariant(normalizedStatus)}
>
<Icon />
{t(`catalog.issued_invoices.status.${normalizedStatus.toLowerCase()}.label`, {
defaultValue: status,
})}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>{t(`catalog.issued_invoices.status.${normalizedStatus.toLowerCase()}.description`)}</p>
</TooltipContent>
</Tooltip>
);
};
VerifactuStatusBadge.displayName = "VerifactuStatusBadge";

View File

@ -96,19 +96,30 @@ export const IssuedInvoiceListPage = () => {
<SelectValue placeholder={t("filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("catalog.issuedInvoices.status.all.label")}</SelectItem>
<SelectItem value="draft">
{t("catalog.issuedInvoices.status.draft.label")}
<SelectItem value="all">{t("catalog.issued_invoices.status.all.label")}</SelectItem>
<SelectItem value="Correcto">
{t("catalog.issued_invoices.status.correcto.label")}
</SelectItem>
<SelectItem value="sent">{t("catalog.issuedInvoices.status.sent.label")}</SelectItem>
<SelectItem value="approved">
{t("catalog.issuedInvoices.status.approved.label")}
<SelectItem value="Pendiente">
{t("catalog.issued_invoices.status.pendiente.label")}
</SelectItem>
<SelectItem value="rejected">
{t("catalog.issuedInvoices.status.rejected.label")}
<SelectItem value="aceptado_con_error">
{t("catalog.issued_invoices.status.aceptado_con_error.label")}
</SelectItem>
<SelectItem value="issued">
{t("catalog.issuedInvoices.status.issued.label")}
<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>

View File

@ -1,2 +1,3 @@
export * from "./issued-invoice.api.schema";
export * from "./issued-invoice-summary.web.schema";
export * from "./verifactu-record-status";

View File

@ -0,0 +1,89 @@
import {
BugIcon,
CheckCheckIcon,
CheckIcon,
CopyIcon,
FileQuestionIcon,
HourglassIcon,
StopCircleIcon,
} from "lucide-react";
export enum VERIFACTU_RECORD_STATUS {
PENDIENTE = "Pendiente", // <- Registro encolado y no procesado aún
CORRECTO = "Correcto", // <- Registro procesado correctamente por la AEAT
ACEPTADO_CON_ERROR = "Aceptado con errores", // <- Registro aceptado con errores por la AEAT. Se requiere enviar un registro de subsanación o emitir una rectificativa
INCORRECTO = "Incorrecto", // <- Registro considerado incorrecto por la AEAT. Se requiere enviar un registro de subsanación con rechazo_previo=S o rechazo_previo=X o emitir una rectificativa
DUPLICADO = "Duplicado", // <- Registro no aceptado por la AEAT por existir un registro con el mismo (serie, numero, fecha_expedicion)
ANULADO = "Anulado", // <- Registro de anulación procesado correctamente por la AEAT
FACTURA_INEXISTENTE = "Factura inexistente", // <- Registro de anulación no aceptado por la AEAT por no existir la factura.
RECHAZADO = "No registrado", // <- Registro rechazado por la AEAT
ERROR = "Error servidor AEAT", // <- Error en el servidor de la AEAT. Se intentará reenviar el registro de facturación de nuevo
}
export type VerifactuRecordStatus = `${VERIFACTU_RECORD_STATUS}`;
export const getVerifactuRecordStatusButtonVariant = (
status: VerifactuRecordStatus
): "default" | "secondary" | "outline" | "destructive" => {
switch (status) {
case "Pendiente":
return "outline";
case "Aceptado con errores":
case "Duplicado":
return "secondary";
case "Anulado":
return "default";
case "Incorrecto":
case "Error servidor AEAT":
case "Factura inexistente":
case "No registrado":
return "destructive";
case "Correcto":
return "default";
default:
return "outline";
}
};
export const getVerifactuRecordStatusColor = (status: VerifactuRecordStatus): string => {
switch (status) {
case "Pendiente":
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
case "Aceptado con errores":
case "Duplicado":
return "bg-yellow-100 text-yellow-700 hover:bg-yellow-100";
case "Anulado":
return "bg-green-100 text-green-700 hover:bg-green-100";
case "Incorrecto":
case "Error servidor AEAT":
case "Factura inexistente":
case "No registrado":
return "bg-red-100 text-red-700 hover:bg-red-100";
case "Correcto":
return "bg-blue-100 text-blue-700 hover:bg-blue-100";
default:
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
}
};
export const getVerifactuRecordStatusIcon = (status: VerifactuRecordStatus) => {
switch (status) {
case "Pendiente":
return HourglassIcon;
case "Aceptado con errores":
return CheckIcon;
case "Duplicado":
return CopyIcon;
case "Anulado":
return StopCircleIcon;
case "Incorrecto":
case "Error servidor AEAT":
case "Factura inexistente":
case "No registrado":
return BugIcon;
case "Correcto":
return CheckCheckIcon;
default:
return FileQuestionIcon;
}
};

View File

@ -1,6 +1,5 @@
import {
Button,
Checkbox,
Tooltip,
TooltipContent,
TooltipProvider,
@ -39,7 +38,7 @@ export function useProformasGridColumns(
return React.useMemo<ColumnDef<ProformaSummaryData, unknown>[]>(
() => [
{
/*{
id: "select",
header: ({ table }) => (
<Checkbox
@ -60,7 +59,7 @@ export function useProformasGridColumns(
),
enableSorting: false,
enableHiding: false,
},
},*/
{
accessorKey: "invoice_number",
header: ({ column }) => {

View File

@ -3,14 +3,11 @@
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="{{ asset 'tailwind.css' }}" />
<title>Factura</title>
<style type="text/css">{{ asset 'tailwind.min.css' }}</style>
<style type="text/css">
/* ---------------------------- */
/* ESTRUCTURA CABECERA */
/* ---------------------------- */
header {
width: 100%;
margin-bottom: 15px;
@ -44,12 +41,14 @@
/* Bloque derecho */
.right-block {
display: flex;
flex-direction: column; /* uno encima de otro */
align-items: flex-end; /* o flex-start / center según quieras */
justify-content: flex-start;
width: 40%;
padding: 4px;
display: flex;
flex-direction: column;
/* uno encima de otro */
align-items: flex-end;
/* o flex-start / center según quieras */
justify-content: flex-start;
width: 40%;
padding: 4px;
}
.factura-img {
@ -222,18 +221,18 @@
<div class="right-block">
<div>
<img src="{{asset 'factura_acana.jpg'}}" alt="Factura" class="factura-img" />
<img src="{{asset 'factura_acana.jpg'}}" alt="Factura" class="factura-img" />
</div>
{{#if verifactu.qr_code}}
<div style="display: flex; align-items: center; gap: 8px; padding-top: 10px;">
<div style="text-align: right;">
<div style="display: flex; align-items: center; gap: 4px; padding-top: 10px; align-content: stretch">
<div style="text-align: right; flex-grow: 1; flex-shrink: 1; flex-basis: auto;">
QR tributario factura verificable en sede electronica de AEAT VERI*FACTU
</div>
<div>
<div style="flex-grow: 0; flex-shrink: 0; flex-basis:100px;">
<img src="{{verifactu.qr_code}}" alt="QR factura" style="width: 100px; height: 100px;" />
</div>
</div>
{{/if}}
{{/if}}
</div>
</div>
@ -246,7 +245,7 @@
</div>
<div class="info-box info-dire">
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
<h2 style="font-weight:600; text-transform:uppercase; margin-bottom:0.25rem;">{{recipient.name}}</h2>
<p>{{recipient.tin}}</p>
<p>{{recipient.street}}</p>
<p>{{recipient.postal_code}} {{recipient.city}} {{recipient.province}}</p>
@ -263,11 +262,11 @@
<table class="table-header">
<thead>
<tr>
<th class="py-2">Concepto</th>
<th class="py-2">Ud.</th>
<th class="py-2">Imp.</th>
<th class="py-2">&nbsp;</th>
<th class="py-2">Imp.&nbsp;total</th>
<th style="padding-top:0.5rem; padding-bottom:0.5rem;">Concepto</th>
<th style="padding-top:0.5rem; padding-bottom:0.5rem;">Ud.</th>
<th style="padding-top:0.5rem; padding-bottom:0.5rem;">Imp.</th>
<th style="padding-top:0.5rem; padding-bottom:0.5rem;">&nbsp;</th>
<th style="padding-top:0.5rem; padding-bottom:0.5rem;">Imp.&nbsp;total</th>
</tr>
</thead>
@ -275,10 +274,10 @@
{{#each items}}
<tr>
<td>{{description}}</td>
<td class="text-right">{{#if quantity}}{{quantity}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if discount_percentage}}{{discount_percentage}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if taxable_amount}}{{taxable_amount}}{{else}}&nbsp;{{/if}}</td>
<td style="text-align:right;">{{#if quantity}}{{quantity}}{{else}}&nbsp;{{/if}}</td>
<td style="text-align:right;">{{#if unit_amount}}{{unit_amount}}{{else}}&nbsp;{{/if}}</td>
<td style="text-align:right;">{{#if discount_percentage}}{{discount_percentage}}{{else}}&nbsp;{{/if}}</td>
<td style="text-align:right;">{{#if taxable_amount}}{{taxable_amount}}{{else}}&nbsp;{{/if}}</td>
</td>
</tr>
{{/each}}
@ -291,7 +290,7 @@
{{/if}}
{{#if notes}}
<p class="mt-2"><strong>Notas:</strong> {{notes}}</p>
<p style="margin-top:0.5rem;"><strong>Notas:</strong> {{notes}}</p>
{{/if}}
</td>
<!-- Columna derecha: totales -->
@ -332,13 +331,13 @@
</main>
<footer id="footer" class="mt-4 border-t border-black">
<aside class="mt-4">
<footer id="footer" style="margin-top:1rem; border-top:1px solid #000000;">
<aside style="margin-top: 1rem;">
<tfoot>
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 31.839, Libro 0, Folio 191, Sección 8, Hoja
<p style="text-align: center;">Insc. en el Reg. Merc. de Madrid, Tomo 31.839, Libro 0, Folio 191, Sección 8, Hoja
M-572991
CIF: B86913910</p>
<p class="text-left" style="font-size: 6pt;">Información en protección de datos<br />De conformidad con lo
<p style="text-align: left; font-size: 6pt;">Información en protección de datos:<br />De conformidad con lo
dispuesto en el RGPD y LOPDGDD,
informamos que los datos personales serán tratados por
ALISO DESIGN S.L para cumplir con la obligación tributaria de emitir facturas. Podrá solicitar más
@ -353,5 +352,4 @@
</footer>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
import { ModuleClientParams } from "@erp/core/client";
import type { ModuleClientParams } from "@erp/core/client";
import { lazy } from "react";
import { Outlet, RouteObject } from "react-router-dom";
import { Outlet, type RouteObject } from "react-router-dom";
// Lazy load components
const CustomersLayout = lazy(() =>
@ -26,9 +26,9 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
children: [
{ path: "", index: true, element: <CustomersList /> }, // index
{ path: "list", element: <CustomersList /> },
{ path: "create", element: <CustomerAdd /> },
//{ path: "create", element: <CustomerAdd /> },
{ path: ":id", element: <CustomerView /> },
{ path: ":id/edit", element: <CustomerUpdate /> },
//{ path: ":id/edit", element: <CustomerUpdate /> },
//
/*{ path: "create", element: <CustomersList /> },

View File

@ -1,12 +1,12 @@
import { SimpleSearchInput } from '@erp/core/components';
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 { useTranslation } from "../../i18n";
import { CustomerSummaryFormData, CustomersPageFormData } from '../../schemas';
import { useCustomersListColumns } from './use-customers-list-columns';
import { useTranslation } from "../../i18n";
import type { CustomerSummaryFormData, CustomersPageFormData } from "../../schemas";
import { useCustomersListColumns } from "./use-customers-list-columns";
export type CustomerUpdateCompProps = {
customersPage: CustomersPageFormData;
@ -20,8 +20,12 @@ export type CustomerUpdateCompProps = {
searchValue: string;
onSearchChange: (value: string) => void;
onRowClick?: (row: CustomerSummaryFormData, index: number, event: React.MouseEvent<HTMLTableRowElement>) => void;
}
onRowClick?: (
row: CustomerSummaryFormData,
index: number,
event: React.MouseEvent<HTMLTableRowElement>
) => void;
};
export const CustomersListGrid = ({
customersPage,
@ -30,8 +34,9 @@ export const CustomersListGrid = ({
pageSize,
onPageChange,
onPageSizeChange,
searchValue, onSearchChange,
onRowClick
searchValue,
onSearchChange,
onRowClick,
}: CustomerUpdateCompProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
@ -41,11 +46,10 @@ export const CustomersListGrid = ({
const columns = useCustomersListColumns({
onEdit: (customer) => navigate(`/customers/${customer.id}/edit`),
onView: (customer) => null, //duplicateInvoice(inv.id),
onView: (customer) => navigate(`/customers/${customer.id}`),
onDelete: (customer) => null, //confirmDelete(inv.id),
});
// Navegación centralizada (click/teclado)
const goToRow = useCallback(
(id: string, newTab = false) => {
@ -60,7 +64,8 @@ export const CustomersListGrid = ({
);
// Handlers de búsqueda
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => onSearchChange(e.target.value);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) =>
onSearchChange(e.target.value);
const handleClear = () => onSearchChange("");
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
@ -83,9 +88,9 @@ export const CustomersListGrid = ({
<div className="flex flex-col gap-4">
<SkeletonDataTable
columns={columns.length}
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
rows={Math.max(6, pageSize)}
showFooter
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
/>
</div>
);
@ -96,23 +101,23 @@ export const CustomersListGrid = ({
<div className="flex flex-col gap-4">
{/* Barra de filtros */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<SimpleSearchInput onSearchChange={onSearchChange} loading={loading} />
<SimpleSearchInput loading={loading} onSearchChange={onSearchChange} />
</div>
<div className="relative flex">
<div className={/*preview.isPinned ? "flex-1 mr-[500px]" : */"flex-1"}>
<div className={/*preview.isPinned ? "flex-1 mr-[500px]" : */ "flex-1"}>
<DataTable
columns={columns}
data={items}
readOnly
enableRowSelection
enablePagination
enableRowSelection
manualPagination
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={total_items}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onRowClick={() => null /*handleRowClick*/}
pageIndex={pageIndex}
pageSize={pageSize}
readOnly
totalItems={total_items}
/>
</div>
@ -129,4 +134,4 @@ export const CustomersListGrid = ({
</div>
</div>
);
};
};

View File

@ -4,9 +4,11 @@ import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Outlet, useNavigate } from "react-router-dom";
import { ErrorAlert } from "../../components";
import { useCustomerListQuery } from "../../hooks";
import { useTranslation } from "../../i18n";
import { CustomersListGrid } from "./customers-list-grid";
export const CustomersListPage = () => {
@ -49,8 +51,8 @@ export const CustomersListPage = () => {
return (
<AppContent>
<ErrorAlert
title={t("pages.list.loadErrorTitle")}
message={(error as Error)?.message || "Error al cargar el listado"}
title={t("pages.list.loadErrorTitle")}
/>
<BackHistoryButton />
</AppContent>
@ -61,35 +63,35 @@ export const CustomersListPage = () => {
<>
<AppHeader>
<PageHeader
title={t("pages.list.title")}
description={t("pages.list.description")}
rightSlot={
<div className='flex items-center space-x-2'>
<div className="flex items-center space-x-2 hidden">
<Button
aria-label={t("pages.create.title")}
className="cursor-pointer"
onClick={() => navigate("/customers/create")}
variant={"default"}
aria-label={t("pages.create.title")}
className='cursor-pointer'
>
<PlusIcon className='mr-2 h-4 w-4' aria-hidden />
<PlusIcon aria-hidden className="mr-2 h-4 w-4" />
{t("pages.create.title")}
</Button>
</div>
}
title={t("pages.list.title")}
/>
</AppHeader>
<AppContent>
<div className='flex flex-col w-full h-full py-3'>
<div className="flex flex-col w-full h-full py-3">
<div className={"flex-1"}>
<CustomersListGrid
customersPage={customersPageData}
loading={isLoading}
pageIndex={pageIndex}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
searchValue={search}
onSearchChange={handleSearchChange}
pageIndex={pageIndex}
pageSize={pageSize}
searchValue={search}
/>
</div>
</div>

View File

@ -14,9 +14,9 @@ import {
import type { ColumnDef } from "@tanstack/react-table";
import {
Building2Icon,
EyeIcon,
MailIcon,
MoreHorizontalIcon,
PencilIcon,
PhoneIcon,
User2Icon,
} from "lucide-react";
@ -193,40 +193,44 @@ export function useCustomersListColumns(
<div className="flex justify-end">
<div className="flex items-center gap-1">
<Button
aria-label="Edit customer"
onClick={() => onEdit?.(customer)}
aria-label="Ver cliente"
onClick={() => onView?.(customer)}
size="icon"
variant="ghost"
>
<PencilIcon className="size-4" />
<EyeIcon className="size-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button aria-label="More actions" size="icon" variant="ghost">
<MoreHorizontalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onView?.(customer)}>Open</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit?.(customer)}>Edit</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => window.open(safeHttp(website), "_blank")}>
Visit website
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(email_primary)}>
Copy email
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete?.(customer)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{0 === false && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button aria-label="More actions" size="icon" variant="ghost">
<MoreHorizontalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onView?.(customer)}>Open</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit?.(customer)}>Edit</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => window.open(safeHttp(website), "_blank")}>
Visit website
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(email_primary)}
>
Copy email
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete?.(customer)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);