Facturas de cliente
This commit is contained in:
parent
a062732e5e
commit
323860b5b8
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 ? (
|
||||
<>
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./status-legend";
|
||||
export * from "./status-node";
|
||||
export * from "./timeline-connector";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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]
|
||||
);
|
||||
|
||||
@ -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" && (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@ -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";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user