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:
parent
67ef358866
commit
c0ed1ca92c
@ -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;
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user