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)",
"download_pdf": "Download PDF",
"send_email": "Send email",
"insert_row_above": "Insert row above",
"insert_row_below": "Insert row below",
"delete_row": "Delete",
"actions": "Actions",
"rows_selected": "{{count}} fila(s) seleccionadas.",
"rows_selected_of_total": "{{count}} de {{total}} fila(s) seleccionadas.",
"search_placeholder": "Type for search...",
"search": "Search",
"clear": "Clear"
},
"catalog": {
"proformas": {
"status": {
"all": "All",
"draft": "Draft",
"sent": "Sent",
"approved": "Approved",
"rejected": "Rejected",
"issued": "Issued"
"all": {
"label": "All",
"description": ""
},
"draft": {
"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": {

View File

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

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

View File

@ -8,64 +8,62 @@ import {
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";
import {
PROFORMA_STATUS,
PROFORMA_STATUS_TRANSITIONS,
type ProformaSummaryData,
getProformaStatusIcon,
} from "../../types";
import { StatusNode, TimelineConnector } from "./components";
interface ChangeStatusDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
proformas: { id: string; status: string }[];
proformas: ProformaSummaryData[];
targetStatus?: string;
isSubmitting: boolean;
onConfirm: () => void;
onConfirm: (status: PROFORMA_STATUS) => void;
}
const STATUS_FLOW = [
{
status: PROFORMA_STATUS.DRAFT,
icon: FileTextIcon,
color: "text-gray-500",
bgColor: "bg-gray-100",
borderColor: "border-gray-300",
activeColor: "text-gray-700",
activeBg: "bg-gray-100",
activeBorder: "border-gray-300",
activeRing: "ring-gray-200",
},
{
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",
activeColor: "text-yellow-700",
activeBg: "bg-yellow-100",
activeBorder: "border-yellow-300",
activeRing: "ring-yellow-200",
},
{
status: PROFORMA_STATUS.REJECTED,
icon: XCircleIcon,
color: "text-red-600",
bgColor: "bg-red-100",
borderColor: "border-red-300",
activeColor: "text-red-700",
activeBg: "bg-red-100",
activeBorder: "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,
icon: FileCheckIcon,
color: "text-purple-600",
bgColor: "bg-purple-100",
borderColor: "border-purple-300",
activeColor: "text-blue-700",
activeBg: "bg-blue-100",
activeBorder: "border-blue-300",
activeRing: "ring-blue-200",
},
] as const;
@ -122,12 +120,12 @@ export function ChangeStatusDialog({
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-4xl">
<DialogContent className="sm: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 la proforma #${proformas[0].reference}`
: `Selecciona el nuevo estado para ${proformas.length} proformas seleccionadas`}
</DialogDescription>
</DialogHeader>
@ -139,150 +137,56 @@ export function ChangeStatusDialog({
) : (
<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">
<div className="relative grid grid-cols-5 gap-2 mb-8">
{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"
)}
<StatusNode
activeBg={statusConfig.activeBg}
activeBorder={statusConfig.activeBorder}
activeColor={statusConfig.activeColor}
activeRing={statusConfig.activeRing}
description={t(`catalog.proformas.status.${statusConfig.status}.description`)}
icon={getProformaStatusIcon(statusConfig.status)}
isSelected={isSelected}
key={statusConfig.status}
label={t(`catalog.proformas.status.${statusConfig.status}.label`)}
onClick={() => {
if (isClickable) {
setSelectedStatus(statusConfig.status);
}
}}
status={statusConfig.status}
statusType={statusType}
/>
{/* 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>
<TimelineConnector
nodeCount={STATUS_FLOW.length}
statusColors={STATUS_FLOW.map((statusConfig) => ({
bgClass: statusConfig.activeBg,
statusType: getStatusType(statusConfig.status),
}))}
/>
</div>
</div>
)}
<DialogFooter>
<DialogFooter className="sm:justify-between">
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
Cancelar
</Button>
<Button
disabled={!selectedStatus || availableStatuses.length === 0 || isSubmitting}
onClick={onConfirm}
onClick={() => {
if (!selectedStatus) return;
onConfirm(selectedStatus); // ← enviamos el estado al controlador
}}
>
{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 { useDeleteProforma } from "../hooks";
interface ProformaState {
interface DeleteProformaDialogState {
open: boolean;
proforma: ProformaSummaryData | null;
proformas: ProformaSummaryData[];
loading: boolean;
requireSecondConfirm: boolean;
}
export function useDeleteProformaDialogController() {
const { t } = useTranslation();
const { mutate, isPending } = useDeleteProforma();
const { deleteProforma } = useDeleteProforma();
const [state, setState] = React.useState<ProformaState>({
const [state, setState] = React.useState<DeleteProformaDialogState>({
open: false,
proforma: null,
proformas: [],
loading: false,
requireSecondConfirm: false,
});
const openDialog = (proforma: ProformaSummaryData) => {
setState({ open: true, proforma });
const openDialog = (proformas: ProformaSummaryData[]) => {
const needDoubleCheck = proformas.length > 5;
setState({
open: true,
proformas,
loading: false,
requireSecondConfirm: needDoubleCheck,
});
};
const closeDialog = () => {
setState((s) => ({ ...s, open: false }));
};
const confirmDelete = () => {
if (!state.proforma) return;
const confirmDelete = async () => {
if (state.proformas.length === 0) 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);
},
if (state.requireSecondConfirm) {
setState((s) => ({ ...s, requireSecondConfirm: false }));
return; // ahora el UI mostrará un segundo mensaje de confirmación
}
setState((s) => ({ ...s, loading: true }));
for (const p of state.proformas) {
await deleteProforma(p.id, {
onSuccess: () => {
showSuccessToast(
"Proforma eliminada",
`La proforma ${p.reference ?? `#${p.id}`} ha sido eliminada.`
);
},
onError: (err) => {
showErrorToast(
"Error al eliminar",
err instanceof Error ? err.message : "Ocurrió un error al eliminar la proforma"
);
},
});
}
setState((s) => ({ ...s, loading: false }));
closeDialog();
};
return {
open: state.open,
proforma: state.proforma,
isSubmitting: isPending,
proformas: state.proformas,
isSubmitting: state.loading,
openDialog,
closeDialog,
confirmDelete,
};
}

View File

@ -3,7 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { deleteProformaApi } from "../api";
interface useDeleteProformaPayload {
interface DeleteProformaOptions {
onSuccess?: () => void;
onError?: (err: unknown) => void;
onLoadingChange?: (loading: boolean) => void;
}
interface DeleteProformaPayload {
proformaId: string;
}
@ -11,13 +17,29 @@ export function useDeleteProforma() {
const dataSource = useDataSource();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ proformaId }: useDeleteProformaPayload) =>
const mutation = useMutation({
mutationFn: ({ proformaId }: DeleteProformaPayload) =>
deleteProformaApi(dataSource, proformaId),
onSuccess() {
console.log("hola");
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,
Spinner,
} from "@repo/shadcn-ui/components";
import { Trans } from "react-i18next";
import { useEffect } from "react";
import { useTranslation } from "../../../i18n";
import type { ProformaSummaryData } from "../../types";
interface DeleteProformaDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
proformaRef?: string;
proformas: ProformaSummaryData[];
isSubmitting: boolean;
onConfirm: () => void;
requireSecondConfirm: boolean;
}
export function DeleteProformaDialog({
open,
onOpenChange,
proformaRef,
proformas,
isSubmitting,
onConfirm,
requireSecondConfirm,
}: DeleteProformaDialogProps) {
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 (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogContent className="max-w-lg">
<AlertDialogHeader>
<AlertDialogTitle>{t("proformas.delete_proforma_dialog.title")}</AlertDialogTitle>
<AlertDialogDescription>
<Trans
i18nKey={t("proformas.delete_proforma_dialog.description")}
values={{ proformaRef }}
/>
</AlertDialogDescription>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</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">
{t("proformas.delete_proforma_dialog.cancel")}
</Button>
@ -52,8 +95,12 @@ export function DeleteProformaDialog({
<Spinner className="mr-2 size-4" />
{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_plural")}</>
)}
</Button>
</AlertDialogFooter>

View File

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

View File

@ -244,15 +244,14 @@ export function useProformasGridColumns(
)}
{/* Cambiar estado */}
{!isIssued &&
availableTransitions.map((next_status) => (
<TooltipProvider key={next_status}>
{!isIssued && availableTransitions.length && (
<TooltipProvider key={availableTransitions[0]}>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="size-8 cursor-pointer"
onClick={() =>
actionHandlers.onChangeStatusClick?.(proforma, next_status)
actionHandlers.onChangeStatusClick?.(proforma, availableTransitions[0])
}
size="icon"
variant="ghost"
@ -262,11 +261,11 @@ export function useProformasGridColumns(
</Button>
</TooltipTrigger>
<TooltipContent>
Cambiar a {t(`catalog.proformas.status.${next_status}`)}
Cambiar a {t(`catalog.proformas.status.${availableTransitions[0]}.label`)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
)}
{/* Emitir factura: solo si 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 { useTranslation } from "../../../../i18n";
@ -6,6 +6,7 @@ import {
type ProformaStatus,
getProformaStatusButtonVariant,
getProformaStatusColor,
getProformaStatusIcon,
} from "../../../types";
export type ProformaStatusBadgeProps = {
@ -16,14 +17,23 @@ export type ProformaStatusBadgeProps = {
export const ProformaStatusBadge = ({ status, className }: ProformaStatusBadgeProps) => {
const { t } = useTranslation();
const normalizedStatus = status.toLowerCase() as ProformaStatus;
const Icon = getProformaStatusIcon(normalizedStatus);
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge
className={cn(getProformaStatusColor(normalizedStatus), "font-semibold", className)}
variant={getProformaStatusButtonVariant(normalizedStatus)}
>
{t(`catalog.proformas.status.${normalizedStatus}`, { defaultValue: status })}
<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")} />
</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>
<SelectItem value="all">{t("catalog.proformas.status.all.label")}</SelectItem>
<SelectItem value="draft">{t("catalog.proformas.status.draft.label")}</SelectItem>
<SelectItem value="sent">{t("catalog.proformas.status.sent.label")}</SelectItem>
<SelectItem value="approved">
{t("catalog.proformas.status.approved.label")}
</SelectItem>
<SelectItem value="rejected">
{t("catalog.proformas.status.rejected.label")}
</SelectItem>
<SelectItem value="issued">{t("catalog.proformas.status.issued.label")}</SelectItem>
</SelectContent>
</Select>
</div>
@ -125,22 +129,18 @@ export const ProformaListPage = () => {
<ChangeStatusDialog
isSubmitting={changeStatusDialogCtrl.isSubmitting}
onConfirm={changeStatusDialogCtrl.confirmChangeStatus}
onOpenChange={(open) => {
if (!open) changeStatusDialogCtrl.closeDialog();
}}
onOpenChange={(open) => !open && changeStatusDialogCtrl.closeDialog()}
open={changeStatusDialogCtrl.open}
proformaRef={changeStatusDialogCtrl.proforma?.reference}
proformas={changeStatusDialogCtrl.proformas}
targetStatus={changeStatusDialogCtrl.targetStatus ?? undefined}
proformas={changeStatusDialogCtrl.proformas} // ← recibe el status seleccionado
/>
{/* Eliminar */}
<DeleteProformaDialog
isSubmitting={deleteDialogCtrl.isSubmitting}
onConfirm={deleteDialogCtrl.confirmDelete}
onOpenChange={(o) => !o && deleteDialogCtrl.closeDialog()}
onOpenChange={(open) => !open && deleteDialogCtrl.closeDialog()}
open={deleteDialogCtrl.open}
proformaRef={deleteDialogCtrl.proforma?.reference}
proformas={deleteDialogCtrl.proformas}
/>
</AppContent>
</>

View File

@ -1,3 +1,12 @@
import {
CheckCircle2Icon,
FileCheckIcon,
FileQuestionIcon,
FileTextIcon,
SendIcon,
XCircleIcon,
} from "lucide-react";
export enum PROFORMA_STATUS {
DRAFT = "draft",
SENT = "sent",
@ -52,3 +61,20 @@ export const getProformaStatusColor = (status: ProformaStatus): string => {
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-tax-summary";