Facturas de cliente

This commit is contained in:
David Arranz 2025-11-22 17:09:37 +01:00
parent a062732e5e
commit 323860b5b8
17 changed files with 567 additions and 287 deletions

View File

@ -11,29 +11,43 @@
"remove_selected_rows_tooltip": "Remove selected row(s)", "remove_selected_rows_tooltip": "Remove selected row(s)",
"download_pdf": "Download PDF", "download_pdf": "Download PDF",
"send_email": "Send email", "send_email": "Send email",
"insert_row_above": "Insert row above", "insert_row_above": "Insert row above",
"insert_row_below": "Insert row below", "insert_row_below": "Insert row below",
"delete_row": "Delete", "delete_row": "Delete",
"actions": "Actions", "actions": "Actions",
"rows_selected": "{{count}} fila(s) seleccionadas.", "rows_selected": "{{count}} fila(s) seleccionadas.",
"rows_selected_of_total": "{{count}} de {{total}} fila(s) seleccionadas.", "rows_selected_of_total": "{{count}} de {{total}} fila(s) seleccionadas.",
"search_placeholder": "Type for search...", "search_placeholder": "Type for search...",
"search": "Search", "search": "Search",
"clear": "Clear" "clear": "Clear"
}, },
"catalog": { "catalog": {
"proformas": { "proformas": {
"status": { "status": {
"all": "All", "all": {
"draft": "Draft", "label": "All",
"sent": "Sent", "description": ""
"approved": "Approved", },
"rejected": "Rejected", "draft": {
"issued": "Issued" "label": "Draft",
"description": "Proforma in preparation"
},
"sent": {
"label": "Sent",
"description": "Sent to the client"
},
"approved": {
"label": "Approved",
"description": "Approved by the client"
},
"rejected": {
"label": "Rejected",
"description": "Rejected by the client"
},
"issued": {
"label": "Issued",
"description": "Converted to invoice"
}
} }
}, },
"issued_invoices": { "issued_invoices": {
@ -294,4 +308,4 @@
} }
} }
} }
} }

View File

@ -11,15 +11,12 @@
"remove_selected_rows_tooltip": "Eliminar fila(s) seleccionada(s)", "remove_selected_rows_tooltip": "Eliminar fila(s) seleccionada(s)",
"download_pdf": "Descargar en PDF", "download_pdf": "Descargar en PDF",
"send_email": "Enviar por email", "send_email": "Enviar por email",
"insert_row_above": "Insertar fila arriba", "insert_row_above": "Insertar fila arriba",
"insert_row_below": "Insertar fila abajo", "insert_row_below": "Insertar fila abajo",
"delete_row": "Eliminar", "delete_row": "Eliminar",
"actions": "Acciones", "actions": "Acciones",
"rows_selected": "{{count}} fila(s) seleccionadas.", "rows_selected": "{{count}} fila(s) seleccionadas.",
"rows_selected_of_total": "{{count}} de {{total}} fila(s) seleccionadas.", "rows_selected_of_total": "{{count}} de {{total}} fila(s) seleccionadas.",
"search_placeholder": "Escribe aquí para buscar...", "search_placeholder": "Escribe aquí para buscar...",
"search": "Buscar", "search": "Buscar",
"clear": "Limpiar" "clear": "Limpiar"
@ -27,12 +24,30 @@
"catalog": { "catalog": {
"proformas": { "proformas": {
"status": { "status": {
"all": "Todas", "all": {
"draft": "Borrador", "label": "Todas",
"sent": "Enviada", "description": ""
"approved": "Aprobada", },
"rejected": "Rechazada", "draft": {
"issued": "Emitida" "label": "Borrador",
"description": "Proforma en preparación"
},
"sent": {
"label": "Enviada",
"description": "Enviada al cliente"
},
"approved": {
"label": "Aprobada",
"description": "Aprobada por el cliente"
},
"rejected": {
"label": "Rechazada",
"description": "Rechazada por el cliente"
},
"issued": {
"label": "Emitida",
"description": "Convertida a factura"
}
} }
}, },
"issued_invoices": { "issued_invoices": {
@ -138,7 +153,6 @@
} }
} }
}, },
"form_groups": { "form_groups": {
"customer": { "customer": {
"title": "Cliente", "title": "Cliente",
@ -187,7 +201,6 @@
"placeholder": "Referencia de la proforma", "placeholder": "Referencia de la proforma",
"description": "Referencia de la proforma" "description": "Referencia de la proforma"
}, },
"description": { "description": {
"label": "Descripción", "label": "Descripción",
"placeholder": "Descripción de la proforma", "placeholder": "Descripción de la proforma",
@ -286,4 +299,4 @@
} }
} }
} }
} }

View File

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

View File

@ -8,64 +8,62 @@ import {
DialogTitle, DialogTitle,
Spinner, Spinner,
} from "@repo/shadcn-ui/components"; } 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 { useState } from "react";
import { useTranslation } from "../../../i18n"; import { useTranslation } from "../../../i18n";
import { PROFORMA_STATUS, PROFORMA_STATUS_TRANSITIONS } from "../../types"; import {
PROFORMA_STATUS,
PROFORMA_STATUS_TRANSITIONS,
type ProformaSummaryData,
getProformaStatusIcon,
} from "../../types";
import { StatusNode, TimelineConnector } from "./components";
interface ChangeStatusDialogProps { interface ChangeStatusDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
proformas: { id: string; status: string }[]; proformas: ProformaSummaryData[];
targetStatus?: string; targetStatus?: string;
isSubmitting: boolean; isSubmitting: boolean;
onConfirm: () => void; onConfirm: (status: PROFORMA_STATUS) => void;
} }
const STATUS_FLOW = [ const STATUS_FLOW = [
{ {
status: PROFORMA_STATUS.DRAFT, status: PROFORMA_STATUS.DRAFT,
icon: FileTextIcon, activeColor: "text-gray-700",
color: "text-gray-500", activeBg: "bg-gray-100",
bgColor: "bg-gray-100", activeBorder: "border-gray-300",
borderColor: "border-gray-300", activeRing: "ring-gray-200",
}, },
{ {
status: PROFORMA_STATUS.SENT, status: PROFORMA_STATUS.SENT,
icon: SendIcon, activeColor: "text-yellow-700",
color: "text-blue-600", activeBg: "bg-yellow-100",
bgColor: "bg-blue-100", activeBorder: "border-yellow-300",
borderColor: "border-blue-300", activeRing: "ring-yellow-200",
},
{
status: PROFORMA_STATUS.APPROVED,
icon: CheckCircle2Icon,
color: "text-green-600",
bgColor: "bg-green-100",
borderColor: "border-green-300",
}, },
{ {
status: PROFORMA_STATUS.REJECTED, status: PROFORMA_STATUS.REJECTED,
icon: XCircleIcon, activeColor: "text-red-700",
color: "text-red-600", activeBg: "bg-red-100",
bgColor: "bg-red-100", activeBorder: "border-red-300",
borderColor: "border-red-300", activeRing: "ring-red-200",
},
{
status: PROFORMA_STATUS.APPROVED,
activeColor: "text-green-700",
activeBg: "bg-green-100",
activeBorder: "border-green-300",
activeRing: "ring-green-200",
}, },
{ {
status: PROFORMA_STATUS.ISSUED, status: PROFORMA_STATUS.ISSUED,
icon: FileCheckIcon, activeColor: "text-blue-700",
color: "text-purple-600", activeBg: "bg-blue-100",
bgColor: "bg-purple-100", activeBorder: "border-blue-300",
borderColor: "border-purple-300", activeRing: "ring-blue-200",
}, },
] as const; ] as const;
@ -122,12 +120,12 @@ export function ChangeStatusDialog({
return ( return (
<Dialog onOpenChange={onOpenChange} open={open}> <Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-4xl"> <DialogContent className="sm:max-w-4xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Cambiar estado de la proforma</DialogTitle> <DialogTitle>Cambiar estado de la proforma</DialogTitle>
<DialogDescription> <DialogDescription>
{proformas.length === 1 {proformas.length === 1
? `Selecciona el nuevo estado para la proforma #${proformas[0].id}` ? `Selecciona el nuevo estado para la proforma #${proformas[0].reference}`
: `Selecciona el nuevo estado para ${proformas.length} proformas seleccionadas`} : `Selecciona el nuevo estado para ${proformas.length} proformas seleccionadas`}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -139,150 +137,56 @@ export function ChangeStatusDialog({
) : ( ) : (
<div className="py-6"> <div className="py-6">
<div className="relative"> <div className="relative">
{/* Línea de conexión */} <div className="relative grid grid-cols-5 gap-2 mb-8">
<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) => { {STATUS_FLOW.map((statusConfig, index) => {
const statusType = getStatusType(statusConfig.status); const statusType = getStatusType(statusConfig.status);
const Icon = statusConfig.icon;
const isSelected = selectedStatus === statusConfig.status; const isSelected = selectedStatus === statusConfig.status;
const isClickable = statusType === "available"; const isClickable = statusType === "available";
return ( return (
<div className="flex flex-col items-center" key={statusConfig.status}> <StatusNode
{/* Botón de estado */} activeBg={statusConfig.activeBg}
<button activeBorder={statusConfig.activeBorder}
className={cn( activeColor={statusConfig.activeColor}
"relative z-10 flex size-24 flex-col items-center justify-center rounded-xl border-2 transition-all", activeRing={statusConfig.activeRing}
// Estado pasado description={t(`catalog.proformas.status.${statusConfig.status}.description`)}
statusType === "past" && ["border-gray-200 bg-gray-50", "opacity-60"], icon={getProformaStatusIcon(statusConfig.status)}
// Estado actual isSelected={isSelected}
statusType === "current" && [ key={statusConfig.status}
statusConfig.borderColor, label={t(`catalog.proformas.status.${statusConfig.status}.label`)}
statusConfig.bgColor, onClick={() => {
"ring-2 ring-offset-2", if (isClickable) {
`ring-${statusConfig.color.split("-")[1]}-200`, setSelectedStatus(statusConfig.status);
], }
// Estado disponible para seleccionar }}
statusType === "available" && [ status={statusConfig.status}
isSelected statusType={statusType}
? [ />
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>
<div className="mt-8 p-4 bg-muted/50 rounded-lg"> <TimelineConnector
<div className="flex items-start gap-6 text-sm"> nodeCount={STATUS_FLOW.length}
{currentStatus && ( statusColors={STATUS_FLOW.map((statusConfig) => ({
<div className="flex items-center gap-2"> bgClass: statusConfig.activeBg,
<div className="size-3 rounded-full bg-blue-600 ring-2 ring-blue-200" /> statusType: getStatusType(statusConfig.status),
<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>
</div> </div>
)} )}
<DialogFooter> <DialogFooter className="sm:justify-between">
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline"> <Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
Cancelar Cancelar
</Button> </Button>
<Button <Button
disabled={!selectedStatus || availableStatuses.length === 0 || isSubmitting} disabled={!selectedStatus || availableStatuses.length === 0 || isSubmitting}
onClick={onConfirm} onClick={() => {
if (!selectedStatus) return;
onConfirm(selectedStatus); // ← enviamos el estado al controlador
}}
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>

View File

@ -0,0 +1,3 @@
export * from "./status-legend";
export * from "./status-node";
export * from "./timeline-connector";

View File

@ -0,0 +1,31 @@
"use client";
import { useTranslation } from "../../../../i18n";
interface StatusLegendProps {
hasCurrentStatus: boolean;
}
export const StatusLegend = ({ hasCurrentStatus }: StatusLegendProps) => {
const { t } = useTranslation();
return (
<div className="mt-8 p-4 bg-muted/50 rounded-lg">
<div className="flex items-start gap-6 text-sm">
{hasCurrentStatus && (
<div className="flex items-center gap-2">
<div className="size-3 rounded-full bg-amber-500 ring-2 ring-amber-200" />
<span className="text-muted-foreground">Estado actual</span>
</div>
)}
<div className="flex items-center gap-2">
<div className="size-3 rounded-full bg-linear-to-r from-blue-400 to-green-400" />
<span className="text-muted-foreground">Estados disponibles (colores originales)</span>
</div>
<div className="flex items-center gap-2">
<div className="size-3 rounded-full bg-gray-300 opacity-50" />
<span className="text-muted-foreground">Estados no disponibles</span>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,106 @@
"use client";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { CheckCircle2, type LucideIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
import type { PROFORMA_STATUS } from "../../../types";
interface StatusNodeProps {
status: PROFORMA_STATUS;
label: string;
description: string;
icon: LucideIcon;
activeColor: string;
activeBg: string;
activeBorder: string;
activeRing: string;
statusType: "past" | "current" | "available" | "unavailable";
isSelected: boolean;
onClick: () => void;
}
export const StatusNode = ({
status,
label,
description,
icon: Icon,
activeColor,
activeBg,
activeBorder,
activeRing,
statusType,
isSelected,
onClick,
}: StatusNodeProps) => {
const { t } = useTranslation();
const isClickable = statusType === "available";
return (
<div className="flex flex-col items-center">
<button
className={cn(
"relative z-10 flex size-24 flex-col items-center justify-center rounded-xl border-2 transition-all",
statusType === "past" && ["border-gray-200 bg-gray-50", "opacity-50 saturate-50"],
statusType === "current" && [activeBorder, activeBg, "border-4 scale-110 shadow-lg"],
statusType === "available" && [
activeBorder,
activeBg,
isSelected
? ["ring-4", activeRing, "ring-offset-2", "scale-105 cursor-pointer"]
: ["hover:scale-105 cursor-pointer", "opacity-80 hover:opacity-100"],
],
statusType === "unavailable" && [
"border-gray-200 bg-gray-50",
"opacity-30 saturate-0 cursor-not-allowed",
]
)}
disabled={!isClickable}
onClick={onClick}
type="button"
>
<Icon
className={cn(
"size-8 mb-1",
statusType === "past" && "text-gray-400",
statusType === "current" && activeColor,
statusType === "available" && activeColor,
statusType === "unavailable" && "text-gray-300"
)}
/>
{statusType === "current" && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-2 py-0.5 rounded-full bg-primary text-primary-foreground text-[10px] font-bold tracking-wider shadow-md">
ACTUAL
</div>
)}
{isSelected && statusType !== "current" && (
<div
className={cn(
"absolute -top-2 -right-2 size-6 rounded-full border-2 border-white flex items-center justify-center",
activeBg
)}
>
<CheckCircle2 className={cn("size-4", activeColor)} />
</div>
)}
</button>
<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" && `${activeColor} font-semibold`,
statusType === "available" && activeColor,
statusType === "unavailable" && "text-gray-400"
)}
>
{label}
</p>
<p className="text-xs text-muted-foreground line-clamp-2">{description}</p>
</div>
</div>
);
};

View File

@ -0,0 +1,90 @@
"use client";
import { cn } from "@repo/shadcn-ui/lib/utils";
interface TimelineConnectorProps {
nodeCount: number;
statusColors: Array<{
bgClass: string;
statusType: "past" | "current" | "available" | "unavailable";
}>;
}
export function TimelineConnector({ nodeCount, statusColors }: TimelineConnectorProps) {
const segments = Array.from({ length: nodeCount - 1 }).map((_, index) => {
const fromStatus = statusColors[index];
const toStatus = statusColors[index + 1];
return {
from: fromStatus,
to: toStatus,
};
});
return (
<div className="relative h-3 mx-8">
<div className="absolute inset-0 bg-gray-200 rounded-full" />
<div className="absolute inset-0 flex">
{segments.map((segment, index) => {
const segmentWidth = `${100 / (nodeCount - 1)}%`;
// Extraer el color del bgClass (ej: "bg-gray-100" -> "gray")
const getColorFromBgClass = (bgClass: string) => {
const match = bgClass.match(/bg-(\w+)-/);
return match ? match[1] : "gray";
};
const fromColor = getColorFromBgClass(segment.from.bgClass);
const toColor = getColorFromBgClass(segment.to.bgClass);
// Determinar opacidad basada en el tipo de estado
const getOpacity = (statusType: string) => {
if (statusType === "past") return 0.3;
if (statusType === "unavailable") return 0.15;
return 1;
};
const fromOpacity = getOpacity(segment.from.statusType);
const toOpacity = getOpacity(segment.to.statusType);
return (
<div
className="h-full rounded-full relative overflow-hidden"
key={index}
style={{
width: segmentWidth,
background: `linear-gradient(to right,
hsl(var(--${fromColor}-200) / ${fromOpacity}),
hsl(var(--${toColor}-200) / ${toOpacity})
)`,
}}
>
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/20 to-transparent" />
</div>
);
})}
</div>
<div className="absolute inset-0 flex justify-between items-center px-14">
{Array.from({ length: nodeCount }).map((_, index) => {
const statusColor = statusColors[index];
const isPast = statusColor.statusType === "past";
const isUnavailable = statusColor.statusType === "unavailable";
return (
<div
className={cn(
"size-4 rounded-full bg-white border-2 shadow-sm transition-all",
isPast && "border-gray-300 opacity-50",
!(isPast || isUnavailable) &&
statusColor.bgClass.replace("bg-", "border-").replace("-100", "-400"),
isUnavailable && "border-gray-200 opacity-30"
)}
key={index}
/>
);
})}
</div>
</div>
);
}

View File

@ -5,55 +5,76 @@ import { useTranslation } from "../../../i18n";
import type { ProformaSummaryData } from "../../types"; import type { ProformaSummaryData } from "../../types";
import { useDeleteProforma } from "../hooks"; import { useDeleteProforma } from "../hooks";
interface ProformaState { interface DeleteProformaDialogState {
open: boolean; open: boolean;
proforma: ProformaSummaryData | null; proformas: ProformaSummaryData[];
loading: boolean;
requireSecondConfirm: boolean;
} }
export function useDeleteProformaDialogController() { export function useDeleteProformaDialogController() {
const { t } = useTranslation(); const { t } = useTranslation();
const { mutate, isPending } = useDeleteProforma(); const { deleteProforma } = useDeleteProforma();
const [state, setState] = React.useState<ProformaState>({ const [state, setState] = React.useState<DeleteProformaDialogState>({
open: false, open: false,
proforma: null, proformas: [],
loading: false,
requireSecondConfirm: false,
}); });
const openDialog = (proforma: ProformaSummaryData) => { const openDialog = (proformas: ProformaSummaryData[]) => {
setState({ open: true, proforma }); const needDoubleCheck = proformas.length > 5;
setState({
open: true,
proformas,
loading: false,
requireSecondConfirm: needDoubleCheck,
});
}; };
const closeDialog = () => { const closeDialog = () => {
setState((s) => ({ ...s, open: false })); setState((s) => ({ ...s, open: false }));
}; };
const confirmDelete = () => { const confirmDelete = async () => {
if (!state.proforma) return; if (state.proformas.length === 0) return;
mutate( if (state.requireSecondConfirm) {
{ proformaId: state.proforma.id }, setState((s) => ({ ...s, requireSecondConfirm: false }));
{ return; // ahora el UI mostrará un segundo mensaje de confirmación
onSuccess(data, variables, onMutateResult, context) { }
console.log("adios");
console.log(data); setState((s) => ({ ...s, loading: true }));
showSuccessToast(t("proformas.delete_proforma_dialog.success_title"));
closeDialog(); for (const p of state.proformas) {
await deleteProforma(p.id, {
onSuccess: () => {
showSuccessToast(
"Proforma eliminada",
`La proforma ${p.reference ?? `#${p.id}`} ha sido eliminada.`
);
}, },
onError(error, variables, onMutateResult, context) { onError: (err) => {
showErrorToast(t("proformas.delete_proforma_dialog.error_title"), error.message); showErrorToast(
"Error al eliminar",
err instanceof Error ? err.message : "Ocurrió un error al eliminar la proforma"
);
}, },
} });
); }
setState((s) => ({ ...s, loading: false }));
closeDialog();
}; };
return { return {
open: state.open, open: state.open,
proforma: state.proforma, proformas: state.proformas,
isSubmitting: isPending, isSubmitting: state.loading,
openDialog, openDialog,
closeDialog, closeDialog,
confirmDelete, confirmDelete,
}; };
} }

View File

@ -3,7 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { deleteProformaApi } from "../api"; import { deleteProformaApi } from "../api";
interface useDeleteProformaPayload { interface DeleteProformaOptions {
onSuccess?: () => void;
onError?: (err: unknown) => void;
onLoadingChange?: (loading: boolean) => void;
}
interface DeleteProformaPayload {
proformaId: string; proformaId: string;
} }
@ -11,13 +17,29 @@ export function useDeleteProforma() {
const dataSource = useDataSource(); const dataSource = useDataSource();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ const mutation = useMutation({
mutationFn: ({ proformaId }: useDeleteProformaPayload) => mutationFn: ({ proformaId }: DeleteProformaPayload) =>
deleteProformaApi(dataSource, proformaId), deleteProformaApi(dataSource, proformaId),
onSuccess() { onSuccess() {
console.log("hola");
queryClient.invalidateQueries({ queryKey: ["proformas"] }); queryClient.invalidateQueries({ queryKey: ["proformas"] });
}, },
}); });
async function deleteProforma(proformaId: string, opts?: DeleteProformaOptions) {
try {
opts?.onLoadingChange?.(true);
await mutation.mutateAsync({ proformaId });
opts?.onSuccess?.();
} catch (err) {
opts?.onError?.(err);
} finally {
opts?.onLoadingChange?.(false);
}
}
return {
deleteProforma,
isPending: mutation.isPending,
};
} }

View File

@ -8,40 +8,83 @@ import {
Button, Button,
Spinner, Spinner,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { Trans } from "react-i18next"; import { useEffect } from "react";
import { useTranslation } from "../../../i18n"; import { useTranslation } from "../../../i18n";
import type { ProformaSummaryData } from "../../types";
interface DeleteProformaDialogProps { interface DeleteProformaDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
proformaRef?: string; proformas: ProformaSummaryData[];
isSubmitting: boolean; isSubmitting: boolean;
onConfirm: () => void; onConfirm: () => void;
requireSecondConfirm: boolean;
} }
export function DeleteProformaDialog({ export function DeleteProformaDialog({
open, open,
onOpenChange, onOpenChange,
proformaRef, proformas,
isSubmitting, isSubmitting,
onConfirm, onConfirm,
requireSecondConfirm,
}: DeleteProformaDialogProps) { }: DeleteProformaDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const total = proformas.length;
const isSingle = total === 1;
const title = requireSecondConfirm
? "Confirmación adicional"
: isSingle
? `Eliminar proforma ${proformas[0].reference ?? `#${proformas[0].id}`}`
: `Eliminar ${total} proformas`;
const description = requireSecondConfirm
? `Estás a punto de borrar ${total} proformas. Esta acción masiva no se puede deshacer. ¿Seguro que quieres continuar?`
: isSingle
? "¿Seguro que deseas eliminar esta proforma? Esta acción no se puede deshacer."
: `¿Seguro que deseas eliminar las ${total} proformas seleccionadas? Esta acción no se puede deshacer.`;
// Usar teclado para confirmar (Enter / Escape)
useEffect(() => {
if (!open) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm();
}
if (e.key === "Escape") {
onOpenChange(false);
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [open, onConfirm, onOpenChange]);
return ( return (
<AlertDialog onOpenChange={onOpenChange} open={open}> <AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent> <AlertDialogContent className="max-w-lg">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{t("proformas.delete_proforma_dialog.title")}</AlertDialogTitle> <AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>{description}</AlertDialogDescription>
<Trans
i18nKey={t("proformas.delete_proforma_dialog.description")}
values={{ proformaRef }}
/>
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> {!requireSecondConfirm && total > 1 && (
<div className="mt-4 max-h-48 overflow-y-auto rounded-md border p-3 text-sm">
<ul className="space-y-1">
{proformas.map((p) => (
<li className="flex justify-between text-muted-foreground" key={p.id}>
<span>Proforma {p.reference ?? `#${p.id}`}</span>
</li>
))}
</ul>
</div>
)}
<AlertDialogFooter className="sm:justify-between">
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline"> <Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
{t("proformas.delete_proforma_dialog.cancel")} {t("proformas.delete_proforma_dialog.cancel")}
</Button> </Button>
@ -52,8 +95,12 @@ export function DeleteProformaDialog({
<Spinner className="mr-2 size-4" /> <Spinner className="mr-2 size-4" />
{t("proformas.delete_proforma_dialog.deleting")} {t("proformas.delete_proforma_dialog.deleting")}
</> </>
) : ( ) : requireSecondConfirm ? (
<>{t("proformas.delete_proforma_dialog.mass_delete")}</>
) : isSingle ? (
<>{t("proformas.delete_proforma_dialog.delete")}</> <>{t("proformas.delete_proforma_dialog.delete")}</>
) : (
<>{t("proformas.delete_proforma_dialog.delete_plural")}</>
)} )}
</Button> </Button>
</AlertDialogFooter> </AlertDialogFooter>

View File

@ -37,15 +37,14 @@ export function useProformaListPageController() {
return; return;
} }
changeStatusDialogCtrl.openDialog(proforma, nextStatus); changeStatusDialogCtrl.openDialog([proforma]);
}, },
[changeStatusDialogCtrl] [changeStatusDialogCtrl]
); );
const handleDeleteProforma = React.useCallback( const handleDeleteProforma = React.useCallback(
(proforma: ProformaSummaryData) => { (proforma: ProformaSummaryData) => {
console.log(proforma); deleteDialogCtrl.openDialog([proforma]);
deleteDialogCtrl.openDialog(proforma);
}, },
[deleteDialogCtrl] [deleteDialogCtrl]
); );

View File

@ -244,29 +244,28 @@ export function useProformasGridColumns(
)} )}
{/* Cambiar estado */} {/* Cambiar estado */}
{!isIssued && {!isIssued && availableTransitions.length && (
availableTransitions.map((next_status) => ( <TooltipProvider key={availableTransitions[0]}>
<TooltipProvider key={next_status}> <Tooltip>
<Tooltip> <TooltipTrigger asChild>
<TooltipTrigger asChild> <Button
<Button className="size-8 cursor-pointer"
className="size-8 cursor-pointer" onClick={() =>
onClick={() => actionHandlers.onChangeStatusClick?.(proforma, availableTransitions[0])
actionHandlers.onChangeStatusClick?.(proforma, next_status) }
} size="icon"
size="icon" variant="ghost"
variant="ghost" >
> <RefreshCwIcon className="size-4" />
<RefreshCwIcon className="size-4" /> <span className="sr-only">Cambiar estado</span>
<span className="sr-only">Cambiar estado</span> </Button>
</Button> </TooltipTrigger>
</TooltipTrigger> <TooltipContent>
<TooltipContent> Cambiar a {t(`catalog.proformas.status.${availableTransitions[0]}.label`)}
Cambiar a {t(`catalog.proformas.status.${next_status}`)} </TooltipContent>
</TooltipContent> </Tooltip>
</Tooltip> </TooltipProvider>
</TooltipProvider> )}
))}
{/* Emitir factura: solo si approved */} {/* Emitir factura: solo si approved */}
{!isIssued && proforma.status === "approved" && ( {!isIssued && proforma.status === "approved" && (

View File

@ -1,4 +1,4 @@
import { Badge } from "@repo/shadcn-ui/components"; import { Badge, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
@ -6,6 +6,7 @@ import {
type ProformaStatus, type ProformaStatus,
getProformaStatusButtonVariant, getProformaStatusButtonVariant,
getProformaStatusColor, getProformaStatusColor,
getProformaStatusIcon,
} from "../../../types"; } from "../../../types";
export type ProformaStatusBadgeProps = { export type ProformaStatusBadgeProps = {
@ -16,14 +17,23 @@ export type ProformaStatusBadgeProps = {
export const ProformaStatusBadge = ({ status, className }: ProformaStatusBadgeProps) => { export const ProformaStatusBadge = ({ status, className }: ProformaStatusBadgeProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const normalizedStatus = status.toLowerCase() as ProformaStatus; const normalizedStatus = status.toLowerCase() as ProformaStatus;
const Icon = getProformaStatusIcon(normalizedStatus);
return ( return (
<Badge <Tooltip>
className={cn(getProformaStatusColor(normalizedStatus), "font-semibold", className)} <TooltipTrigger asChild>
variant={getProformaStatusButtonVariant(normalizedStatus)} <Badge
> className={cn(getProformaStatusColor(normalizedStatus), "font-semibold", className)}
{t(`catalog.proformas.status.${normalizedStatus}`, { defaultValue: status })} variant={getProformaStatusButtonVariant(normalizedStatus)}
</Badge> >
<Icon />
{t(`catalog.proformas.status.${normalizedStatus}.label`, { defaultValue: status })}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>{t(`catalog.proformas.status.${normalizedStatus}.description`)}</p>
</TooltipContent>
</Tooltip>
); );
}; };

View File

@ -86,12 +86,16 @@ export const ProformaListPage = () => {
<SelectValue placeholder={t("filters.status")} /> <SelectValue placeholder={t("filters.status")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">{t("catalog.proformas.status.all")}</SelectItem> <SelectItem value="all">{t("catalog.proformas.status.all.label")}</SelectItem>
<SelectItem value="draft">{t("catalog.proformas.status.draft")}</SelectItem> <SelectItem value="draft">{t("catalog.proformas.status.draft.label")}</SelectItem>
<SelectItem value="sent">{t("catalog.proformas.status.sent")}</SelectItem> <SelectItem value="sent">{t("catalog.proformas.status.sent.label")}</SelectItem>
<SelectItem value="approved">{t("catalog.proformas.status.approved")}</SelectItem> <SelectItem value="approved">
<SelectItem value="rejected">{t("catalog.proformas.status.rejected")}</SelectItem> {t("catalog.proformas.status.approved.label")}
<SelectItem value="issued">{t("catalog.proformas.status.issued")}</SelectItem> </SelectItem>
<SelectItem value="rejected">
{t("catalog.proformas.status.rejected.label")}
</SelectItem>
<SelectItem value="issued">{t("catalog.proformas.status.issued.label")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -125,22 +129,18 @@ export const ProformaListPage = () => {
<ChangeStatusDialog <ChangeStatusDialog
isSubmitting={changeStatusDialogCtrl.isSubmitting} isSubmitting={changeStatusDialogCtrl.isSubmitting}
onConfirm={changeStatusDialogCtrl.confirmChangeStatus} onConfirm={changeStatusDialogCtrl.confirmChangeStatus}
onOpenChange={(open) => { onOpenChange={(open) => !open && changeStatusDialogCtrl.closeDialog()}
if (!open) changeStatusDialogCtrl.closeDialog();
}}
open={changeStatusDialogCtrl.open} open={changeStatusDialogCtrl.open}
proformaRef={changeStatusDialogCtrl.proforma?.reference} proformas={changeStatusDialogCtrl.proformas} // ← recibe el status seleccionado
proformas={changeStatusDialogCtrl.proformas}
targetStatus={changeStatusDialogCtrl.targetStatus ?? undefined}
/> />
{/* Eliminar */} {/* Eliminar */}
<DeleteProformaDialog <DeleteProformaDialog
isSubmitting={deleteDialogCtrl.isSubmitting} isSubmitting={deleteDialogCtrl.isSubmitting}
onConfirm={deleteDialogCtrl.confirmDelete} onConfirm={deleteDialogCtrl.confirmDelete}
onOpenChange={(o) => !o && deleteDialogCtrl.closeDialog()} onOpenChange={(open) => !open && deleteDialogCtrl.closeDialog()}
open={deleteDialogCtrl.open} open={deleteDialogCtrl.open}
proformaRef={deleteDialogCtrl.proforma?.reference} proformas={deleteDialogCtrl.proformas}
/> />
</AppContent> </AppContent>
</> </>

View File

@ -1,3 +1,12 @@
import {
CheckCircle2Icon,
FileCheckIcon,
FileQuestionIcon,
FileTextIcon,
SendIcon,
XCircleIcon,
} from "lucide-react";
export enum PROFORMA_STATUS { export enum PROFORMA_STATUS {
DRAFT = "draft", DRAFT = "draft",
SENT = "sent", SENT = "sent",
@ -52,3 +61,20 @@ export const getProformaStatusColor = (status: ProformaStatus): string => {
return "bg-gray-100 text-gray-700 hover:bg-gray-100"; return "bg-gray-100 text-gray-700 hover:bg-gray-100";
} }
}; };
export const getProformaStatusIcon = (status: ProformaStatus) => {
switch (status) {
case "draft":
return FileTextIcon;
case "sent":
return SendIcon;
case "approved":
return CheckCircle2Icon;
case "rejected":
return XCircleIcon;
case "issued":
return FileCheckIcon;
default:
return FileQuestionIcon;
}
};

View File

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