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 cors, { type CorsOptions } from "cors";
import express, { Application } from "express"; import express, { type Application } from "express";
import helmet from "helmet"; import helmet from "helmet";
import responseTime from "response-time"; import responseTime from "response-time";
// ❗️ No cargamos dotenv aquí. Debe hacerse en el entrypoint o en ./config. // ❗️ No cargamos dotenv aquí. Debe hacerse en el entrypoint o en ./config.
// dotenv.config(); // dotenv.config();
import { ENV } from "./config"; import { ENV } from "./config";
@ -9,22 +10,6 @@ import { logger } from "./lib/logger";
export function createApp(): Application { export function createApp(): Application {
const app = express(); 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 // CORS
@ -60,6 +45,24 @@ export function createApp(): Application {
}; };
app.use(cors(ENV.NODE_ENV === "development" ? devCors : prodCors)); 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 // Seguridad HTTP

View File

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

View File

@ -52,15 +52,46 @@
}, },
"issued_invoices": { "issued_invoices": {
"status": { "status": {
"all": "Todos", "all": {
"pendiente": "Pendiente", "label": "All",
"aceptado_con_error": "Aceptado con error", "description": "Proforma in preparation"
"incorrecto": "Incorrecto", },
"duplicado": "Duplicado", "pendiente": {
"anulado": "Anulado", "label": "Pending",
"factura_inexistente": "Factura inexistente", "description": "Queued record not yet processed"
"rechazado": "Rechazado", },
"error": "Error" "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": { "issued_invoices": {
"status": { "status": {
"all": "All", "all": {
"pendiente": "Pendiente", "label": "All",
"aceptado_con_error": "Aceptado con error", "description": "Proforma en preparación"
"incorrecto": "Incorrecto", },
"duplicado": "Duplicado", "pendiente": {
"anulado": "Anulado", "label": "Pendiente",
"factura_inexistente": "Factura inexistente", "description": "Registro encolado y no procesado aún"
"rechazado": "Rechazado", },
"error": "Error" "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), queryKey: ISSUED_INVOICES_QUERY_KEY(criteria),
queryFn: async ({ signal }) => getIssuedInvoiceListApi(dataSource, signal, criteria), queryFn: async ({ signal }) => getIssuedInvoiceListApi(dataSource, signal, criteria),
enabled, enabled,
staleTime: 5000,
placeholderData: (previousData, _previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`) 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 { useTranslation } from "../../../../../i18n";
import type { IssuedInvoiceSummaryData } from "../../../../types"; import type { IssuedInvoiceSummaryData } from "../../../../types";
import { VerifactuStatusBadge } from "../../components";
type GridActionHandlers = { type GridActionHandlers = {
onDownloadPdf?: (issuedInvoice: IssuedInvoiceSummaryData) => void; onDownloadPdf?: (issuedInvoice: IssuedInvoiceSummaryData) => void;
@ -68,6 +69,15 @@ export function useIssuedInvoicesGridColumns(
/> />
), ),
accessorFn: (row) => row.verifactu.status, // para ordenar/buscar por nombre 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, enableHiding: false,
enableSorting: false, enableSorting: false,
size: 140, 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")} /> <SelectValue placeholder={t("filters.status")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">{t("catalog.issuedInvoices.status.all.label")}</SelectItem> <SelectItem value="all">{t("catalog.issued_invoices.status.all.label")}</SelectItem>
<SelectItem value="draft"> <SelectItem value="Correcto">
{t("catalog.issuedInvoices.status.draft.label")} {t("catalog.issued_invoices.status.correcto.label")}
</SelectItem> </SelectItem>
<SelectItem value="sent">{t("catalog.issuedInvoices.status.sent.label")}</SelectItem> <SelectItem value="Pendiente">
<SelectItem value="approved"> {t("catalog.issued_invoices.status.pendiente.label")}
{t("catalog.issuedInvoices.status.approved.label")}
</SelectItem> </SelectItem>
<SelectItem value="rejected"> <SelectItem value="aceptado_con_error">
{t("catalog.issuedInvoices.status.rejected.label")} {t("catalog.issued_invoices.status.aceptado_con_error.label")}
</SelectItem> </SelectItem>
<SelectItem value="issued"> <SelectItem value="incorrecto">
{t("catalog.issuedInvoices.status.issued.label")} {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> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@ -1,2 +1,3 @@
export * from "./issued-invoice.api.schema"; export * from "./issued-invoice.api.schema";
export * from "./issued-invoice-summary.web.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 { import {
Button, Button,
Checkbox,
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
@ -39,7 +38,7 @@ export function useProformasGridColumns(
return React.useMemo<ColumnDef<ProformaSummaryData, unknown>[]>( return React.useMemo<ColumnDef<ProformaSummaryData, unknown>[]>(
() => [ () => [
{ /*{
id: "select", id: "select",
header: ({ table }) => ( header: ({ table }) => (
<Checkbox <Checkbox
@ -60,7 +59,7 @@ export function useProformasGridColumns(
), ),
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
}, },*/
{ {
accessorKey: "invoice_number", accessorKey: "invoice_number",
header: ({ column }) => { header: ({ column }) => {

View File

@ -3,14 +3,11 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="stylesheet" href="{{ asset 'tailwind.css' }}" />
<title>Factura</title> <title>Factura</title>
<style type="text/css">{{ asset 'tailwind.min.css' }}</style>
<style type="text/css"> <style type="text/css">
/* ---------------------------- */
/* ESTRUCTURA CABECERA */
/* ---------------------------- */
header { header {
width: 100%; width: 100%;
margin-bottom: 15px; margin-bottom: 15px;
@ -44,12 +41,14 @@
/* Bloque derecho */ /* Bloque derecho */
.right-block { .right-block {
display: flex; display: flex;
flex-direction: column; /* uno encima de otro */ flex-direction: column;
align-items: flex-end; /* o flex-start / center según quieras */ /* uno encima de otro */
justify-content: flex-start; align-items: flex-end;
width: 40%; /* o flex-start / center según quieras */
padding: 4px; justify-content: flex-start;
width: 40%;
padding: 4px;
} }
.factura-img { .factura-img {
@ -222,18 +221,18 @@
<div class="right-block"> <div class="right-block">
<div> <div>
<img src="{{asset 'factura_acana.jpg'}}" alt="Factura" class="factura-img" /> <img src="{{asset 'factura_acana.jpg'}}" alt="Factura" class="factura-img" />
</div> </div>
{{#if verifactu.qr_code}} {{#if verifactu.qr_code}}
<div style="display: flex; align-items: center; gap: 8px; padding-top: 10px;"> <div style="display: flex; align-items: center; gap: 4px; padding-top: 10px; align-content: stretch">
<div style="text-align: right;"> <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 QR tributario factura verificable en sede electronica de AEAT VERI*FACTU
</div> </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;" /> <img src="{{verifactu.qr_code}}" alt="QR factura" style="width: 100px; height: 100px;" />
</div> </div>
</div> </div>
{{/if}} {{/if}}
</div> </div>
</div> </div>
@ -246,7 +245,7 @@
</div> </div>
<div class="info-box info-dire"> <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.tin}}</p>
<p>{{recipient.street}}</p> <p>{{recipient.street}}</p>
<p>{{recipient.postal_code}} {{recipient.city}} {{recipient.province}}</p> <p>{{recipient.postal_code}} {{recipient.city}} {{recipient.province}}</p>
@ -263,11 +262,11 @@
<table class="table-header"> <table class="table-header">
<thead> <thead>
<tr> <tr>
<th class="py-2">Concepto</th> <th style="padding-top:0.5rem; padding-bottom:0.5rem;">Concepto</th>
<th class="py-2">Ud.</th> <th style="padding-top:0.5rem; padding-bottom:0.5rem;">Ud.</th>
<th class="py-2">Imp.</th> <th style="padding-top:0.5rem; padding-bottom:0.5rem;">Imp.</th>
<th class="py-2">&nbsp;</th> <th style="padding-top:0.5rem; padding-bottom:0.5rem;">&nbsp;</th>
<th class="py-2">Imp.&nbsp;total</th> <th style="padding-top:0.5rem; padding-bottom:0.5rem;">Imp.&nbsp;total</th>
</tr> </tr>
</thead> </thead>
@ -275,10 +274,10 @@
{{#each items}} {{#each items}}
<tr> <tr>
<td>{{description}}</td> <td>{{description}}</td>
<td class="text-right">{{#if quantity}}{{quantity}}{{else}}&nbsp;{{/if}}</td> <td style="text-align:right;">{{#if quantity}}{{quantity}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}}&nbsp;{{/if}}</td> <td style="text-align:right;">{{#if unit_amount}}{{unit_amount}}{{else}}&nbsp;{{/if}}</td>
<td class="text-right">{{#if discount_percentage}}{{discount_percentage}}{{else}}&nbsp;{{/if}}</td> <td style="text-align: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 taxable_amount}}{{taxable_amount}}{{else}}&nbsp;{{/if}}</td>
</td> </td>
</tr> </tr>
{{/each}} {{/each}}
@ -291,7 +290,7 @@
{{/if}} {{/if}}
{{#if notes}} {{#if notes}}
<p class="mt-2"><strong>Notas:</strong> {{notes}}</p> <p style="margin-top:0.5rem;"><strong>Notas:</strong> {{notes}}</p>
{{/if}} {{/if}}
</td> </td>
<!-- Columna derecha: totales --> <!-- Columna derecha: totales -->
@ -332,13 +331,13 @@
</main> </main>
<footer id="footer" class="mt-4 border-t border-black"> <footer id="footer" style="margin-top:1rem; border-top:1px solid #000000;">
<aside class="mt-4"> <aside style="margin-top: 1rem;">
<tfoot> <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 M-572991
CIF: B86913910</p> 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, dispuesto en el RGPD y LOPDGDD,
informamos que los datos personales serán tratados por 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 ALISO DESIGN S.L para cumplir con la obligación tributaria de emitir facturas. Podrá solicitar más
@ -353,5 +352,4 @@
</footer> </footer>
</body> </body>
</html> </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 { lazy } from "react";
import { Outlet, RouteObject } from "react-router-dom"; import { Outlet, type RouteObject } from "react-router-dom";
// Lazy load components // Lazy load components
const CustomersLayout = lazy(() => const CustomersLayout = lazy(() =>
@ -26,9 +26,9 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
children: [ children: [
{ path: "", index: true, element: <CustomersList /> }, // index { path: "", index: true, element: <CustomersList /> }, // index
{ path: "list", element: <CustomersList /> }, { path: "list", element: <CustomersList /> },
{ path: "create", element: <CustomerAdd /> }, //{ path: "create", element: <CustomerAdd /> },
{ path: ":id", element: <CustomerView /> }, { path: ":id", element: <CustomerView /> },
{ path: ":id/edit", element: <CustomerUpdate /> }, //{ path: ":id/edit", element: <CustomerUpdate /> },
// //
/*{ path: "create", element: <CustomersList /> }, /*{ 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 { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useNavigate } from "react-router-dom"; 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 = { export type CustomerUpdateCompProps = {
customersPage: CustomersPageFormData; customersPage: CustomersPageFormData;
@ -20,8 +20,12 @@ export type CustomerUpdateCompProps = {
searchValue: string; searchValue: string;
onSearchChange: (value: string) => void; 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 = ({ export const CustomersListGrid = ({
customersPage, customersPage,
@ -30,8 +34,9 @@ export const CustomersListGrid = ({
pageSize, pageSize,
onPageChange, onPageChange,
onPageSizeChange, onPageSizeChange,
searchValue, onSearchChange, searchValue,
onRowClick onSearchChange,
onRowClick,
}: CustomerUpdateCompProps) => { }: CustomerUpdateCompProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@ -41,11 +46,10 @@ export const CustomersListGrid = ({
const columns = useCustomersListColumns({ const columns = useCustomersListColumns({
onEdit: (customer) => navigate(`/customers/${customer.id}/edit`), 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), onDelete: (customer) => null, //confirmDelete(inv.id),
}); });
// Navegación centralizada (click/teclado) // Navegación centralizada (click/teclado)
const goToRow = useCallback( const goToRow = useCallback(
(id: string, newTab = false) => { (id: string, newTab = false) => {
@ -60,7 +64,8 @@ export const CustomersListGrid = ({
); );
// Handlers de búsqueda // 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 handleClear = () => onSearchChange("");
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") { if (e.key === "Enter") {
@ -83,9 +88,9 @@ export const CustomersListGrid = ({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<SkeletonDataTable <SkeletonDataTable
columns={columns.length} columns={columns.length}
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
rows={Math.max(6, pageSize)} rows={Math.max(6, pageSize)}
showFooter showFooter
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
/> />
</div> </div>
); );
@ -96,23 +101,23 @@ export const CustomersListGrid = ({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Barra de filtros */} {/* Barra de filtros */}
<div className="flex flex-col sm:flex-row gap-4 mb-6"> <div className="flex flex-col sm:flex-row gap-4 mb-6">
<SimpleSearchInput onSearchChange={onSearchChange} loading={loading} /> <SimpleSearchInput loading={loading} onSearchChange={onSearchChange} />
</div> </div>
<div className="relative flex"> <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 <DataTable
columns={columns} columns={columns}
data={items} data={items}
readOnly
enableRowSelection
enablePagination enablePagination
enableRowSelection
manualPagination manualPagination
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={total_items}
onPageChange={onPageChange} onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange} onPageSizeChange={onPageSizeChange}
onRowClick={() => null /*handleRowClick*/} onRowClick={() => null /*handleRowClick*/}
pageIndex={pageIndex}
pageSize={pageSize}
readOnly
totalItems={total_items}
/> />
</div> </div>
@ -129,4 +134,4 @@ export const CustomersListGrid = ({
</div> </div>
</div> </div>
); );
}; };

View File

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

View File

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