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,
Spinner,
} 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 { useTranslation } from "../../../i18n";
import { PROFORMA_STATUS, type ProformaStatus } from "../../shared";
import type { ChangeProformaStatusTarget } from "../entities";
import { getProformaStatusIcon } from "../helpers";
import { getProformaStatusColor, getProformaStatusIcon } from "../helpers";
import { getCommonAvailableProformaStatuses } from "../utils";
import { StatusLegend, StatusNode, TimelineConnector } from "./components";
interface ChangeProformaStatusDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -26,43 +26,76 @@ interface ChangeProformaStatusDialogProps {
onConfirm: (status: ProformaStatus) => void;
}
const STATUS_FLOW = [
{
status: PROFORMA_STATUS.DRAFT,
activeColor: "text-gray-700",
activeBg: "bg-gray-100",
activeBorder: "border-gray-300",
activeRing: "ring-gray-200",
},
{
status: PROFORMA_STATUS.SENT,
activeColor: "text-yellow-700",
activeBg: "bg-yellow-100",
activeBorder: "border-yellow-300",
activeRing: "ring-yellow-200",
},
{
status: PROFORMA_STATUS.REJECTED,
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,
activeColor: "text-blue-700",
activeBg: "bg-blue-100",
activeBorder: "border-blue-300",
activeRing: "ring-blue-200",
},
] as const;
const STATUS_ORDER: ProformaStatus[] = [
PROFORMA_STATUS.DRAFT,
PROFORMA_STATUS.SENT,
PROFORMA_STATUS.APPROVED,
PROFORMA_STATUS.REJECTED,
PROFORMA_STATUS.ISSUED,
];
const StatusBadge = ({ status }: { status: ProformaStatus }) => {
const { t } = useTranslation();
const Icon = getProformaStatusIcon(status);
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-sm font-medium",
getProformaStatusColor(status)
)}
>
<Icon className="size-3.5" />
{t(`catalog.proformas.status.${status}.label`)}
</span>
);
};
interface StatusOptionCardProps {
status: ProformaStatus;
selected: boolean;
onClick: () => void;
}
const StatusOptionCard = ({ status, selected, onClick }: StatusOptionCardProps) => {
const { t } = useTranslation();
const Icon = getProformaStatusIcon(status);
return (
<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 = ({
open,
@ -81,33 +114,123 @@ export const ChangeProformaStatusDialog = ({
}, [open]);
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 = (
status: ProformaStatus
): "past" | "current" | "available" | "unavailable" => {
if (currentStatus === status) {
return "current";
}
const emptyState = (
<div className="flex items-start gap-3 rounded-lg border border-dashed p-4 text-sm">
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
<LockIcon className="size-4 text-muted-foreground" />
</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) {
return availableStatuses.includes(status) ? "available" : "unavailable";
}
const singleTargetContent = currentTarget ? (
<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);
const statusIndex = STATUS_FLOW.findIndex((item) => item.status === status);
{orderedAvailableStatuses.length === 0 ? (
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) {
return "past";
}
<div
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)) {
return "available";
}
{selectedStatus ? (
<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 (
<Dialog
@ -120,14 +243,14 @@ export const ChangeProformaStatusDialog = ({
}}
open={open}
>
<DialogContent className="sm:max-w-4xl">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("proformas.change_proforma_status_dialog.title")}</DialogTitle>
<DialogDescription>
{targets.length === 1
{isSingleTarget
? t("proformas.change_proforma_status_dialog.single_description", {
reference: targets[0].reference,
reference: currentTarget?.reference,
})
: t("proformas.change_proforma_status_dialog.multiple_description", {
count: targets.length,
@ -135,62 +258,15 @@ export const ChangeProformaStatusDialog = ({
</DialogDescription>
</DialogHeader>
{availableStatuses.length === 0 ? (
<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";
{isSingleTarget ? singleTargetContent : bulkContent}
return (
<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">
<DialogFooter>
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
{t("proformas.change_proforma_status_dialog.cancel")}
</Button>
<Button
disabled={!selectedStatus || availableStatuses.length === 0 || isSubmitting}
disabled={!selectedStatus || orderedAvailableStatuses.length === 0 || isSubmitting}
onClick={() => {
if (!selectedStatus) {
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 * as React from "react";
@ -12,6 +17,7 @@ interface ProformasGridProps {
searching?: boolean;
columns: ColumnDef<ProformaListRow, unknown>[];
meta?: DataTableMeta<ProformaListRow>;
topContent?: React.ReactNode;
pageIndex: number;
@ -31,6 +37,7 @@ interface ProformasGridProps {
export const ProformasGrid = ({
data,
columns,
meta,
topContent,
loading,
@ -75,6 +82,7 @@ export const ProformasGrid = ({
isSearching={searching}
manualPagination
manualSorting
meta={meta}
onColumnVisibilityChange={onColumnVisibilityChange}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}

View File

@ -3,8 +3,14 @@ import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import { DateHelper } from "@repo/rdx-utils";
import {
Button,
ButtonGroup,
Checkbox,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
Spinner,
Tooltip,
TooltipContent,
@ -18,6 +24,7 @@ import {
FileDownIcon,
FileTextIcon,
FolderArchiveIcon,
MoreHorizontalIcon,
PencilIcon,
RefreshCwIcon,
Trash2Icon,
@ -103,7 +110,7 @@ export function useProformasGridColumns(
/>
),
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),
meta: {
cellClassName: "text-muted-foreground",
},
},
{
@ -132,34 +142,9 @@ export function useProformasGridColumns(
cell: ({ row }) => {
const proforma = row.original;
const isIssued = proforma.status === "issued";
const invoiceId = proforma.linkedInvoiceId;
return (
<div className="flex items-center gap-2">
<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>
);
},
@ -216,7 +201,7 @@ export function useProformasGridColumns(
<EmptyCellValue />
),
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",
},
},
@ -243,7 +228,8 @@ export function useProformasGridColumns(
header: "Dtos",
enableSorting: false,
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",
},
},
@ -319,224 +305,273 @@ export function useProformasGridColumns(
const isPDFLoading =
actionHandlers.isPdfDownloading && actionHandlers.pdfDownloadingId === proforma.id;
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 (
<ButtonGroup>
{canEdit && (
<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">
{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>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-7 cursor-pointer text-muted-foreground hover:text-primary "
className="size-8 text-muted-foreground hover:text-primary"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
actionHandlers.onEditClick?.(proforma);
quickAction.onClick();
}}
size="icon"
variant="ghost"
>
<PencilIcon className="size-4" />
<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 ? (
{quickAction.loading ? (
<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>
}
/>
<TooltipContent>Descargar PDF</TooltipContent>
<TooltipContent>{quickAction.label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
) : null}
{/* Eliminar */}
{canDelete && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
{menuActions.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
className="size-8 text-destructive/75 hover:text-destructive cursor-pointer"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
actionHandlers.onDeleteClick?.(proforma);
}}
className="size-8 text-muted-foreground hover:text-primary"
onClick={stop}
size="icon"
variant="ghost"
>
<Trash2Icon className="size-4" />
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">Más acciones</span>
</Button>
}
/>
<TooltipContent>Eliminar</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="end" className="w-52" onClick={stop}>
<DropdownMenuGroup>
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
<DropdownMenuSeparator />
{menuActions.length &&
menuActions.map((action, index) =>
action ? (
<React.Fragment key={action.label}>
{action.destructive && index > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
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" />
)}
</ButtonGroup>
<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 { ReceiptTextIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
import {
getProformaStatusButtonVariant,
getProformaStatusColor,
getProformaStatusIcon,
} from "../../../change-status/helpers";
import { getProformaStatusIcon } from "../../../change-status/helpers";
import type { ProformaStatus } from "../../../shared";
export type ProformaStatusBadgeProps = {
@ -17,19 +14,36 @@ export type ProformaStatusBadgeProps = {
export const ProformaStatusBadge = ({ status, className }: ProformaStatusBadgeProps) => {
const { t } = useTranslation();
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 (
<Tooltip>
<TooltipTrigger
render={
<Badge
className={cn(getProformaStatusColor(normalizedStatus), "gap-1", className)}
variant={getProformaStatusButtonVariant(normalizedStatus)}
<span
className={cn(
"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 })}
</Badge>
</span>
}
/>
<TooltipContent>

View File

@ -1,30 +1,42 @@
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 { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import {
Button,
Card,
CardContent,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
Spinner,
} from "@repo/shadcn-ui/components";
import {
CheckCircle2Icon,
CircleDollarSignIcon,
ClockIcon,
FileDownIcon,
FileTextIcon,
MoreHorizontalIcon,
PlusIcon,
RefreshCwIcon,
Trash2Icon,
} from "lucide-react";
import { createSearchParams, useNavigate } from "react-router-dom";
import { useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "../../../../i18n";
import { ChangeProformaStatusDialog } from "../../../change-status";
import { prepareChangeProformaStatusTargets } from "../../../change-status/utils";
import { DeleteProformaDialog } from "../../../delete";
import { prepareDeleteProformaTargets } from "../../../delete/utils";
import { downloadProformaPDFApi } from "../../../download-pdf/api";
import { IssueProformaDialog } from "../../../issue-proforma";
import { prepareIssueProformaTarget } from "../../../issue-proforma/utils";
import type { ProformaListRow } from "../../../shared";
@ -42,6 +54,13 @@ interface ScopedListProformasPageProps {
scope: ProformaListScope;
}
interface ProformasSelectionActionsProps {
scope: ProformaListScope;
selectedProformas: ProformaListRow[];
onChangeStatus: (selected: ProformaListRow[]) => void;
onDelete: (selected: ProformaListRow[]) => void;
}
const SCOPE_CONFIG: Record<
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 { t } = useTranslation();
const navigate = useNavigate();
@ -155,6 +284,32 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
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 listContent = (
@ -166,6 +321,7 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
data={listCtrl.data}
fetching={listCtrl.isFetching}
loading={listCtrl.isLoading}
meta={tableMeta}
onColumnVisibilityChange={listCtrl.tablePreferences.setColumnVisibility}
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}

View File

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

View File

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