feat: enhance proforma status management and UI components

- Added new StatusBadge and StatusOptionCard components for better status representation.
- Integrated status selection and change functionality in ChangeProformaStatusDialog.
- Improved ProformasGrid to support metadata and selection actions.
- Implemented bulk actions for proforma selection, including status change and deletion.
- Updated DataTable components to support custom selection actions.
- Enhanced ProformaStatusBadge to include specific styles for each status.
This commit is contained in:
David Arranz 2026-07-30 17:36:29 +02:00
parent 67ef358866
commit c0ed1ca92c
7 changed files with 652 additions and 354 deletions

View File

@ -8,16 +8,16 @@ import {
DialogTitle, DialogTitle,
Spinner, Spinner,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ArrowRightIcon, CheckIcon, LockIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "../../../i18n"; import { useTranslation } from "../../../i18n";
import { PROFORMA_STATUS, type ProformaStatus } from "../../shared"; import { PROFORMA_STATUS, type ProformaStatus } from "../../shared";
import type { ChangeProformaStatusTarget } from "../entities"; import type { ChangeProformaStatusTarget } from "../entities";
import { getProformaStatusIcon } from "../helpers"; import { getProformaStatusColor, getProformaStatusIcon } from "../helpers";
import { getCommonAvailableProformaStatuses } from "../utils"; import { getCommonAvailableProformaStatuses } from "../utils";
import { StatusLegend, StatusNode, TimelineConnector } from "./components";
interface ChangeProformaStatusDialogProps { interface ChangeProformaStatusDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@ -26,43 +26,76 @@ interface ChangeProformaStatusDialogProps {
onConfirm: (status: ProformaStatus) => void; onConfirm: (status: ProformaStatus) => void;
} }
const STATUS_FLOW = [ const STATUS_ORDER: ProformaStatus[] = [
{ PROFORMA_STATUS.DRAFT,
status: PROFORMA_STATUS.DRAFT, PROFORMA_STATUS.SENT,
activeColor: "text-gray-700", PROFORMA_STATUS.APPROVED,
activeBg: "bg-gray-100", PROFORMA_STATUS.REJECTED,
activeBorder: "border-gray-300", PROFORMA_STATUS.ISSUED,
activeRing: "ring-gray-200", ];
},
{ const StatusBadge = ({ status }: { status: ProformaStatus }) => {
status: PROFORMA_STATUS.SENT, const { t } = useTranslation();
activeColor: "text-yellow-700", const Icon = getProformaStatusIcon(status);
activeBg: "bg-yellow-100",
activeBorder: "border-yellow-300", return (
activeRing: "ring-yellow-200", <span
}, className={cn(
{ "inline-flex items-center gap-1 rounded-full border px-3 py-1 text-sm font-medium",
status: PROFORMA_STATUS.REJECTED, getProformaStatusColor(status)
activeColor: "text-red-700", )}
activeBg: "bg-red-100", >
activeBorder: "border-red-300", <Icon className="size-3.5" />
activeRing: "ring-red-200", {t(`catalog.proformas.status.${status}.label`)}
}, </span>
{ );
status: PROFORMA_STATUS.APPROVED, };
activeColor: "text-green-700",
activeBg: "bg-green-100", interface StatusOptionCardProps {
activeBorder: "border-green-300", status: ProformaStatus;
activeRing: "ring-green-200", selected: boolean;
}, onClick: () => void;
{ }
status: PROFORMA_STATUS.ISSUED,
activeColor: "text-blue-700", const StatusOptionCard = ({ status, selected, onClick }: StatusOptionCardProps) => {
activeBg: "bg-blue-100", const { t } = useTranslation();
activeBorder: "border-blue-300", const Icon = getProformaStatusIcon(status);
activeRing: "ring-blue-200",
}, return (
] as const; <button
aria-checked={selected}
className={cn(
"flex w-full items-start gap-3 rounded-lg border p-4 text-left transition-colors",
selected
? "border-primary bg-primary/5 ring-1 ring-primary"
: "hover:border-foreground/20 hover:bg-muted/50"
)}
onClick={onClick}
role="radio"
type="button"
>
<div
className={cn(
"mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full",
selected ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
)}
>
<Icon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 font-medium text-foreground">
<span>{t(`catalog.proformas.status.${status}.label`)}</span>
{selected ? <CheckIcon aria-hidden="true" className="size-4 text-primary" /> : null}
</div>
<p className="text-sm text-muted-foreground">
{t(`catalog.proformas.status.${status}.description`)}
</p>
</div>
</button>
);
};
export const ChangeProformaStatusDialog = ({ export const ChangeProformaStatusDialog = ({
open, open,
@ -81,33 +114,123 @@ export const ChangeProformaStatusDialog = ({
}, [open]); }, [open]);
const availableStatuses = useMemo(() => getCommonAvailableProformaStatuses(targets), [targets]); const availableStatuses = useMemo(() => getCommonAvailableProformaStatuses(targets), [targets]);
const orderedAvailableStatuses = useMemo(
() => STATUS_ORDER.filter((status) => availableStatuses.includes(status)),
[availableStatuses]
);
const currentStatus = targets.length === 1 ? targets[0].status : null; const isSingleTarget = targets.length === 1;
const currentTarget = isSingleTarget ? targets[0] : null;
const currentStatus = currentTarget?.status ?? null;
const getStatusType = ( const emptyState = (
status: ProformaStatus <div className="flex items-start gap-3 rounded-lg border border-dashed p-4 text-sm">
): "past" | "current" | "available" | "unavailable" => { <div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
if (currentStatus === status) { <LockIcon className="size-4 text-muted-foreground" />
return "current"; </div>
} <div>
<p className="font-medium">
{t("proformas.change_proforma_status_dialog.empty_title", "No hay cambios disponibles")}
</p>
<p className="text-muted-foreground">
{t("proformas.change_proforma_status_dialog.empty_available_statuses")}
</p>
</div>
</div>
);
if (!currentStatus) { const singleTargetContent = currentTarget ? (
return availableStatuses.includes(status) ? "available" : "unavailable"; <div className="space-y-5">
} <div className="flex items-center gap-3 rounded-lg border bg-muted/40 p-3">
<span className="text-sm text-muted-foreground">
{t("proformas.change_proforma_status_dialog.current_status", "Estado actual")}
</span>
<StatusBadge status={currentTarget.status} />
</div>
const currentIndex = STATUS_FLOW.findIndex((item) => item.status === currentStatus); {orderedAvailableStatuses.length === 0 ? (
const statusIndex = STATUS_FLOW.findIndex((item) => item.status === status); emptyState
) : (
<fieldset className="space-y-3">
<legend className="text-sm font-medium">
{t(
"proformas.change_proforma_status_dialog.select_new_status",
"Selecciona el nuevo estado"
)}
</legend>
if (statusIndex < currentIndex) { <div
return "past"; aria-label={t(
} "proformas.change_proforma_status_dialog.select_new_status",
"Selecciona el nuevo estado"
)}
role="radiogroup"
>
<div className="flex flex-col gap-3">
{orderedAvailableStatuses.map((status) => (
<StatusOptionCard
key={status}
onClick={() => setSelectedStatus(status)}
selected={selectedStatus === status}
status={status}
/>
))}
</div>
</div>
if (availableStatuses.includes(status)) { {selectedStatus ? (
return "available"; <div className="flex items-center justify-center gap-3 rounded-lg bg-muted/40 p-3">
} <StatusBadge status={currentTarget.status} />
<ArrowRightIcon aria-hidden="true" className="size-4 text-muted-foreground" />
<StatusBadge status={selectedStatus} />
</div>
) : null}
</fieldset>
)}
</div>
) : null;
return "unavailable"; const bulkContent =
}; orderedAvailableStatuses.length === 0 ? (
emptyState
) : (
<div className="space-y-4 py-2">
<div className="rounded-lg border bg-muted/30 p-4 text-sm text-muted-foreground">
{t(
"proformas.change_proforma_status_dialog.bulk_help",
"Selecciona el estado que quieres aplicar a todas las proformas seleccionadas."
)}
</div>
<fieldset className="space-y-3">
<legend className="text-sm font-medium">
{t(
"proformas.change_proforma_status_dialog.select_new_status",
"Selecciona el nuevo estado"
)}
</legend>
<div
aria-label={t(
"proformas.change_proforma_status_dialog.select_new_status",
"Selecciona el nuevo estado"
)}
role="radiogroup"
>
<div className="grid gap-3 sm:grid-cols-2">
{orderedAvailableStatuses.map((status) => (
<StatusOptionCard
key={status}
onClick={() => setSelectedStatus(status)}
selected={selectedStatus === status}
status={status}
/>
))}
</div>
</div>
</fieldset>
</div>
);
return ( return (
<Dialog <Dialog
@ -120,14 +243,14 @@ export const ChangeProformaStatusDialog = ({
}} }}
open={open} open={open}
> >
<DialogContent className="sm:max-w-4xl"> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{t("proformas.change_proforma_status_dialog.title")}</DialogTitle> <DialogTitle>{t("proformas.change_proforma_status_dialog.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
{targets.length === 1 {isSingleTarget
? t("proformas.change_proforma_status_dialog.single_description", { ? t("proformas.change_proforma_status_dialog.single_description", {
reference: targets[0].reference, reference: currentTarget?.reference,
}) })
: t("proformas.change_proforma_status_dialog.multiple_description", { : t("proformas.change_proforma_status_dialog.multiple_description", {
count: targets.length, count: targets.length,
@ -135,62 +258,15 @@ export const ChangeProformaStatusDialog = ({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{availableStatuses.length === 0 ? ( {isSingleTarget ? singleTargetContent : bulkContent}
<div className="py-8 text-center text-sm text-muted-foreground">
{t("proformas.change_proforma_status_dialog.empty_available_statuses")}
</div>
) : (
<div className="py-6">
<div className="relative">
<div className="relative mb-8 grid grid-cols-5 gap-2">
{STATUS_FLOW.map((statusConfig) => {
const statusType = getStatusType(statusConfig.status);
const isSelected = selectedStatus === statusConfig.status;
const isClickable = statusType === "available";
return ( <DialogFooter>
<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}
/>
);
})}
</div>
<TimelineConnector
nodeCount={STATUS_FLOW.length}
statusColors={STATUS_FLOW.map((statusConfig) => ({
bgClass: statusConfig.activeBg,
statusType: getStatusType(statusConfig.status),
}))}
/>
</div>
<StatusLegend hasCurrentStatus={currentStatus !== null} />
</div>
)}
<DialogFooter className="sm:justify-between">
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline"> <Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
{t("proformas.change_proforma_status_dialog.cancel")} {t("proformas.change_proforma_status_dialog.cancel")}
</Button> </Button>
<Button <Button
disabled={!selectedStatus || availableStatuses.length === 0 || isSubmitting} disabled={!selectedStatus || orderedAvailableStatuses.length === 0 || isSubmitting}
onClick={() => { onClick={() => {
if (!selectedStatus) { if (!selectedStatus) {
return; return;

View File

@ -1,4 +1,9 @@
import { DataTable, type DataTableSort, SkeletonDataTable } from "@repo/rdx-ui/components"; import {
DataTable,
type DataTableMeta,
type DataTableSort,
SkeletonDataTable,
} from "@repo/rdx-ui/components";
import type { ColumnDef, OnChangeFn, VisibilityState } from "@tanstack/react-table"; import type { ColumnDef, OnChangeFn, VisibilityState } from "@tanstack/react-table";
import type * as React from "react"; import type * as React from "react";
@ -12,6 +17,7 @@ interface ProformasGridProps {
searching?: boolean; searching?: boolean;
columns: ColumnDef<ProformaListRow, unknown>[]; columns: ColumnDef<ProformaListRow, unknown>[];
meta?: DataTableMeta<ProformaListRow>;
topContent?: React.ReactNode; topContent?: React.ReactNode;
pageIndex: number; pageIndex: number;
@ -31,6 +37,7 @@ interface ProformasGridProps {
export const ProformasGrid = ({ export const ProformasGrid = ({
data, data,
columns, columns,
meta,
topContent, topContent,
loading, loading,
@ -75,6 +82,7 @@ export const ProformasGrid = ({
isSearching={searching} isSearching={searching}
manualPagination manualPagination
manualSorting manualSorting
meta={meta}
onColumnVisibilityChange={onColumnVisibilityChange} onColumnVisibilityChange={onColumnVisibilityChange}
onPageChange={onPageChange} onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange} onPageSizeChange={onPageSizeChange}

View File

@ -3,8 +3,14 @@ import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import { DateHelper } from "@repo/rdx-utils"; import { DateHelper } from "@repo/rdx-utils";
import { import {
Button, Button,
ButtonGroup,
Checkbox, Checkbox,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
Spinner, Spinner,
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -18,6 +24,7 @@ import {
FileDownIcon, FileDownIcon,
FileTextIcon, FileTextIcon,
FolderArchiveIcon, FolderArchiveIcon,
MoreHorizontalIcon,
PencilIcon, PencilIcon,
RefreshCwIcon, RefreshCwIcon,
Trash2Icon, Trash2Icon,
@ -103,7 +110,7 @@ export function useProformasGridColumns(
/> />
), ),
meta: { meta: {
cellClassName: "font-medium", cellClassName: "font-mono text-sm font-medium",
}, },
}, },
@ -119,6 +126,9 @@ export function useProformasGridColumns(
/> />
), ),
cell: ({ row }) => DateHelper.format(row.original.proformaDate), cell: ({ row }) => DateHelper.format(row.original.proformaDate),
meta: {
cellClassName: "text-muted-foreground",
},
}, },
{ {
@ -132,34 +142,9 @@ export function useProformasGridColumns(
cell: ({ row }) => { cell: ({ row }) => {
const proforma = row.original; const proforma = row.original;
const isIssued = proforma.status === "issued";
const invoiceId = proforma.linkedInvoiceId;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ProformaStatusBadge status={proforma.status} /> <ProformaStatusBadge status={proforma.status} />
{/* Enlace discreto a factura real */}
{isIssued && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-6 text-foreground hover:text-primary"
size="icon"
variant="ghost"
>
<a href={`/facturas/${invoiceId}`}>
<ExternalLinkIcon />
<span className="sr-only">Ver factura {invoiceId}</span>
</a>
</Button>
}
/>
<TooltipContent>Ver factura {invoiceId}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div> </div>
); );
}, },
@ -216,7 +201,7 @@ export function useProformasGridColumns(
<EmptyCellValue /> <EmptyCellValue />
), ),
meta: { meta: {
cellClassName: "hidden lg:table-cell max-w-16", cellClassName: "hidden lg:table-cell max-w-16 text-muted-foreground",
headerClassName: "hidden lg:table-cell", headerClassName: "hidden lg:table-cell",
}, },
}, },
@ -243,7 +228,8 @@ export function useProformasGridColumns(
header: "Dtos", header: "Dtos",
enableSorting: false, enableSorting: false,
meta: { meta: {
cellClassName: "hidden 2xl:table-cell text-right tabular-nums font-medium", cellClassName:
"hidden 2xl:table-cell text-right tabular-nums font-medium text-muted-foreground",
headerClassName: "hidden 2xl:table-cell text-right", headerClassName: "hidden 2xl:table-cell text-right",
}, },
}, },
@ -319,224 +305,273 @@ export function useProformasGridColumns(
const isPDFLoading = const isPDFLoading =
actionHandlers.isPdfDownloading && actionHandlers.pdfDownloadingId === proforma.id; actionHandlers.isPdfDownloading && actionHandlers.pdfDownloadingId === proforma.id;
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation(); const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
const nextTransition = availableTransitions[0] ?? null;
const handleEdit = () => actionHandlers.onEditClick?.(proforma);
const handleDuplicate = () => actionHandlers.onDuplicateClick?.(proforma);
const handleArchive = () => actionHandlers.onArchiveClick?.(proforma);
const handleUnarchive = () => actionHandlers.onUnarchiveClick?.(proforma);
const handleIssue = () => actionHandlers.onIssueClick?.(proforma);
const handleDownloadPdf = () => actionHandlers.onDownloadPdf?.(proforma);
const handleDelete = () => actionHandlers.onDeleteClick?.(proforma);
const handleChangeStatus = () => {
if (!nextTransition) {
return;
}
actionHandlers.onChangeStatusClick?.(proforma, nextTransition);
};
const primaryAction = (() => {
if (isIssued && proforma.linkedInvoiceId) {
return {
href: `/issed-invoices/${proforma.linkedInvoiceId}`,
icon: ExternalLinkIcon,
label: "Ver factura",
};
}
if (canIssue) {
return {
icon: FileTextIcon,
label: "Emitir factura",
onClick: handleIssue,
};
}
if (isUnarchivable) {
return {
icon: Undo2Icon,
label: "Desarchivar",
onClick: handleUnarchive,
};
}
if (!isDraft && nextTransition && actionHandlers.onChangeStatusClick) {
return {
icon: RefreshCwIcon,
label: "Cambiar estado",
onClick: handleChangeStatus,
};
}
if (canEdit) {
return {
icon: PencilIcon,
label: "Editar",
onClick: handleEdit,
};
}
if (nextTransition && actionHandlers.onChangeStatusClick) {
return {
icon: RefreshCwIcon,
label: "Cambiar estado",
onClick: handleChangeStatus,
};
}
if (isArchivable && actionHandlers.onArchiveClick) {
return {
icon: FolderArchiveIcon,
label: "Archivar",
onClick: handleArchive,
};
}
return null;
})();
const quickAction = (() => {
if (primaryAction?.label !== "Editar" && canEdit) {
return {
icon: PencilIcon,
label: "Editar",
loading: false,
onClick: handleEdit,
};
}
return null;
})();
const menuActions = [
!isIssued &&
nextTransition &&
actionHandlers.onChangeStatusClick &&
primaryAction?.label !== "Cambiar estado"
? {
icon: RefreshCwIcon,
label: "Cambiar estado",
onClick: handleChangeStatus,
}
: null,
canIssue && primaryAction?.label !== "Emitir factura"
? {
icon: FileTextIcon,
label: "Emitir factura",
onClick: handleIssue,
}
: null,
canEdit && primaryAction?.label !== "Editar" && quickAction?.label !== "Editar"
? {
icon: PencilIcon,
label: "Editar",
onClick: handleEdit,
}
: null,
canDuplicate
? {
icon: CopyIcon,
label: "Duplicar",
onClick: handleDuplicate,
}
: null,
isArchivable && actionHandlers.onArchiveClick && primaryAction?.label !== "Archivar"
? {
icon: FolderArchiveIcon,
label: "Archivar",
onClick: handleArchive,
}
: null,
isUnarchivable &&
actionHandlers.onUnarchiveClick &&
primaryAction?.label !== "Desarchivar"
? {
icon: Undo2Icon,
label: "Desarchivar",
onClick: handleUnarchive,
}
: null,
canDownloadPdf
? {
icon: FileDownIcon,
label: "Descargar PDF",
onClick: handleDownloadPdf,
loading: isPDFLoading,
}
: null,
canDelete
? {
destructive: true,
icon: Trash2Icon,
label: "Eliminar",
onClick: handleDelete,
}
: null,
].filter(Boolean);
return ( return (
<ButtonGroup> <div className="flex items-center justify-end gap-1 [@media(hover:hover)_and_(pointer:fine)]:pointer-events-none [@media(hover:hover)_and_(pointer:fine)]:opacity-0 [@media(hover:hover)_and_(pointer:fine)]:transition-opacity [@media(hover:hover)_and_(pointer:fine)]:duration-150 [@media(hover:hover)_and_(pointer:fine)]:group-hover:pointer-events-auto [@media(hover:hover)_and_(pointer:fine)]:group-hover:opacity-100 [@media(hover:hover)_and_(pointer:fine)]:group-focus-within:pointer-events-auto [@media(hover:hover)_and_(pointer:fine)]:group-focus-within:opacity-100">
{canEdit && ( {primaryAction && "href" in primaryAction ? (
<Button className="h-8 gap-2 px-3" size="sm" variant="outline">
<a
href={primaryAction.href}
onClick={(event) => {
event.stopPropagation();
}}
>
<primaryAction.icon className="size-4" />
<span>{primaryAction.label}</span>
</a>
</Button>
) : primaryAction ? (
<Button
className="h-8 gap-2 px-3"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
primaryAction.onClick?.();
}}
size="sm"
variant="outline"
>
<primaryAction.icon className="size-4" />
<span>{primaryAction.label}</span>
</Button>
) : null}
{quickAction ? (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
<Button <Button
className="size-7 cursor-pointer text-muted-foreground hover:text-primary " className="size-8 text-muted-foreground hover:text-primary"
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
quickAction.onClick();
actionHandlers.onEditClick?.(proforma);
}} }}
size="icon" size="icon"
variant="ghost" variant="ghost"
> >
<PencilIcon className="size-4" /> {quickAction.loading ? (
<span className="sr-only">Editar</span>
</Button>
}
/>
<TooltipContent>Editar</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{canDuplicate && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-7 cursor-pointer text-muted-foreground hover:text-primary"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
actionHandlers.onDuplicateClick?.(proforma);
}}
size="icon"
variant="ghost"
>
<CopyIcon className="size-4" />
<span className="sr-only">Duplicar</span>
</Button>
}
/>
<TooltipContent>Duplicar</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isArchivable && actionHandlers.onArchiveClick && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-7 cursor-pointer text-muted-foreground hover:text-primary"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
actionHandlers.onArchiveClick?.(proforma);
}}
size="icon"
variant="ghost"
>
<FolderArchiveIcon className="size-4" />
</Button>
}
/>
<TooltipContent>Archivar</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isUnarchivable && actionHandlers.onUnarchiveClick && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-7 cursor-pointer text-muted-foreground hover:text-primary"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
actionHandlers.onUnarchiveClick?.(proforma);
}}
size="icon"
variant="ghost"
>
<Undo2Icon className="size-4" />
</Button>
}
/>
<TooltipContent>Desarchivar</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Cambiar estado */}
{!(isDeletedScope || isIssued) &&
availableTransitions.length > 0 &&
actionHandlers.onChangeStatusClick && (
<TooltipProvider key={availableTransitions[0]}>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-7 cursor-pointer text-muted-foreground hover:text-primary"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
actionHandlers.onChangeStatusClick?.(
proforma,
availableTransitions[0]
);
}}
size="icon"
variant="ghost"
>
<RefreshCwIcon className="size-4" />
<span className="sr-only">Cambiar estado</span>
</Button>
}
/>
<TooltipContent>
Cambiar a {t(`catalog.proformas.status.${availableTransitions[0]}.label`)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Emitir factura: solo si approved */}
{canIssue && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-7 cursor-pointer text-muted-foreground hover:text-primary"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
actionHandlers.onIssueClick?.(proforma);
}}
size="icon"
variant="ghost"
>
<FileTextIcon className="size-4" />
</Button>
}
/>
<TooltipContent>Emitir a factura</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Descargar en PDF */}
{canDownloadPdf && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className={"size-8"}
disabled={isPDFLoading}
onClick={(e) => {
stop(e);
actionHandlers.onDownloadPdf?.(proforma);
}}
size="icon"
variant="ghost"
>
{isPDFLoading ? (
<Spinner className="size-4 cursor-progress" /> <Spinner className="size-4 cursor-progress" />
) : ( ) : (
<FileDownIcon className="size-4 cursor-pointer" /> <quickAction.icon className="size-4" />
)} )}
<span className="sr-only">Descargar PDF</span> <span className="sr-only">{quickAction.label}</span>
</Button> </Button>
} }
/> />
<TooltipContent>Descargar PDF</TooltipContent> <TooltipContent>{quickAction.label}</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)} ) : null}
{/* Eliminar */} {menuActions.length > 0 ? (
{canDelete && ( <DropdownMenu>
<TooltipProvider> <DropdownMenuTrigger
<Tooltip> render={
<TooltipTrigger <Button
render={ className="size-8 text-muted-foreground hover:text-primary"
<Button onClick={stop}
className="size-8 text-destructive/75 hover:text-destructive cursor-pointer" size="icon"
onClick={(event) => { variant="ghost"
event.preventDefault(); >
event.stopPropagation(); <MoreHorizontalIcon className="size-4" />
<span className="sr-only">Más acciones</span>
actionHandlers.onDeleteClick?.(proforma); </Button>
}} }
size="icon" />
variant="ghost" <DropdownMenuContent align="end" className="w-52" onClick={stop}>
> <DropdownMenuGroup>
<Trash2Icon className="size-4" /> <DropdownMenuLabel>Acciones</DropdownMenuLabel>
</Button> <DropdownMenuSeparator />
} {menuActions.length &&
/> menuActions.map((action, index) =>
<TooltipContent>Eliminar</TooltipContent> action ? (
</Tooltip> <React.Fragment key={action.label}>
</TooltipProvider> {action.destructive && index > 0 ? <DropdownMenuSeparator /> : null}
)} <DropdownMenuItem
</ButtonGroup> className={
action.destructive
? "text-destructive focus:text-destructive"
: undefined
}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
action.onClick();
}}
variant={action.destructive ? "destructive" : "default"}
>
{"loading" in action && action.loading ? (
<Spinner className="mr-2 size-4" />
) : (
<action.icon className="mr-2 size-4" />
)}
<span>{action.label}</span>
</DropdownMenuItem>
</React.Fragment>
) : null
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
); );
}, },
}, },

View File

@ -1,12 +1,9 @@
import { Badge, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components"; import { 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 { ReceiptTextIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
import { import { getProformaStatusIcon } from "../../../change-status/helpers";
getProformaStatusButtonVariant,
getProformaStatusColor,
getProformaStatusIcon,
} from "../../../change-status/helpers";
import type { ProformaStatus } from "../../../shared"; import type { ProformaStatus } from "../../../shared";
export type ProformaStatusBadgeProps = { export type ProformaStatusBadgeProps = {
@ -17,19 +14,36 @@ 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); const Icon = normalizedStatus === "issued" ? ReceiptTextIcon : getProformaStatusIcon(normalizedStatus);
const stylesByStatus: Record<ProformaStatus, string> = {
draft:
"border-gray-200 bg-gray-50 text-gray-600 ring-1 ring-inset ring-gray-200/90",
sent:
"border-amber-300 bg-amber-50 text-amber-800 ring-1 ring-inset ring-amber-300/90",
approved:
"border-emerald-300 bg-emerald-100/70 text-emerald-700 ring-1 ring-inset ring-emerald-300/90",
rejected:
"border-red-300 bg-red-50 text-red-600 ring-1 ring-inset ring-red-300/90",
issued:
"border-sky-300 bg-sky-100/70 text-sky-700 ring-1 ring-inset ring-sky-300/90",
};
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
<Badge <span
className={cn(getProformaStatusColor(normalizedStatus), "gap-1", className)} className={cn(
variant={getProformaStatusButtonVariant(normalizedStatus)} "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium leading-none whitespace-nowrap",
"[&>svg]:pointer-events-none [&>svg]:shrink-0 [&>svg]:size-3.5",
stylesByStatus[normalizedStatus],
className
)}
> >
<Icon className="size-3" /> <Icon aria-hidden="true" />
{t(`catalog.proformas.status.${normalizedStatus}.label`, { defaultValue: status })} {t(`catalog.proformas.status.${normalizedStatus}.label`, { defaultValue: status })}
</Badge> </span>
} }
/> />
<TooltipContent> <TooltipContent>

View File

@ -1,30 +1,42 @@
import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components"; import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components";
import { useReturnToNavigation } from "@erp/core/hooks"; import { useDataSource, useReturnToNavigation } from "@erp/core/hooks";
import { type DataTableMeta, AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers"; import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { import {
Button, Button,
Card, Card,
CardContent, CardContent,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
ResizablePanelGroup, ResizablePanelGroup,
Spinner,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { import {
CheckCircle2Icon, CheckCircle2Icon,
CircleDollarSignIcon, CircleDollarSignIcon,
ClockIcon, ClockIcon,
FileDownIcon,
FileTextIcon, FileTextIcon,
MoreHorizontalIcon,
PlusIcon, PlusIcon,
RefreshCwIcon,
Trash2Icon,
} from "lucide-react"; } from "lucide-react";
import { createSearchParams, useNavigate } from "react-router-dom"; import { createSearchParams, useNavigate } from "react-router-dom";
import { useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
import { ChangeProformaStatusDialog } from "../../../change-status"; import { ChangeProformaStatusDialog } from "../../../change-status";
import { prepareChangeProformaStatusTargets } from "../../../change-status/utils"; import { prepareChangeProformaStatusTargets } from "../../../change-status/utils";
import { DeleteProformaDialog } from "../../../delete"; import { DeleteProformaDialog } from "../../../delete";
import { prepareDeleteProformaTargets } from "../../../delete/utils"; import { prepareDeleteProformaTargets } from "../../../delete/utils";
import { downloadProformaPDFApi } from "../../../download-pdf/api";
import { IssueProformaDialog } from "../../../issue-proforma"; import { IssueProformaDialog } from "../../../issue-proforma";
import { prepareIssueProformaTarget } from "../../../issue-proforma/utils"; import { prepareIssueProformaTarget } from "../../../issue-proforma/utils";
import type { ProformaListRow } from "../../../shared"; import type { ProformaListRow } from "../../../shared";
@ -42,6 +54,13 @@ interface ScopedListProformasPageProps {
scope: ProformaListScope; scope: ProformaListScope;
} }
interface ProformasSelectionActionsProps {
scope: ProformaListScope;
selectedProformas: ProformaListRow[];
onChangeStatus: (selected: ProformaListRow[]) => void;
onDelete: (selected: ProformaListRow[]) => void;
}
const SCOPE_CONFIG: Record< const SCOPE_CONFIG: Record<
ProformaListScope, ProformaListScope,
{ {
@ -79,6 +98,116 @@ const SCOPE_CONFIG: Record<
}, },
}; };
const ProformasSelectionActions = ({
scope,
selectedProformas,
onChangeStatus,
onDelete,
}: ProformasSelectionActionsProps) => {
const { t } = useTranslation();
const dataSource = useDataSource();
const [isDownloading, setIsDownloading] = useState(false);
const canDeleteSelection =
scope !== "deleted" &&
selectedProformas.length > 0 &&
selectedProformas.every((proforma) => proforma.status === "draft");
const handleDownloadSelection = useCallback(async () => {
if (selectedProformas.length === 0 || isDownloading) {
return;
}
setIsDownloading(true);
let successCount = 0;
try {
for (const proforma of selectedProformas) {
const blob = await downloadProformaPDFApi(dataSource, proforma.id);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `proforma-${proforma.proformaReference}.pdf`;
anchor.click();
URL.revokeObjectURL(url);
successCount += 1;
}
showSuccessToast(
"Descarga iniciada",
successCount === 1
? `La proforma ${selectedProformas[0]?.proformaReference ?? ""} se está descargando.`
: `Se han iniciado ${successCount} descargas de proformas.`
);
} catch (error) {
showErrorToast(
"Error al descargar",
error instanceof Error ? error.message : "No se pudieron descargar las proformas seleccionadas."
);
} finally {
setIsDownloading(false);
}
}, [dataSource, isDownloading, selectedProformas]);
return (
<>
<Button
className="cursor-pointer"
onClick={() => onChangeStatus(selectedProformas)}
size="sm"
type="button"
variant="outline"
>
<RefreshCwIcon aria-hidden="true" className="mr-2 size-4" />
<span>{t("proformas.change_proforma_status_dialog.title")}</span>
</Button>
<Button
className="cursor-pointer"
disabled={isDownloading}
onClick={() => {
void handleDownloadSelection();
}}
size="sm"
type="button"
variant="outline"
>
{isDownloading ? (
<Spinner aria-hidden="true" className="mr-2 size-4" />
) : (
<FileDownIcon aria-hidden="true" className="mr-2 size-4" />
)}
<span>Descargar PDF</span>
</Button>
{canDeleteSelection ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button className="cursor-pointer" size="sm" type="button" variant="outline">
<MoreHorizontalIcon aria-hidden="true" className="mr-2 size-4" />
<span>Más acciones</span>
</Button>
}
/>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuLabel>Selección</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(selectedProformas)}
variant="destructive"
>
<Trash2Icon aria-hidden="true" className="mr-2 size-4" />
<span>Eliminar borradores</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</>
);
};
const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => { const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@ -155,6 +284,32 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
isPdfDownloading, isPdfDownloading,
}); });
const tableMeta = useMemo<DataTableMeta<ProformaListRow>>(
() => ({
renderSelectionActions: (table) => {
const selectedProformas = table.getSelectedRowModel().rows.map((row) => row.original);
if (selectedProformas.length === 0) {
return null;
}
return (
<ProformasSelectionActions
onChangeStatus={(selected) =>
changeStatusDialogCtrl.openDialog(prepareChangeProformaStatusTargets(selected))
}
onDelete={(selected) =>
deleteDialogCtrl.openDialog(prepareDeleteProformaTargets(selected))
}
scope={scope}
selectedProformas={selectedProformas}
/>
);
},
}),
[changeStatusDialogCtrl, deleteDialogCtrl, scope]
);
const isPanelOpen = scopeConfig.enablePanel && panelCtrl.panelState.isOpen; const isPanelOpen = scopeConfig.enablePanel && panelCtrl.panelState.isOpen;
const listContent = ( const listContent = (
@ -166,6 +321,7 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
data={listCtrl.data} data={listCtrl.data}
fetching={listCtrl.isFetching} fetching={listCtrl.isFetching}
loading={listCtrl.isLoading} loading={listCtrl.isLoading}
meta={tableMeta}
onColumnVisibilityChange={listCtrl.tablePreferences.setColumnVisibility} onColumnVisibilityChange={listCtrl.tablePreferences.setColumnVisibility}
onPageChange={listCtrl.setPageIndex} onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize} onPageSizeChange={listCtrl.setPageSize}

View File

@ -72,6 +72,14 @@ export function DataTableToolbar<TData>({
table.resetRowSelection(); table.resetRowSelection();
}, [table]); }, [table]);
const customSelectionActions = React.useMemo(() => {
if (!hasSelection) {
return null;
}
return meta?.renderSelectionActions?.(table) ?? null;
}, [hasSelection, meta, table]);
// Render principal // Render principal
return ( return (
<div <div
@ -100,7 +108,7 @@ export function DataTableToolbar<TData>({
{/* Acciones sobre selección */} {/* Acciones sobre selección */}
{hasSelection && ( {hasSelection && (
<> <>
<Separator className="h-5 mx-1" orientation="vertical" /> {customSelectionActions}
{!readOnly && meta?.bulkOps?.duplicateSelected && ( {!readOnly && meta?.bulkOps?.duplicateSelected && (
<Button <Button

View File

@ -64,6 +64,7 @@ export type DataTableMeta<TData> = TableMeta<TData> & {
tableOps?: DataTableOps<TData>; tableOps?: DataTableOps<TData>;
rowOps?: DataTableRowOps<TData>; rowOps?: DataTableRowOps<TData>;
bulkOps?: DataTableBulkRowOps<TData>; bulkOps?: DataTableBulkRowOps<TData>;
renderSelectionActions?: (table: Table<TData>) => React.ReactNode;
}; };
export type DataTableSortDirection = "asc" | "desc"; export type DataTableSortDirection = "asc" | "desc";