Facturas de cliente

This commit is contained in:
David Arranz 2025-11-21 19:42:17 +01:00
parent 747d11a956
commit 169de55b0a
101 changed files with 1538 additions and 971 deletions

View File

@ -33,7 +33,7 @@
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "vscode.json-language-features"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"

View File

@ -173,7 +173,7 @@
"noThisInStatic": "error",
"noUselessCatch": "error",
"noUselessConstructor": "error",
"noUselessFragments": "error",
"noUselessFragments": "off",
"noUselessLabel": "error",
"noUselessRename": "error",
"noUselessSwitchCase": "error",
@ -184,7 +184,7 @@
"useLiteralKeys": "error",
"useOptionalChain": "error",
"useSimpleNumberKeys": "error",
"useSimplifiedLogicExpression": "error"
"useSimplifiedLogicExpression": "info"
},
"security": {
"noDangerouslySetInnerHtml": "error",

View File

@ -7,7 +7,7 @@ export function mockUser(req: RequestWithAuth, _res: Response, next: NextFunctio
req.user = {
userId: UniqueID.create("9e4dc5b3-96b9-4968-9490-14bd032fec5f").data,
email: EmailAddress.create("dev@example.com").data,
companyId: UniqueID.create("5e4dc5b3-96b9-4968-9490-14bd032fec5f").data,
companyId: UniqueID.create("019a9667-6a65-767a-a737-48234ee50a3a").data,
companySlug: "acana",
roles: ["admin"],
};

View File

@ -58,6 +58,7 @@ const format = (
// Respetar fracciones si no vienen dadas en options.
const nfOptions: Intl.NumberFormatOptions = {
style: "currency",
useGrouping: true,
currency: normalizedDTO.currency_code,
minimumFractionDigits: options?.minimumFractionDigits ?? scale,
maximumFractionDigits: options?.maximumFractionDigits ?? scale,

View File

@ -41,7 +41,7 @@ const toNumericString = (dto?: PercentageDTO | null, fallbackScale = 2): string
const format = (
dto: PercentageDTO,
locale?: string,
options?: Intl.NumberFormatOptions,
options?: { hideZeros?: boolean } & Intl.NumberFormatOptions,
fallbackScale = 2
): string => {
if (isEmptyPercentageDTO(dto)) {
@ -59,6 +59,9 @@ const format = (
};
const absolute = toNumber(dto, fallbackScale); // ej. 12.5
if (absolute === 0 && options?.hideZeros) return "";
const fraction = absolute / 100; // ej. 0.125 para Intl percent
return new Intl.NumberFormat(locale, nfOptions).format(fraction);

View File

@ -1,5 +1,7 @@
import { AxiosInstance } from "axios";
import { ICustomParams, IDataSource } from "../datasource.interface";
import type { AxiosInstance } from "axios";
import type { ICustomParams, IDataSource } from "../datasource.interface";
import { defaultAxiosRequestConfig } from "./create-axios-instance";
/**

View File

@ -58,6 +58,7 @@
"puppeteer": "^24.30.0",
"react-hook-form": "^7.58.1",
"react-i18next": "^15.5.1",
"react-qr-code": "^2.0.18",
"react-router-dom": "^6.26.0",
"sequelize": "^6.37.5",
"zod": "^4.1.11"

View File

@ -55,7 +55,11 @@ export class IssuedInvoiceReportPresenter extends Presenter<
locale,
moneyOptions
),
discount_percentage: PercentageDTOHelper.format(issuedInvoiceDTO.discount_percentage, locale),
discount_percentage: PercentageDTOHelper.format(
issuedInvoiceDTO.discount_percentage,
locale,
{ hideZeros: true }
),
discount_amount: MoneyDTOHelper.format(
issuedInvoiceDTO.discount_amount,
locale,

View File

@ -23,6 +23,9 @@ export class IssuedInvoiceReportHTMLPresenter extends TemplatePresenter {
const invoiceDTO = dtoPresenter.toOutput(invoice);
const prettyDTO = prePresenter.toOutput(invoiceDTO);
console.log(prettyDTO.verifactu);
// Obtener y compilar la plantilla HTML
const template = this.templateResolver.compileTemplate(
"customer-invoices",

View File

@ -23,6 +23,8 @@ export class IssuedInvoiceReportPDFPresenter extends Presenter<
format: "HTML",
}) as IssuedInvoiceReportHTMLPresenter;
console.log(invoice);
const htmlData = htmlPresenter.toOutput(invoice, params);
// Generar el PDF con Puppeteer
@ -33,8 +35,8 @@ export class IssuedInvoiceReportPDFPresenter extends Presenter<
});
const page = await browser.newPage();
//page.setDefaultNavigationTimeout(60000);
//page.setDefaultTimeout(60000);
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(60000);
await page.setContent(htmlData, {
waitUntil: "networkidle2",

View File

@ -33,8 +33,8 @@ export class ProformaReportPDFPresenter extends Presenter<
});
const page = await browser.newPage();
//page.setDefaultNavigationTimeout(60000);
//page.setDefaultTimeout(60000);
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(60000);
await page.setContent(htmlData, {
waitUntil: "networkidle2",

View File

@ -1,4 +1,5 @@
import { Tax, Taxes } from "@erp/core/api";
import { type Tax, Taxes } from "@erp/core/api";
import { ItemAmount } from "../../value-objects";
export type ItemTaxTotal = {

View File

@ -360,7 +360,7 @@ export class CustomerInvoiceRepository
model: VerifactuRecordModel,
as: "verifactu",
required: false,
attributes: ["id", "estado", "url", "uuid"],
attributes: ["id", "estado", "url", "uuid", "qr"],
},
{
model: CustomerModel,
@ -578,7 +578,7 @@ export class CustomerInvoiceRepository
model: VerifactuRecordModel,
as: "verifactu",
required: false,
attributes: ["id", "estado", "url", "uuid"],
attributes: ["id", "estado", "url", "uuid", "qr"],
},
{
model: CustomerModel,

View File

@ -57,7 +57,9 @@ export class CustomerInvoiceItemTaxModel extends Model<
});
}
static hooks(_database: Sequelize) {}
static hooks(_database: Sequelize) {
//
}
}
export default (database: Sequelize) => {

View File

@ -421,7 +421,7 @@ export default (database: Sequelize) => {
{
name: "idx_invoice_company_series_number",
fields: ["company_id", "series", "invoice_number"],
fields: ["company_id", "series", "invoice_number", "is_proforma"],
unique: true,
}, // <- para consulta get

View File

@ -21,7 +21,7 @@ export class VerifactuRecordModel extends Model<
declare estado: string;
declare url: CreationOptional<string>;
declare qr: CreationOptional<Blob>;
declare qr: CreationOptional<string>;
declare uuid: CreationOptional<string>;
declare operacion: CreationOptional<string>;
@ -77,7 +77,7 @@ export default (database: Sequelize) => {
},
qr: {
type: new DataTypes.BLOB(),
type: new DataTypes.TEXT(),
allowNull: false,
defaultValue: "",
},

View File

@ -50,6 +50,17 @@
}
}
},
"proformas": {
"delete_proforma_dialog": {
"title": "Delete proforma",
"description": "Are you sure you want to delete proforma <strong>{{proformaRef}}</strong>? This action cannot be undone.",
"cancel": "Cancel",
"delete": "Delete",
"deleting": "Deleting...",
"success_title": "Proforma deleted",
"error_title": "Error deleting proforma"
}
},
"pages": {
"proformas": {
"title": "Proformas",

View File

@ -49,6 +49,17 @@
}
}
},
"proformas": {
"delete_proforma_dialog": {
"title": "Eliminar proforma",
"description": "¿Seguro que deseas eliminar la proforma <strong>{{proformaRef}}</strong>? Esta acción no se puede deshacer.",
"cancel": "Cancelar",
"delete": "Eliminar",
"deleting": "Eliminando...",
"success_title": "Proforma eliminada",
"error_title": "Error al eliminar la proforma"
}
},
"pages": {
"proformas": {
"title": "Proformas",

View File

@ -11,7 +11,7 @@ const IssuedInvoicesLayout = lazy(() =>
);
const ProformasListPage = lazy(() =>
import("./proformas/pages").then((m) => ({ default: m.ProformaListPage }))
import("./proformas/list").then((m) => ({ default: m.ProformaListPage }))
);
const IssuedInvoiceListPage = lazy(() =>

View File

@ -7,7 +7,7 @@ import {
calculateInvoiceHeaderAmounts,
calculateInvoiceItemAmounts,
} from "../../domain";
import type { ProformaFormData } from "../../proformas/schema";
import type { ProformaFormData } from "../../proformas/types";
import type { InvoiceFormData, InvoiceItemFormData } from "../../schemas";
export type UseProformaAutoRecalcParams = {

View File

@ -3,7 +3,7 @@ import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { CreateProformaRequestSchema } from "../../common";
import type { Proforma } from "../proformas/schema/proforma.api.schema";
import type { Proforma } from "../proformas/types/proforma.api.schema";
import type { InvoiceFormData } from "../schemas";
type CreateCustomerInvoicePayload = {

View File

@ -1,7 +1,12 @@
import { KeyPrefix, Namespace, i18n } from "i18next";
import { UseTranslationResponse, useTranslation as useI18NextTranslation } from "react-i18next";
import type { KeyPrefix, Namespace, i18n } from "i18next";
import {
type UseTranslationResponse,
useTranslation as useI18NextTranslation,
} from "react-i18next";
import enResources from "../common/locales/en.json";
import esResources from "../common/locales/es.json";
import { MODULE_NAME } from "./manifest";
const addMissingBundles = (i18n: i18n) => {

View File

@ -12,8 +12,9 @@ import {
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import { DownloadIcon, MailIcon, MoreVerticalIcon } from "lucide-react";
import { DownloadIcon, MailIcon, MoreVerticalIcon, QrCodeIcon } from "lucide-react";
import * as React from "react";
import QRCode from "react-qr-code";
import { useTranslation } from "../../../../i18n";
import type { IssuedInvoiceSummaryData } from "../../../schema";
@ -84,38 +85,39 @@ export function useIssuedInvoicesGridColumns(
/>
),
accessorFn: (row) => row.verifactu.qr_code, // para ordenar/buscar por nombre
cell: ({ row }) => (
<div className="font-medium text-left">{row.original.verifactu.qr_code}</div>
),
cell: ({ row }) => {
const { verifactu } = row.original;
const isPending = verifactu.status === "Pendiente";
console.log(verifactu.status);
return (
<>
{isPending ? (
<QrCodeIcon className="size-8 text-muted-foreground" />
) : (
<Tooltip>
<TooltipTrigger>
<a href={verifactu.url} rel="noopener" target="_blank">
<QrCodeIcon className="size-8" />
</a>
</TooltipTrigger>
<TooltipContent className="m-0 p-3">
<QRCode className="bg-white p-8" value={verifactu.url} />
</TooltipContent>
</Tooltip>
)}
</>
);
},
enableHiding: false,
enableSorting: false,
size: 140,
minSize: 120,
maxSize: 64,
size: 64,
minSize: 64,
meta: {
title: t("pages.issued_invoices.list.grid_columns.verifactu_qr_code"),
},
},
{
id: "verifactu_url",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.issued_invoices.list.grid_columns.verifactu_url")}
/>
),
accessorFn: (row) => row.verifactu.url, // para ordenar/buscar por nombre
cell: ({ row }) => (
<div className="font-medium text-left">{row.original.verifactu.url}</div>
),
enableHiding: false,
enableSorting: false,
size: 140,
minSize: 120,
meta: {
title: t("pages.issued_invoices.list.grid_columns.verifactu_url"),
},
},
{
id: "recipient",
header: ({ column }) => (

View File

@ -5,7 +5,7 @@ import {
type TaxCatalogProvider,
} from "@erp/core";
import type { Proforma, ProformaFormData, UpdateProformaInput } from "../schema";
import type { Proforma, ProformaFormData, UpdateProformaInput } from "../types";
export type ProformaDtoAdapterContext = {
taxCatalog: TaxCatalogProvider;

View File

@ -1,10 +1,10 @@
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
import type { ProformaSummaryPage } from "../schema/proforma.api.schema";
import type { ProformaSummaryPage } from "../types/proforma.api.schema";
import type {
ProformaSummaryData,
ProformaSummaryPageData,
} from "../schema/proforma-summary.web.schema";
} from "../types/proforma-summary.web.schema";
/**
* Convierte el DTO completo de API a datos numéricos para el formulario.

View File

@ -0,0 +1,17 @@
import type { IDataSource } from "@erp/core/client";
export interface ChangeStatusResponse {
success: boolean;
}
export async function changeProformaStatusApi(
dataSource: IDataSource,
proformaId: string,
newStatus: string
): Promise<ChangeStatusResponse> {
return dataSource.custom<ChangeStatusResponse>({
path: `proformas/${proformaId}/status`,
method: "patch",
data: { new_status: newStatus },
});
}

View File

@ -0,0 +1 @@
export * from "./change-proforma-status.api";

View File

@ -0,0 +1 @@
export * from "./use-change-status-dialog-controller";

View File

@ -0,0 +1,75 @@
import { showErrorToast } from "@repo/rdx-ui/helpers";
import * as React from "react";
import type { ProformaSummaryData } from "../../types";
import { useChangeProformaStatus } from "../hooks/use-change-proforma-status";
type ProformasType = {
id: string;
status: string;
}[];
interface ChangeStatusDialogState {
open: boolean;
targetStatus: string | null;
proformas: ProformaSummaryData[];
loading: boolean;
}
export function useChangeStatusDialogController() {
const { changeStatus } = useChangeProformaStatus();
const [state, setState] = React.useState<ChangeStatusDialogState>({
open: false,
targetStatus: null,
proformas: [],
loading: false,
});
const openDialog = (proformas: ProformaSummaryData[]) => {
setState({
open: true,
targetStatus: null,
proformas,
loading: false,
});
};
const closeDialog = () => {
setState((s) => ({ ...s, open: false }));
};
const setTargetStatus = (status: string | null) => {
setState((s) => ({ ...s, targetStatus: status }));
};
const confirmChangeStatus = async () => {
if (!state.targetStatus || state.proformas.length === 0) return;
setState((s) => ({ ...s, loading: true }));
for (const proforma of state.proformas) {
await changeStatus(proforma.id.toString(), state.targetStatus, {
onError: (err: unknown) => {
const error = err as Error;
showErrorToast("Error cambiando estado", error.message);
},
});
}
setState((s) => ({ ...s, loading: false }));
closeDialog();
};
return {
open: state.open,
proformas: state.proformas,
targetStatus: state.targetStatus,
isSubmitting: state.loading,
openDialog,
closeDialog,
setTargetStatus,
confirmChangeStatus,
};
}

View File

@ -0,0 +1 @@
export * from "./use-change-proforma-status";

View File

@ -0,0 +1,51 @@
import { useDataSource } from "@erp/core/hooks";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { PROFORMA_STATUS } from "../../types";
import { changeProformaStatusApi } from "../api/change-proforma-status.api";
interface ChangeProformaStatusOptions {
onSuccess?: () => void;
onError?: (err: unknown) => void;
onLoadingChange?: (loading: boolean) => void;
}
interface ChangeProformaStatusPayload {
proformaId: string;
newStatus: PROFORMA_STATUS;
}
export function useChangeProformaStatus() {
const dataSource = useDataSource();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({ proformaId, newStatus }: ChangeProformaStatusPayload) =>
changeProformaStatusApi(dataSource, proformaId, newStatus),
onSuccess() {
queryClient.invalidateQueries({ queryKey: ["proformas"] });
},
});
async function changeStatus(
proformaId: string,
newStatus: string,
opts?: ChangeProformaStatusOptions
) {
try {
opts?.onLoadingChange?.(true);
await mutation.mutateAsync({ proformaId, newStatus: newStatus as PROFORMA_STATUS });
opts?.onSuccess?.();
} catch (err) {
opts?.onError?.(err);
} finally {
opts?.onLoadingChange?.(false);
}
}
return {
changeStatus,
isPending: mutation.isPending,
};
}

View File

@ -0,0 +1,2 @@
export * from "./controllers";
export * from "./ui";

View File

@ -0,0 +1,300 @@
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Spinner,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
CheckCircle2Icon,
ChevronRightIcon,
FileCheckIcon,
FileTextIcon,
SendIcon,
XCircleIcon,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "../../../i18n";
import { PROFORMA_STATUS, PROFORMA_STATUS_TRANSITIONS } from "../../types";
interface ChangeStatusDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
proformas: { id: string; status: string }[];
targetStatus?: string;
isSubmitting: boolean;
onConfirm: () => void;
}
const STATUS_FLOW = [
{
status: PROFORMA_STATUS.DRAFT,
icon: FileTextIcon,
color: "text-gray-500",
bgColor: "bg-gray-100",
borderColor: "border-gray-300",
},
{
status: PROFORMA_STATUS.SENT,
icon: SendIcon,
color: "text-blue-600",
bgColor: "bg-blue-100",
borderColor: "border-blue-300",
},
{
status: PROFORMA_STATUS.APPROVED,
icon: CheckCircle2Icon,
color: "text-green-600",
bgColor: "bg-green-100",
borderColor: "border-green-300",
},
{
status: PROFORMA_STATUS.REJECTED,
icon: XCircleIcon,
color: "text-red-600",
bgColor: "bg-red-100",
borderColor: "border-red-300",
},
{
status: PROFORMA_STATUS.ISSUED,
icon: FileCheckIcon,
color: "text-purple-600",
bgColor: "bg-purple-100",
borderColor: "border-purple-300",
},
] as const;
export function ChangeStatusDialog({
open,
onOpenChange,
proformas,
isSubmitting,
onConfirm,
}: ChangeStatusDialogProps) {
const { t } = useTranslation();
const [selectedStatus, setSelectedStatus] = useState<PROFORMA_STATUS | null>(null);
// Obtener estados disponibles comunes para todas las proformas seleccionadas
const getAvailableStatuses = () => {
if (!proformas || proformas.length === 0) return [];
// Intersección de estados disponibles para todas las proformas
const firstProforma = proformas[0];
let availableStatuses =
PROFORMA_STATUS_TRANSITIONS[firstProforma.status as PROFORMA_STATUS] || [];
for (let i = 1; i < proformas.length; i++) {
const currentStatuses =
PROFORMA_STATUS_TRANSITIONS[proformas[i].status as PROFORMA_STATUS] || [];
availableStatuses = availableStatuses.filter((status) => currentStatuses.includes(status));
}
return availableStatuses;
};
const availableStatuses = getAvailableStatuses();
const currentStatus = proformas.length === 1 ? proformas[0].status : null;
const getStatusType = (
status: PROFORMA_STATUS
): "past" | "current" | "available" | "unavailable" => {
if (currentStatus === status) return "current";
// Si no hay un estado actual único, solo mostrar disponibles
if (!currentStatus) {
return availableStatuses.includes(status) ? "available" : "unavailable";
}
// Determinar si es pasado basado en el flujo lógico
const currentIndex = STATUS_FLOW.findIndex((s) => s.status === currentStatus);
const statusIndex = STATUS_FLOW.findIndex((s) => s.status === status);
if (statusIndex < currentIndex) return "past";
if (availableStatuses.includes(status)) return "available";
return "unavailable";
};
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>Cambiar estado de la proforma</DialogTitle>
<DialogDescription>
{proformas.length === 1
? `Selecciona el nuevo estado para la proforma #${proformas[0].id}`
: `Selecciona el nuevo estado para ${proformas.length} proformas seleccionadas`}
</DialogDescription>
</DialogHeader>
{availableStatuses.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No hay transiciones de estado disponibles para las proformas seleccionadas.
</div>
) : (
<div className="py-6">
<div className="relative">
{/* Línea de conexión */}
<div className="absolute top-12 left-0 right-0 h-0.5 bg-gray-200" />
{/* Estados */}
<div className="relative grid grid-cols-5 gap-2">
{STATUS_FLOW.map((statusConfig, index) => {
const statusType = getStatusType(statusConfig.status);
const Icon = statusConfig.icon;
const isSelected = selectedStatus === statusConfig.status;
const isClickable = statusType === "available";
return (
<div className="flex flex-col items-center" key={statusConfig.status}>
{/* Botón de estado */}
<button
className={cn(
"relative z-10 flex size-24 flex-col items-center justify-center rounded-xl border-2 transition-all",
// Estado pasado
statusType === "past" && ["border-gray-200 bg-gray-50", "opacity-60"],
// Estado actual
statusType === "current" && [
statusConfig.borderColor,
statusConfig.bgColor,
"ring-2 ring-offset-2",
`ring-${statusConfig.color.split("-")[1]}-200`,
],
// Estado disponible para seleccionar
statusType === "available" && [
isSelected
? [
statusConfig.borderColor,
statusConfig.bgColor,
"ring-2 ring-offset-2",
`ring-${statusConfig.color.split("-")[1]}-300`,
"scale-105",
]
: [
"border-gray-300 bg-white hover:border-gray-400",
"hover:scale-105 cursor-pointer",
],
],
// Estado no disponible
statusType === "unavailable" && [
"border-gray-200 bg-gray-50",
"opacity-40 cursor-not-allowed",
]
)}
disabled={!isClickable}
onClick={() => isClickable && setSelectedStatus(statusConfig.status)}
type="button"
>
<Icon
className={cn(
"size-8 mb-1",
statusType === "past" && "text-gray-400",
statusType === "current" && statusConfig.color,
statusType === "available" &&
(isSelected ? statusConfig.color : "text-gray-600"),
statusType === "unavailable" && "text-gray-300"
)}
/>
{/* Indicador de estado actual */}
{statusType === "current" && (
<div
className={cn(
"absolute -top-2 -right-2 size-6 rounded-full border-2 border-white flex items-center justify-center",
statusConfig.bgColor
)}
>
<div
className={cn(
"size-2 rounded-full",
statusConfig.color.replace("text-", "bg-")
)}
/>
</div>
)}
{/* Check de selección */}
{isSelected && statusType !== "current" && (
<div className="absolute -top-2 -right-2 size-6 rounded-full bg-blue-600 border-2 border-white flex items-center justify-center">
<CheckCircle2Icon className="size-4 text-white" />
</div>
)}
</button>
{/* Etiqueta y descripción */}
<div className="mt-3 text-center space-y-1 px-1">
<p
className={cn(
"text-sm font-medium",
statusType === "past" && "text-gray-500",
statusType === "current" && statusConfig.color,
statusType === "available" &&
(isSelected ? statusConfig.color : "text-gray-700"),
statusType === "unavailable" && "text-gray-400"
)}
>
{t(`catalog.proformas.status.${statusConfig.status}`)}
</p>
<p className="text-xs text-muted-foreground line-clamp-2">
{t(`catalog.proformas.status.${statusConfig.status}.description`)}
</p>
</div>
{/* Flecha de conexión (excepto el último) */}
{index < STATUS_FLOW.length - 1 && (
<ChevronRightIcon className="absolute top-10 -right-4 z-20 size-5 text-gray-300" />
)}
</div>
);
})}
</div>
</div>
<div className="mt-8 p-4 bg-muted/50 rounded-lg">
<div className="flex items-start gap-6 text-sm">
{currentStatus && (
<div className="flex items-center gap-2">
<div className="size-3 rounded-full bg-blue-600 ring-2 ring-blue-200" />
<span className="text-muted-foreground">Estado actual</span>
</div>
)}
<div className="flex items-center gap-2">
<div className="size-3 rounded-full border-2 border-gray-400 bg-white" />
<span className="text-muted-foreground">Estados disponibles</span>
</div>
<div className="flex items-center gap-2">
<div className="size-3 rounded-full bg-gray-300" />
<span className="text-muted-foreground">Estados no disponibles</span>
</div>
</div>
</div>
</div>
)}
<DialogFooter>
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
Cancelar
</Button>
<Button
disabled={!selectedStatus || availableStatuses.length === 0 || isSubmitting}
onClick={onConfirm}
>
{isSubmitting ? (
<>
<Spinner className="mr-2 size-4" />
Cambiando...
</>
) : (
"Cambiar estado"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1 @@
export * from "./change-status-dialog";

View File

@ -0,0 +1,8 @@
import type { IDataSource } from "@erp/core/client";
export async function deleteProformaApi(
dataSource: IDataSource,
proformaId: string
): Promise<void> {
await dataSource.deleteOne("proformas", proformaId);
}

View File

@ -0,0 +1 @@
export * from "./delete-proforma.api";

View File

@ -0,0 +1 @@
export * from "./use-delete-proforma-dialog-controller";

View File

@ -0,0 +1,59 @@
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import React from "react";
import { useTranslation } from "../../../i18n";
import type { ProformaSummaryData } from "../../types";
import { useDeleteProforma } from "../hooks";
interface ProformaState {
open: boolean;
proforma: ProformaSummaryData | null;
}
export function useDeleteProformaDialogController() {
const { t } = useTranslation();
const { mutate, isPending } = useDeleteProforma();
const [state, setState] = React.useState<ProformaState>({
open: false,
proforma: null,
});
const openDialog = (proforma: ProformaSummaryData) => {
setState({ open: true, proforma });
};
const closeDialog = () => {
setState((s) => ({ ...s, open: false }));
};
const confirmDelete = () => {
if (!state.proforma) return;
mutate(
{ proformaId: state.proforma.id },
{
onSuccess(data, variables, onMutateResult, context) {
console.log("adios");
console.log(data);
showSuccessToast(t("proformas.delete_proforma_dialog.success_title"));
closeDialog();
},
onError(error, variables, onMutateResult, context) {
showErrorToast(t("proformas.delete_proforma_dialog.error_title"), error.message);
},
}
);
};
return {
open: state.open,
proforma: state.proforma,
isSubmitting: isPending,
openDialog,
closeDialog,
confirmDelete,
};
}

View File

@ -0,0 +1 @@
export * from "./use-delete-proforma";

View File

@ -0,0 +1,23 @@
import { useDataSource } from "@erp/core/hooks";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { deleteProformaApi } from "../api";
interface useDeleteProformaPayload {
proformaId: string;
}
export function useDeleteProforma() {
const dataSource = useDataSource();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ proformaId }: useDeleteProformaPayload) =>
deleteProformaApi(dataSource, proformaId),
onSuccess() {
console.log("hola");
queryClient.invalidateQueries({ queryKey: ["proformas"] });
},
});
}

View File

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

View File

@ -0,0 +1,63 @@
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Spinner,
} from "@repo/shadcn-ui/components";
import { Trans } from "react-i18next";
import { useTranslation } from "../../../i18n";
interface DeleteProformaDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
proformaRef?: string;
isSubmitting: boolean;
onConfirm: () => void;
}
export function DeleteProformaDialog({
open,
onOpenChange,
proformaRef,
isSubmitting,
onConfirm,
}: DeleteProformaDialogProps) {
const { t } = useTranslation();
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("proformas.delete_proforma_dialog.title")}</AlertDialogTitle>
<AlertDialogDescription>
<Trans
i18nKey={t("proformas.delete_proforma_dialog.description")}
values={{ proformaRef }}
/>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
{t("proformas.delete_proforma_dialog.cancel")}
</Button>
<Button disabled={isSubmitting} onClick={onConfirm} variant="destructive">
{isSubmitting ? (
<>
<Spinner className="mr-2 size-4" />
{t("proformas.delete_proforma_dialog.deleting")}
</>
) : (
<>{t("proformas.delete_proforma_dialog.delete")}</>
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -0,0 +1 @@
export * from "./delete-proforma-dialog";

View File

@ -1,4 +1,4 @@
export * from "./use-proforma-items-columns";
export * from "./use-issue-proforma-invoice";
export * from "./use-proforma-query";
export * from "./use-proforma-update-mutation";
export * from "./use-proformas-query";

View File

@ -1,4 +1,10 @@
import { useDataSource } from "@erp/core/hooks";
11111import
{
useDataSource;
}
from;
("@erp/core/hooks");
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { InvoiceFormData } from "../schemas";

View File

@ -0,0 +1,26 @@
// hooks/use-issue-proforma-invoice.ts
import { useDataSource } from "@erp/core/hooks";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export const ISSUE_PROFORMA_INVOICE_KEY = ["proformas", "issue"] as const;
interface IssueProformaInvoicePayload {
proformaId: string;
}
export function useIssueProformaInvoice() {
const dataSource = useDataSource();
const queryClient = useQueryClient();
return useMutation({
mutationKey: ISSUE_PROFORMA_INVOICE_KEY,
mutationFn: ({ proformaId }: IssueProformaInvoicePayload) =>
issueProformaInvoiceApi(dataSource, proformaId),
onSuccess() {
queryClient.invalidateQueries({ queryKey: ["proformas"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
},
});
}

View File

@ -1,7 +1,7 @@
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
import type { Proforma } from "../schema/proforma.api.schema";
import type { Proforma } from "../types/proforma.api.schema";
export const PROFORMA_QUERY_KEY = (id: string): QueryKey => ["proforma", id] as const;

View File

@ -3,7 +3,7 @@ import { useDataSource } from "@erp/core/hooks";
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
import type { ProformaSummaryPage } from "../schema/proforma.api.schema";
import type { ProformaSummaryPage } from "../types/proforma.api.schema";
export const PROFORMAS_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [
"proforma",

View File

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

View File

@ -0,0 +1 @@
export * from "./issue-proforma-invoice.api";

View File

@ -0,0 +1,18 @@
import type { IDataSource } from "@erp/core/client";
export interface IssueInvoiceResponse {
invoiceId: string;
proformaId: string;
customerId: string;
}
export async function issueProformaInvoiceApi(
dataSource: IDataSource,
proformaId: string
): Promise<IssueInvoiceResponse> {
return dataSource.custom<IssueInvoiceResponse>({
path: `proformas/${proformaId}/issue`,
method: "put",
data: {},
});
}

View File

@ -0,0 +1 @@
export * from "./use-issue-proforma-dialog.controller";

View File

@ -0,0 +1,52 @@
import * as React from "react";
import type { ProformaSummaryData } from "../../types";
import { useIssueProformaMutation } from "../hooks/use-issue-proforma-mutation";
interface State {
open: boolean;
proforma: ProformaSummaryData | null;
}
export function useProformaIssueDialogController() {
const { mutate, isPending } = useIssueProformaMutation();
const [state, setState] = React.useState<State>({
open: false,
proforma: null,
});
// abrir diálogo
const openDialog = (p: ProformaSummaryData) => {
setState({ open: true, proforma: p });
};
// cerrar diálogo
const closeDialog = () => {
setState((s) => ({ ...s, open: false }));
};
// confirmar emisión
const confirmIssue = () => {
if (!state.proforma) return;
mutate(
{ proformaId: state.proforma.id },
{
onSuccess() {
closeDialog();
},
}
);
};
return {
open: state.open,
proforma: state.proforma,
isSubmitting: isPending,
openDialog,
closeDialog,
confirmIssue,
};
}

View File

@ -0,0 +1,31 @@
import { useDataSource } from "@erp/core/hooks";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { issueProformaInvoiceApi } from "../api";
interface IssueProformaPayload {
proformaId: string;
}
interface IssueProformaResponse {
proformaId: string;
success: boolean;
}
export function useIssueProformaMutation() {
const dataSource = useDataSource();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ proformaId }: IssueProformaPayload) =>
issueProformaInvoiceApi(dataSource, proformaId),
onSuccess(_data, _vars, _ctx) {
// Refresca el listado de proformas
queryClient.invalidateQueries({ queryKey: ["proformas"] });
// Opcional: refrescar facturas si existe la feature
queryClient.invalidateQueries({ queryKey: ["invoices"] });
},
});
}

View File

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

View File

@ -0,0 +1 @@
export * from "./issue-proforma-dialog";

View File

@ -0,0 +1,62 @@
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Spinner,
} from "@repo/shadcn-ui/components";
import { useProformaIssueDialogController } from "../controllers";
interface ProformaIssueDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
proformaId: string;
proformaReference: string;
}
export function ProformaIssueDialog({
open,
onOpenChange,
proformaId,
proformaReference,
}: ProformaIssueDialogProps) {
const { issue, isSubmitting } = useProformaIssueDialogController();
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Emitir factura</AlertDialogTitle>
<AlertDialogDescription>
¿Seguro que quieres emitir la factura desde la proforma{" "}
<strong>{proformaReference}</strong>? Esta acción es irreversible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
Cancelar
</Button>
<Button
disabled={isSubmitting}
onClick={() => issue(proformaId, () => onOpenChange(false))}
>
{isSubmitting ? (
<>
<Spinner className="mr-2 size-4" />
Emitiendo...
</>
) : (
"Emitir factura"
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

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

View File

@ -0,0 +1,71 @@
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
import type {
ProformaSummaryData,
ProformaSummaryPage,
ProformaSummaryPageData,
} from "../../types";
/**
* Convierte el DTO completo de API a datos numéricos para el formulario.
*/
export const ProformaSummaryDtoAdapter = {
fromDto(pageDto: ProformaSummaryPage, context?: unknown): ProformaSummaryPageData {
return {
...pageDto,
items: pageDto.items.map(
(summaryDto) =>
({
...summaryDto,
subtotal_amount: MoneyDTOHelper.toNumber(summaryDto.subtotal_amount),
subtotal_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.subtotal_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
discount_percentage: PercentageDTOHelper.toNumber(summaryDto.discount_percentage),
discount_percentage_fmt: PercentageDTOHelper.toNumericString(
summaryDto.discount_percentage
),
discount_amount: MoneyDTOHelper.toNumber(summaryDto.discount_amount),
discount_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.discount_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
taxable_amount: MoneyDTOHelper.toNumber(summaryDto.taxable_amount),
taxable_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.taxable_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
taxes_amount: MoneyDTOHelper.toNumber(summaryDto.taxes_amount),
taxes_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.taxes_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
total_amount: MoneyDTOHelper.toNumber(summaryDto.total_amount),
total_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.total_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code
),
//taxes: dto.taxes,
}) as unknown as ProformaSummaryData
),
};
},
};

View File

@ -0,0 +1,18 @@
import type { CriteriaDTO } from "@erp/core";
import type { IDataSource } from "@erp/core/client";
import type { ProformaSummaryPage } from "../../types";
export async function getProformaListApi(
dataSource: IDataSource,
signal: AbortSignal,
criteria: CriteriaDTO
) {
const response = dataSource.getList<ProformaSummaryPage>("proformas", {
signal,
...criteria,
});
//return mapProformaList(raw);
return response;
}

View File

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

View File

@ -0,0 +1,2 @@
export * from "./use-proforma-list.controller.ts";
export * from "./use-proforma-list-page.controller.ts";

View File

@ -0,0 +1,64 @@
import React from "react";
import { useChangeStatusDialogController } from "../../change-status";
import { useDeleteProformaDialogController } from "../../delete";
import { useProformaIssueDialogController } from "../../issue-proforma";
import {
type PROFORMA_STATUS,
PROFORMA_STATUS_TRANSITIONS,
type ProformaSummaryData,
} from "../../types";
import { useProformaListController } from "./use-proforma-list.controller";
export function useProformaListPageController() {
const listCtrl = useProformaListController();
// Controlador de diálogos
const issueDialogCtrl = useProformaIssueDialogController();
const changeStatusDialogCtrl = useChangeStatusDialogController();
const deleteDialogCtrl = useDeleteProformaDialogController();
const handleIssueProforma = React.useCallback(
(proforma: ProformaSummaryData) => {
// Solo si approved → issued
issueDialogCtrl.openDialog(proforma);
},
[issueDialogCtrl]
);
const handleChangeStatusProforma = React.useCallback(
(proforma: ProformaSummaryData, nextStatus: string) => {
const proforma_status = proforma.status as PROFORMA_STATUS;
const transitions = PROFORMA_STATUS_TRANSITIONS[proforma_status] ?? [];
if (!transitions.includes(nextStatus as PROFORMA_STATUS)) {
console.warn(`Transición inválida: ${proforma.status}${nextStatus}`);
return;
}
changeStatusDialogCtrl.openDialog(proforma, nextStatus);
},
[changeStatusDialogCtrl]
);
const handleDeleteProforma = React.useCallback(
(proforma: ProformaSummaryData) => {
console.log(proforma);
deleteDialogCtrl.openDialog(proforma);
},
[deleteDialogCtrl]
);
return {
listCtrl,
issueDialogCtrl,
changeStatusDialogCtrl,
deleteDialogCtrl,
handleIssueProforma,
handleChangeStatusProforma,
handleDeleteProforma,
};
}

View File

@ -1,13 +1,11 @@
// src/modules/proformas/hooks/use-proformas-list.ts
import type { CriteriaDTO } from "@erp/core";
import { useDebounce } from "@repo/rdx-ui/components";
import { useMemo, useState } from "react";
import { ProformaSummaryDtoAdapter } from "../../../adapters/proforma-summary-dto.adapter";
import { useProformasQuery } from "../../../hooks";
import { ProformaSummaryDtoAdapter } from "../adapters";
import { useProformaListQuery } from "../hooks";
export const useProformasList = () => {
export const useProformaListController = () => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
@ -29,7 +27,7 @@ export const useProformasList = () => {
};
}, [pageSize, pageIndex, debouncedQ, status]);
const query = useProformasQuery({ criteria });
const query = useProformaListQuery({ criteria });
const data = useMemo(
() => (query.data ? ProformaSummaryDtoAdapter.fromDto(query.data) : undefined),
[query.data]

View File

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

View File

@ -0,0 +1,38 @@
import type { CriteriaDTO } from "@erp/core";
import { useDataSource } from "@erp/core/hooks";
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
import type { ProformaSummaryPage } from "../../types";
import { getProformaListApi } from "../api";
export const PROFORMAS_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [
"proforma",
{
pageNumber: criteria?.pageNumber ?? INITIAL_PAGE_INDEX,
pageSize: criteria?.pageSize ?? INITIAL_PAGE_SIZE,
q: criteria?.q ?? "",
filters: criteria?.filters ?? [],
orderBy: criteria?.orderBy ?? "",
order: criteria?.order ?? "",
},
];
type ProformasQueryOptions = {
enabled?: boolean;
criteria?: CriteriaDTO;
};
// Obtener todas las facturas
export const useProformaListQuery = (options?: ProformasQueryOptions) => {
const dataSource = useDataSource();
const enabled = options?.enabled ?? true;
const criteria = options?.criteria ?? {};
return useQuery<ProformaSummaryPage, DefaultError>({
queryKey: PROFORMAS_QUERY_KEY(criteria),
queryFn: async ({ signal }) => getProformaListApi(dataSource, signal, criteria),
enabled,
placeholderData: (previousData, _previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
});
};

View File

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

View File

@ -0,0 +1,3 @@
export * from "../../../issue-proforma/ui/issue-proforma-dialog";
export * from "./proformas-grid";

View File

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

View File

@ -1,27 +1,28 @@
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
import type { ProformaSummaryPageData } from "../../../schema/proforma-summary.web.schema";
import { useProformasGridColumns } from "../hooks";
import { useTranslation } from "../../../../../i18n";
import type { ProformaSummaryData, ProformaSummaryPageData } from "../../../../types";
interface ProformasGridProps {
data: ProformaSummaryPageData;
loading?: boolean;
loading: boolean;
columns: ColumnDef<ProformaSummaryData, unknown>[];
pageIndex: number;
pageSize: number;
searchValue: string;
onSearchChange: (v: string) => void;
onPageChange: (p: number) => void;
onPageSizeChange: (s: number) => void;
onRowClick?: (id: string) => void;
onExportClick?: () => void;
onStatusFilterChange?: (newStatus: string) => void;
onPageChange: (pageIndex: number) => void;
onPageSizeChange: (size: number) => void;
onRowClick?: (proformaId: string) => void;
}
export const ProformasGrid = ({
data,
loading,
columns,
pageIndex,
pageSize,
onPageChange,
@ -32,14 +33,6 @@ export const ProformasGrid = ({
const { t } = useTranslation();
const { items, total_items } = data;
const columns = useProformasGridColumns({
onEdit: (proforma) => navigate(`/proformas/${proforma.id}/edit`),
onDuplicate: (proforma) => null, //duplicateInvoice(inv.id),
onDownloadPdf: (proforma) => null, //downloadInvoicePdf(inv.id),
onSendEmail: (proforma) => null, //sendInvoiceEmail(inv.id),
onDelete: (proforma) => null, //confirmDelete(inv.id),
});
if (loading)
return (
<SkeletonDataTable
@ -55,6 +48,7 @@ export const ProformasGrid = ({
columns={columns}
data={items}
enablePagination
enableRowSelection
manualPagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}

View File

@ -17,25 +17,27 @@ import {
} from "lucide-react";
import * as React from "react";
import { useTranslation } from "../../../../i18n";
import type { ProformaSummaryData } from "../../../schema";
import { ProformaStatusBadge } from "../ui";
import { useTranslation } from "../../../../../i18n";
import {
PROFORMA_STATUS_TRANSITIONS,
type ProformaStatus,
type ProformaSummaryData,
} from "../../../../types";
import { ProformaStatusBadge } from "../../components";
type GridActionHandlers = {
onEdit?: (proforma: ProformaSummaryData) => void;
onDuplicate?: (proforma: ProformaSummaryData) => void;
onDownloadPdf?: (proforma: ProformaSummaryData) => void;
onSendEmail?: (proforma: ProformaSummaryData) => void;
onDelete?: (proforma: ProformaSummaryData) => void;
onEditClick?: (proforma: ProformaSummaryData) => void;
onIssueClick?: (proforma: ProformaSummaryData) => void;
onChangeStatusClick?: (proforma: ProformaSummaryData, nextStatus: string) => void;
onDeleteClick?: (proforma: ProformaSummaryData) => void;
};
export function useProformasGridColumns(
actionHandlers: GridActionHandlers = {}
): ColumnDef<ProformaSummaryData, unknown>[] {
const { t } = useTranslation();
const { onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete } = actionHandlers;
return React.useMemo<ColumnDef<ProformaSummaryData>[]>(
return React.useMemo<ColumnDef<ProformaSummaryData, unknown>[]>(
() => [
{
id: "select",
@ -75,29 +77,34 @@ export function useProformasGridColumns(
},
cell: ({ row }) => <div className="font-medium">{row.getValue("invoice_number")}</div>,
},
// Estado
{
accessorKey: "status",
header: "Estado",
cell: ({ row }) => {
const status = String(row.getValue("status"));
const isIssued = status === "issued";
const proforma = row.original;
const isIssued = proforma.status === "issued";
const invoiceId = proforma.linked_invoice_id;
return (
<div className="flex items-center gap-2">
<ProformaStatusBadge status={status} />
{isIssued && proforma.id && (
<ProformaStatusBadge status={proforma.status} />
{/* Enlace discreto a factura real */}
{isIssued && invoiceId && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button asChild className="size-6" size="icon" variant="ghost">
<a href={`/facturas/${proforma.id}`}>
<a href={`/facturas/${invoiceId}`}>
<ExternalLinkIcon className="size-3 text-muted-foreground" />
<span className="sr-only">Ver factura #{proforma.id}</span>
<span className="sr-only">Ver factura {invoiceId}</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Ver factura #{proforma.id}</TooltipContent>
<TooltipContent>Ver factura {invoiceId}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
@ -105,6 +112,8 @@ export function useProformasGridColumns(
);
},
},
// Cliente
{
accessorKey: "client_name",
header: ({ column }) => {
@ -124,7 +133,7 @@ export function useProformasGridColumns(
return (
<div>
<a
className="text-blue-600 hover:underline"
className="text-primary hover:underline"
href={`/customers/${proforma.customer_id}`}
>
{proforma.recipient.name}
@ -205,88 +214,105 @@ export function useProformasGridColumns(
{
id: "actions",
header: "Acciones",
enableSorting: false,
cell: ({ row }) => {
const proforma = row.original;
const isIssued = proforma.status === "issued";
const isApproved = proforma.status === "approved";
const availableTransitions =
PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? [];
return (
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button asChild className="size-8" size="icon" variant="ghost">
<a href={`/proformas/${proforma.id}`}>
<PencilIcon className="size-4" />
<span className="sr-only">Editar</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Editar</TooltipContent>
</Tooltip>
</TooltipProvider>
{!isIssued && (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="size-8"
onClick={() => {
row.toggleSelected(true);
//setChangeStatusOpen(true);
}}
className="size-8 cursor-pointer"
onClick={() => actionHandlers.onEditClick?.(proforma)}
size="icon"
variant="ghost"
>
<RefreshCwIcon className="size-4" />
<span className="sr-only">Cambiar estado</span>
<PencilIcon className="size-4" />
<span className="sr-only">Editar</span>
</Button>
</TooltipTrigger>
<TooltipContent>Cambiar estado</TooltipContent>
<TooltipContent>Editar</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isApproved && (
{/* Cambiar estado */}
{!isIssued &&
availableTransitions.map((next_status) => (
<TooltipProvider key={next_status}>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="size-8"
onClick={() => null /*handleIssueInvoice(proforma)*/}
className="size-8 cursor-pointer"
onClick={() =>
actionHandlers.onChangeStatusClick?.(proforma, next_status)
}
size="icon"
variant="ghost"
>
<FileTextIcon className="size-4" />
<span className="sr-only">Emitir a factura</span>
<RefreshCwIcon className="size-4" />
<span className="sr-only">Cambiar estado</span>
</Button>
</TooltipTrigger>
<TooltipContent>Emitir a factura</TooltipContent>
<TooltipContent>
Cambiar a {t(`catalog.proformas.status.${next_status}`)}
</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
))}
{/* Emitir factura: solo si approved */}
{!isIssued && proforma.status === "approved" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="size-8 text-destructive hover:text-destructive"
onClick={() => {
//setProformaToDelete(proforma.id);
//setDeleteDialogOpen(true);
className="size-8 cursor-pointer"
onClick={() => actionHandlers.onIssueClick?.(proforma)}
size="icon"
variant="ghost"
>
<FileTextIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Emitir a factura</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Eliminar */}
{!isIssued && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="size-8 text-destructive hover:text-destructive cursor-pointer"
onClick={(e) => {
e.preventDefault();
actionHandlers.onDeleteClick?.(proforma);
}}
size="icon"
variant="ghost"
>
<Trash2Icon className="size-4" />
<span className="sr-only">Eliminar</span>
</Button>
</TooltipTrigger>
<TooltipContent>Eliminar</TooltipContent>
</Tooltip>
</>
</TooltipProvider>
)}
</div>
);
},
},
],
[t, onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete]
[t, actionHandlers]
);
}

View File

@ -1,2 +1 @@
export * from "./proforma-status-badge";
export * from "./proformas-grid";

View File

@ -0,0 +1,30 @@
import { Badge } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { useTranslation } from "../../../../i18n";
import {
type ProformaStatus,
getProformaStatusButtonVariant,
getProformaStatusColor,
} from "../../../types";
export type ProformaStatusBadgeProps = {
status: string | ProformaStatus; // permitir cualquier valor
className?: string;
};
export const ProformaStatusBadge = ({ status, className }: ProformaStatusBadgeProps) => {
const { t } = useTranslation();
const normalizedStatus = status.toLowerCase() as ProformaStatus;
return (
<Badge
className={cn(getProformaStatusColor(normalizedStatus), "font-semibold", className)}
variant={getProformaStatusButtonVariant(normalizedStatus)}
>
{t(`catalog.proformas.status.${normalizedStatus}`, { defaultValue: status })}
</Badge>
);
};
ProformaStatusBadge.displayName = "ProformaStatusBadge";

View File

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

View File

@ -0,0 +1,148 @@
import { PageHeader, SimpleSearchInput } from "@erp/core/components";
import { ErrorAlert } from "@erp/customers/components";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { FilterIcon, PlusIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
import { ChangeStatusDialog } from "../../../change-status";
import { DeleteProformaDialog } from "../../../delete/ui";
import { useProformaListPageController } from "../../controllers";
import { ProformaIssueDialog } from "../blocks";
import { ProformasGrid } from "../blocks/proformas-grid";
import { useProformasGridColumns } from "../blocks/proformas-grid/use-proforma-grid-columns";
export const ProformaListPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const {
listCtrl,
issueDialogCtrl,
changeStatusDialogCtrl,
deleteDialogCtrl,
handleChangeStatusProforma,
handleDeleteProforma,
handleIssueProforma,
} = useProformaListPageController();
const columns = useProformasGridColumns({
onEditClick: (proforma) => navigate(`/proformas/${proforma.id}/edit`),
onIssueClick: handleIssueProforma,
onDeleteClick: handleDeleteProforma,
onChangeStatusClick: handleChangeStatusProforma,
});
if (listCtrl.isError || !listCtrl.data) {
return (
<AppContent>
<ErrorAlert
message={(listCtrl.error as Error)?.message || "Error al cargar el listado"}
title={t("pages.proformas.list.loadErrorTitle")}
/>
<BackHistoryButton />
</AppContent>
);
}
return (
<>
<AppHeader>
<PageHeader
description={t("pages.proformas.list.description")}
rightSlot={
<Button
aria-label={t("pages.proformas.create.title")}
onClick={() => navigate("/proformas/create")}
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.proformas.create.title")}
</Button>
}
title={t("pages.proformas.list.title")}
/>
</AppHeader>
<AppContent>
{/* Search and filters */}
<div className="flex items-center justify-between gap-16">
<SimpleSearchInput
loading={listCtrl.isLoading}
onSearchChange={listCtrl.setSearchValue}
/>
<Select defaultValue="all" onValueChange={listCtrl.setStatusFilter}>
<SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("catalog.proformas.status.all")}</SelectItem>
<SelectItem value="draft">{t("catalog.proformas.status.draft")}</SelectItem>
<SelectItem value="sent">{t("catalog.proformas.status.sent")}</SelectItem>
<SelectItem value="approved">{t("catalog.proformas.status.approved")}</SelectItem>
<SelectItem value="rejected">{t("catalog.proformas.status.rejected")}</SelectItem>
<SelectItem value="issued">{t("catalog.proformas.status.issued")}</SelectItem>
</SelectContent>
</Select>
</div>
<ProformasGrid
columns={columns}
data={listCtrl.data}
loading={listCtrl.isLoading}
onChangeStatusClick={handleChangeStatusProforma}
onDeleteClick={handleDeleteProforma}
onIssueClick={handleIssueProforma}
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}
// acciones rápidas del grid → page controller
//onRowClick={(id) => navigate(`/proformas/${id}`)}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
/>
{/* Emitir factura */}
<ProformaIssueDialog
isSubmitting={issueDialogCtrl.isSubmitting}
onConfirm={issueDialogCtrl.confirmIssue}
onOpenChange={(open) => !open && issueDialogCtrl.closeDialog()}
open={issueDialogCtrl.open}
proformaId={issueDialogCtrl.proforma?.id ?? 0}
proformaReference={issueDialogCtrl.proforma?.reference ?? ""}
/>
{/* Cambiar estado */}
<ChangeStatusDialog
isSubmitting={changeStatusDialogCtrl.isSubmitting}
onConfirm={changeStatusDialogCtrl.confirmChangeStatus}
onOpenChange={(open) => {
if (!open) changeStatusDialogCtrl.closeDialog();
}}
open={changeStatusDialogCtrl.open}
proformaRef={changeStatusDialogCtrl.proforma?.reference}
proformas={changeStatusDialogCtrl.proformas}
targetStatus={changeStatusDialogCtrl.targetStatus ?? undefined}
/>
{/* Eliminar */}
<DeleteProformaDialog
isSubmitting={deleteDialogCtrl.isSubmitting}
onConfirm={deleteDialogCtrl.confirmDelete}
onOpenChange={(o) => !o && deleteDialogCtrl.closeDialog()}
open={deleteDialogCtrl.open}
proformaRef={deleteDialogCtrl.proforma?.reference}
/>
</AppContent>
</>
);
};

View File

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

View File

@ -1,513 +0,0 @@
import { formatDate } from "@erp/core/client";
import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import {
Button,
ButtonGroup,
Checkbox,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import {
ArrowBigRightDashIcon,
CopyIcon,
DownloadIcon,
EditIcon,
ExternalLinkIcon,
MailIcon,
MoreVerticalIcon,
Trash2Icon,
} from "lucide-react";
import * as React from "react";
import { useTranslation } from "../../../../i18n";
import type { ProformaSummaryData } from "../../../schema";
import { ProformaStatusBadge } from "../ui";
type GridActionHandlers = {
onEdit?: (proforma: ProformaSummaryData) => void;
onDuplicate?: (proforma: ProformaSummaryData) => void;
onDownloadPdf?: (proforma: ProformaSummaryData) => void;
onSendEmail?: (proforma: ProformaSummaryData) => void;
onDelete?: (proforma: ProformaSummaryData) => void;
};
export function useProformasGridColumns(
actionHandlers: GridActionHandlers = {}
): ColumnDef<ProformaSummaryData, unknown>[] {
const { t } = useTranslation();
const { onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete } = actionHandlers;
return React.useMemo<ColumnDef<ProformaSummaryData>[]>(
() => [
// Select
{
id: "select",
header: ({ table }) => (
<Checkbox
aria-label="Seleccionar todo"
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label="Seleccionar fila"
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
),
enableSorting: false,
enableHiding: false,
},
// Nº
{
accessorKey: "invoice_number",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left tabular-nums justify-end"
column={column}
title={t("pages.proformas.list.grid_columns.invoice_number")}
/>
),
cell: ({ row }) => (
<div className="font-semibold tabular-nums">{row.getValue("invoice_number")}</div>
),
enableHiding: false,
meta: {
title: t("pages.proformas.list.grid_columns.invoice_number"),
},
},
// Estado
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.status")}
/>
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<ProformaStatusBadge status={row.original.status} />
{row.original.status === "issued" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button asChild className="size-6" size="icon" variant="ghost">
<a href={`/facturas/${row.original.issued_invoice_id}`}>
<ExternalLinkIcon className="size-4 text-foreground" />
<span className="sr-only">
Ver factura #{row.original.issued_invoice_id}
</span>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Ver factura #{row.original.issued_invoice_id}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
),
enableSorting: false,
size: 64,
minSize: 64,
meta: {
title: t("pages.proformas.list.grid_columns.status"),
},
},
{
id: "recipient",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.recipient")}
/>
),
accessorFn: (row) => row.recipient.name, // para ordenar/buscar por nombre
enableHiding: false,
minSize: 120,
cell: ({ row }) => {
const c = row.original.recipient;
return (
<div className="flex items-start gap-1">
<div className="min-w-0 grid gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold truncate text-primary">{c.name}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{c.tin && <span className="text-xs text-muted-foreground truncate">{c.tin}</span>}
</div>
</div>
</div>
);
},
meta: {
title: t("pages.proformas.list.grid_columns.recipient"),
},
},
// Serie
{
accessorKey: "series",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.series")}
/>
),
cell: ({ row }) => <div className="font-normal text-left">{row.original.series}</div>,
enableSorting: false,
size: 64,
minSize: 64,
meta: {
title: t("pages.proformas.list.grid_columns.series"),
},
},
// Referencia
{
accessorKey: "reference",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.reference")}
/>
),
cell: ({ row }) => <div className="font-medium text-left">{row.original.reference}</div>,
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.proformas.list.grid_columns.reference"),
},
},
// Fecha factura
{
accessorKey: "invoice_date",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.invoice_date")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.original.invoice_date)}
</div>
),
size: 96,
minSize: 96,
meta: {
title: t("pages.proformas.list.grid_columns.invoice_date"),
},
},
// Fecha operación
{
accessorKey: "operation_date",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.operation_date")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.original.operation_date)}
</div>
),
size: 96,
minSize: 96,
meta: {
title: t("pages.proformas.list.grid_columns.operation_date"),
},
},
// Subtotal amount
{
accessorKey: "subtotal_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.subtotal_amount")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">
{row.original.subtotal_amount_fmt}
</div>
),
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.proformas.list.grid_columns.subtotal_amount"),
},
},
// Discount amount
{
accessorKey: "discount_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.discount_amount")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">
{row.original.discount_amount_fmt}
</div>
),
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.proformas.list.grid_columns.discount_amount"),
},
},
// Taxes amount
{
accessorKey: "taxes_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.taxes_amount")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">{row.original.taxes_amount_fmt}</div>
),
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.proformas.list.grid_columns.taxes_amount"),
},
},
// Total amount
{
accessorKey: "total_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.total_amount")}
/>
),
cell: ({ row }) => (
<div className="font-semibold text-right tabular-nums">
{row.original.total_amount_fmt}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
meta: {
title: t("pages.proformas.list.grid_columns.total_amount"),
},
},
// ─────────────────────────────
// Acciones
// ─────────────────────────────
{
id: "actions",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("common.actions")}
/>
),
enableSorting: false,
enableHiding: false,
size: 110,
minSize: 96,
cell: ({ row }) => {
const proforma = row.original;
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
return (
<ButtonGroup>
{/* Emitir factura: approved -> issued */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.edit_row")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={(e) => {
e.stopPropagation();
onEdit?.(proforma);
}}
size="sm"
type="button"
variant="ghost"
>
<ArrowBigRightDashIcon aria-hidden="true" className="size-4 " />
<span className="sr-only">Emitir</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.edit_row")}</TooltipContent>
</Tooltip>
{/* Editar (acción primaria) */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.edit_row")}
className="cursor-pointer text-muted-foreground hover:text-primary hidden"
onClick={(e) => {
e.stopPropagation();
onEdit?.(proforma);
}}
size="sm"
type="button"
variant="ghost"
>
<EditIcon aria-hidden="true" className="size-4 " />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.edit_row")}</TooltipContent>
</Tooltip>
{/* Duplicar */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.duplicate_row")}
className="cursor-pointer text-muted-foreground hover:text-primary hidden"
onClick={(e) => {
e.stopPropagation();
onDuplicate?.(proforma);
}}
size="sm"
type="button"
variant="ghost"
>
<CopyIcon aria-hidden="true" className="size-4 " />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.duplicate_row")}</TooltipContent>
</Tooltip>
{/* Descargar en PDF */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.download_pdf")}
className="cursor-pointer text-muted-foreground hover:text-primary hidden"
onClick={(e) => {
e.stopPropagation();
onDownloadPdf?.(proforma);
}}
size="icon-sm"
type="button"
variant="ghost"
>
<DownloadIcon aria-hidden="true" className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.download_pdf")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.delete_row")}
className="cursor-pointer text-destructive hover:bg-destructive/90 hover:text-white"
onClick={() => onDelete?.(proforma)}
size="icon-sm"
type="button"
variant="ghost"
>
<Trash2Icon aria-hidden="true" className="size-4" />
<span className="sr-only">{t("common.delete_row")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.delete_row")}</TooltipContent>
</Tooltip>
{/* Menú demás acciones */}
{/** biome-ignore lint/suspicious/noSelfCompare: <Desactivado por ahora> */}
{false !== false && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={t("common.more_actions")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={stop}
size="sm"
type="button"
variant="ghost"
>
<MoreVerticalIcon aria-hidden="true" className="size-4" />
<span className="sr-only">{t("common.more_actions")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onDuplicate?.(proforma)}
>
<CopyIcon className="mr-2 size-4" />
{t("common.duplicate_row")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onDownloadPdf?.(proforma)}
>
<DownloadIcon className="mr-2 size-4" />
{t("common.download_pdf")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onSendEmail?.(proforma)}
>
<MailIcon className="mr-2 size-4" />
{t("common.send_email")}
</DropdownMenuItem>{" "}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive-foreground focus:bg-destructive cursor-pointer"
onClick={() => onDelete?.(proforma)}
>
<Trash2Icon className="mr-2 size-4 text-destructive focus:text-destructive-foreground" />
{t("common.delete_row")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</ButtonGroup>
);
},
meta: {
title: t("common.actions"),
},
},
],
[t, onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete]
);
}

View File

@ -1,89 +0,0 @@
import { PageHeader, SimpleSearchInput } from "@erp/core/components";
import { ErrorAlert } from "@erp/customers/components";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { FilterIcon, PlusIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useProformasList } from "./hooks";
import { ProformasGrid } from "./ui";
export const ProformaListPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const list = useProformasList();
if (list.isError || !list.data) {
return (
<AppContent>
<ErrorAlert
message={(list.error as Error)?.message || "Error al cargar el listado"}
title={t("pages.proformas.list.loadErrorTitle")}
/>
<BackHistoryButton />
</AppContent>
);
}
return (
<>
<AppHeader>
<PageHeader
description={t("pages.proformas.list.description")}
rightSlot={
<Button
aria-label={t("pages.proformas.create.title")}
onClick={() => navigate("/proformas/create")}
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.proformas.create.title")}
</Button>
}
title={t("pages.proformas.list.title")}
/>
</AppHeader>
<AppContent>
{/* Search and filters */}
<div className="flex items-center justify-between gap-16">
<SimpleSearchInput loading={list.isLoading} onSearchChange={list.setSearchValue} />
<Select defaultValue="all" onValueChange={list.setStatusFilter}>
<SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("catalog.proformas.status.all")}</SelectItem>
<SelectItem value="draft">{t("catalog.proformas.status.draft")}</SelectItem>
<SelectItem value="sent">{t("catalog.proformas.status.sent")}</SelectItem>
<SelectItem value="approved">{t("catalog.proformas.status.approved")}</SelectItem>
<SelectItem value="rejected">{t("catalog.proformas.status.rejected")}</SelectItem>
<SelectItem value="issued">{t("catalog.proformas.status.issued")}</SelectItem>
</SelectContent>
</Select>
</div>
<ProformasGrid
data={list.data}
loading={list.isLoading}
onPageChange={list.setPageIndex}
onPageSizeChange={list.setPageSize}
onSearchChange={list.setSearchValue}
onStatusFilterChange={list.setStatusFilter}
pageIndex={list.pageIndex}
pageSize={list.pageSize}
searchValue={list.search}
/>
</AppContent>
</>
);
};

View File

@ -1,63 +0,0 @@
import { Badge } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { useTranslation } from "../../../../i18n";
export type ProformaStatus = "draft" | "sent" | "approved" | "rejected" | "issued";
export type ProformaStatusBadgeProps = {
status: string | ProformaStatus; // permitir cualquier valor
className?: string;
};
export const ProformaStatusBadge = ({ status, className }: ProformaStatusBadgeProps) => {
const { t } = useTranslation();
const normalizedStatus = status.toLowerCase() as ProformaStatus;
const getVariant = (
status: ProformaStatus
): "default" | "secondary" | "outline" | "destructive" => {
switch (status) {
case "draft":
return "outline";
case "sent":
return "secondary";
case "approved":
return "default";
case "rejected":
return "destructive";
case "issued":
return "default";
default:
return "outline";
}
};
const getColor = (status: ProformaStatus): string => {
switch (status) {
case "draft":
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
case "sent":
return "bg-yellow-100 text-yellow-700 hover:bg-yellow-100";
case "approved":
return "bg-green-100 text-green-700 hover:bg-green-100";
case "rejected":
return "bg-red-100 text-red-700 hover:bg-red-100";
case "issued":
return "bg-blue-100 text-blue-700 hover:bg-blue-100";
default:
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
}
};
return (
<Badge
className={cn(getColor(normalizedStatus), "font-semibold", className)}
variant={getVariant(normalizedStatus)}
>
{t(`catalog.proformas.status.${normalizedStatus}`, { defaultValue: status })}
</Badge>
);
};
ProformaStatusBadge.displayName = "ProformaStatusBadge";

View File

@ -14,7 +14,7 @@ import {
type ProformaFormData,
ProformaFormSchema,
defaultProformaFormData,
} from "../../schema";
} from "../../types";
import { useProformaContext } from "./context";
import { ProformaUpdateForm } from "./proforma-update-form";

View File

@ -2,7 +2,7 @@ import { FormDebug } from "@erp/core/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { type FieldErrors, useFormContext } from "react-hook-form";
import type { ProformaFormData } from "../../schema";
import type { ProformaFormData } from "../../types";
import { ProformaBasicInfoFields, ProformaItems, ProformaRecipient, ProformaTotals } from "./ui";

View File

@ -4,7 +4,7 @@ import type { ComponentProps } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../../../../i18n";
import type { ProformaFormData } from "../../../../schema";
import type { ProformaFormData } from "../../../../types";
export const ProformaBasicInfoFields = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();

View File

@ -12,7 +12,7 @@ import type { ComponentProps } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "../../../../../i18n";
import type { ProformaFormData } from "../../../../schema";
import type { ProformaFormData } from "../../../../types";
import { useProformaContext } from "../../context";
export const ProformaTotals = (props: ComponentProps<"fieldset">) => {

View File

@ -1,7 +1,7 @@
import { Button, Input, Label, Textarea } from "@repo/shadcn-ui/components";
import { useFormContext } from "react-hook-form";
import type { ProformaFormData, ProformaItemFormData } from "../../../../../schema";
import type { ProformaFormData, ProformaItemFormData } from "../../../../../types";
export function ItemRowEditor({
row,

View File

@ -1,14 +1,14 @@
/** biome-ignore-all lint/complexity/noForEach: <explanation> */
/** biome-ignore-all lint/suspicious/useIterableCallbackReturn: <explanation> */
import { useProformaItemsColumns } from "@erp/customer-invoices/web/proformas/hooks";
import { useProformaGridColumns } from "@erp/customer-invoices/web/proformas/hooks";
import { DataTable, useWithRowSelection } from "@repo/rdx-ui/components";
import { useMemo } from "react";
import { useFieldArray, useFormContext } from "react-hook-form";
import { useProformaAutoRecalc } from "../../../../../../hooks";
import { useTranslation } from "../../../../../../i18n";
import { type ProformaFormData, defaultProformaItemFormData } from "../../../../../schema";
import { type ProformaFormData, defaultProformaItemFormData } from "../../../../../types";
import { useProformaContext } from "../../../context";
import { ItemRowEditor } from "./item-row-editor";
@ -28,7 +28,7 @@ export const ItemsEditor = () => {
name: "items",
});
const baseColumns = useWithRowSelection(useProformaItemsColumns(), true);
const baseColumns = useWithRowSelection(useProformaGridColumns(), true);
const columns = useMemo(() => baseColumns, [baseColumns]);
return (

View File

@ -1,3 +1,4 @@
export * from "./proforma.api.schema";
export * from "./proforma.form.schema";
export * from "./proforma-status";
export * from "./proforma-summary.web.schema";

View File

@ -0,0 +1,54 @@
export enum PROFORMA_STATUS {
DRAFT = "draft",
SENT = "sent",
APPROVED = "approved",
REJECTED = "rejected",
ISSUED = "issued",
}
// Transiciones válidas según reglas del dominio
export const PROFORMA_STATUS_TRANSITIONS: Record<PROFORMA_STATUS, PROFORMA_STATUS[]> = {
[PROFORMA_STATUS.DRAFT]: [PROFORMA_STATUS.SENT],
[PROFORMA_STATUS.SENT]: [PROFORMA_STATUS.APPROVED, PROFORMA_STATUS.REJECTED],
[PROFORMA_STATUS.APPROVED]: [PROFORMA_STATUS.ISSUED, PROFORMA_STATUS.DRAFT],
[PROFORMA_STATUS.REJECTED]: [PROFORMA_STATUS.DRAFT],
[PROFORMA_STATUS.ISSUED]: [],
};
export type ProformaStatus = `${PROFORMA_STATUS}`;
export const getProformaStatusButtonVariant = (
status: ProformaStatus
): "default" | "secondary" | "outline" | "destructive" => {
switch (status) {
case "draft":
return "outline";
case "sent":
return "secondary";
case "approved":
return "default";
case "rejected":
return "destructive";
case "issued":
return "default";
default:
return "outline";
}
};
export const getProformaStatusColor = (status: ProformaStatus): string => {
switch (status) {
case "draft":
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
case "sent":
return "bg-yellow-100 text-yellow-700 hover:bg-yellow-100";
case "approved":
return "bg-green-100 text-green-700 hover:bg-green-100";
case "rejected":
return "bg-red-100 text-red-700 hover:bg-red-100";
case "issued":
return "bg-blue-100 text-blue-700 hover:bg-blue-100";
default:
return "bg-gray-100 text-gray-700 hover:bg-gray-100";
}
};

View File

@ -1,4 +1,5 @@
export * from "../../issue-proforma/ui/issue-proforma-dialog";
export * from "./proforma-delete-dialog";
export * from "./proforma-issue-dialog";
export * from "./proforma-layout";
export * from "./proforma-tax-summary";

View File

@ -1,87 +0,0 @@
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Spinner,
} from "@repo/shadcn-ui/components";
import { useState } from "react";
interface IssueInvoiceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
proformaId: number;
proformaReference: string;
}
export function ProformaIssueDialog({
open,
onOpenChange,
proformaId,
proformaReference,
}: IssueInvoiceDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
//const { toast } = useToast();
const handleIssue = async () => {
setIsSubmitting(true);
/*try {
const result = await issueInvoiceFromProforma(proformaId);
if (result.success) {
toast({
title: "Factura emitida",
description: `Se ha emitido la factura #${result.invoiceId} desde la proforma.`,
});
onOpenChange(false);
} else {
throw new Error(result.error);
}
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "Error al emitir la factura",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}*/
};
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Emitir factura</AlertDialogTitle>
<AlertDialogDescription>
¿Estás seguro de que deseas emitir una factura de cliente desde la proforma{" "}
<strong>{proformaReference}</strong>?
<br />
<br />
Esta acción creará una nueva factura definitiva y la proforma pasará al estado
"Emitida", no pudiendo modificarse ni eliminarse posteriormente.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
Cancelar
</Button>
<Button disabled={isSubmitting} onClick={handleIssue}>
{isSubmitting ? (
<>
<Spinner className="mr-2 size-4" />
Emitiendo...
</>
) : (
"Emitir factura"
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -12,7 +12,7 @@ import { useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import { useProformaContext } from "../../pages/update/context";
import type { ProformaFormData } from "../../schema";
import type { ProformaFormData } from "../../types";
export const ProformaTaxSummary = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();

View File

@ -3,9 +3,10 @@
<head>
<meta charset="UTF-8">
<style>{{ asset 'tailwind.css' }}</style>
<link rel="stylesheet" href="{{ asset 'tailwind.css' }}" />
<title>Factura</title>
<style>
<style type="text/css">
/* ---------------------------- */
/* ESTRUCTURA CABECERA */
/* ---------------------------- */
@ -43,10 +44,12 @@
/* Bloque derecho */
.right-block {
display: flex;
align-items: flex-start;
justify-content: flex-end;
width: 40%;
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 {
@ -202,6 +205,7 @@
<!-- FILA SUPERIOR: logo + dirección / imagen factura -->
<div class="top-header">
<div class="left-block">
<img src="{{asset 'logo_acana.jpg'}}" alt="Logo Acana" class="logo" />
<div class="company-text">
@ -217,7 +221,19 @@
</div>
<div class="right-block">
<div>
<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;">
QR tributario factura verificable en sede electronica de AEAT VERI*FACTU
</div>
<div>
<img src="{{verifactu.qr_code}}" alt="QR factura" style="width: 100px; height: 100px;" />
</div>
</div>
{{/if}}
</div>
</div>
@ -227,7 +243,6 @@
<div class="info-box">
<p>Factura nº: <strong>{{series}}{{invoice_number}}</strong></p>
<p>Fecha: <strong>{{invoice_date}}</strong></p>
<p>Página <span class="pageNumber"></span> de <span class="totalPages"></span></p>
</div>
<div class="info-box info-dire">
@ -244,7 +259,6 @@
<main id="main">
<section id="details">
<!-- Tu tabla -->
<table class="table-header">
<thead>

View File

@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8">
<style>{{ asset 'tailwind.css' }}</style>
<link rel="stylesheet" href="{{ asset 'tailwind.css' }}" />
<title>Factura proforma</title>
<style type="text/css">
/* ---------------------------- */

View File

@ -22,7 +22,8 @@
"duplicate": "Duplicate",
"remove": "Remove",
"move_up": "Move up",
"move_down": "Move down"
"move_down": "Move down",
"clear_selection": "Clear selection"
},
"pagination": {
"goto_first_page": "Go to first page",

View File

@ -25,7 +25,8 @@
"duplicate": "Duplicar",
"remove": "Eliminar",
"move_up": "Subir",
"move_down": "Bajar"
"move_down": "Bajar",
"clear_selection": "Quitar selección"
},
"pagination": {
"goto_first_page": "Ir a la primera página",

View File

@ -1,33 +1,29 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@repo/shadcn-ui/lib/utils"
import { Separator } from "@repo/shadcn-ui/components/separator"
import { Slot } from "@radix-ui/react-slot";
import { Separator } from "@repo/shadcn-ui/components/separator";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { type VariantProps, cva } from "class-variance-authority";
import type * as React from "react";
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn("group/item-group flex flex-col", className)}
data-slot="item-group"
role="list"
{...props}
/>
)
);
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
className={cn("my-0", className)}
data-slot="item-separator"
orientation="horizontal"
className={cn("my-0", className)}
{...props}
/>
)
);
}
const itemVariants = cva(
@ -49,7 +45,7 @@ const itemVariants = cva(
size: "default",
},
}
)
);
function Item({
className,
@ -57,18 +53,17 @@ function Item({
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
}: React.ComponentProps<"div"> & VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
className={cn(itemVariants({ variant, size, className }))}
data-size={size}
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
);
}
const itemMediaVariants = cva(
@ -78,15 +73,14 @@ const itemMediaVariants = cva(
variant: {
default: "bg-transparent",
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
image: "size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
);
function ItemMedia({
className,
@ -95,88 +89,72 @@ function ItemMedia({
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
className={cn(itemMediaVariants({ variant, className }))}
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
);
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none", className)}
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
);
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex w-fit items-center gap-2 text-sm leading-snug font-medium", className)}
data-slot="item-title"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
className
)}
{...props}
/>
)
);
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
data-slot="item-description"
{...props}
/>
)
);
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
<div className={cn("flex items-center gap-2", className)} data-slot="item-actions" {...props} />
);
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex basis-full items-center justify-between gap-2", className)}
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
);
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex basis-full items-center justify-between gap-2", className)}
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
);
}
export {
@ -190,4 +168,4 @@ export {
ItemDescription,
ItemHeader,
ItemFooter,
}
};

Some files were not shown because too many files have changed in this diff Show More