Facturas de cliente
This commit is contained in:
parent
fe1faaab4d
commit
220cc4c3a4
@ -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
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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`)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./verifactu-status-badge";
|
||||||
@ -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";
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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 }) => {
|
||||||
|
|||||||
@ -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">
|
<style type="text/css">{{ asset 'tailwind.min.css' }}</style>
|
||||||
/* ---------------------------- */
|
|
||||||
/* ESTRUCTURA CABECERA */
|
|
||||||
/* ---------------------------- */
|
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
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"> </th>
|
<th style="padding-top:0.5rem; padding-bottom:0.5rem;"> </th>
|
||||||
<th class="py-2">Imp. total</th>
|
<th style="padding-top:0.5rem; padding-bottom:0.5rem;">Imp. 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}} {{/if}}</td>
|
<td style="text-align:right;">{{#if quantity}}{{quantity}}{{else}} {{/if}}</td>
|
||||||
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}} {{/if}}</td>
|
<td style="text-align:right;">{{#if unit_amount}}{{unit_amount}}{{else}} {{/if}}</td>
|
||||||
<td class="text-right">{{#if discount_percentage}}{{discount_percentage}}{{else}} {{/if}}</td>
|
<td style="text-align:right;">{{#if discount_percentage}}{{discount_percentage}}{{else}} {{/if}}</td>
|
||||||
<td class="text-right">{{#if taxable_amount}}{{taxable_amount}}{{else}} {{/if}}</td>
|
<td style="text-align:right;">{{#if taxable_amount}}{{taxable_amount}}{{else}} {{/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>
|
||||||
1
modules/customer-invoices/templates/acana/tailwind.min.css
vendored
Normal file
1
modules/customer-invoices/templates/acana/tailwind.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -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 /> },
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user