From c0ed1ca92c0a4a38b57a48d484d892cea5b5523d Mon Sep 17 00:00:00 2001 From: david Date: Thu, 30 Jul 2026 17:36:29 +0200 Subject: [PATCH] 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. --- .../change-status/ui/change-status-dialog.tsx | 302 +++++++---- .../blocks/proformas-grid/proformas-grid.tsx | 10 +- .../use-proforma-grid-columns.tsx | 483 ++++++++++-------- .../ui/components/proforma-status-badge.tsx | 38 +- .../list/ui/pages/list-proformas-page.tsx | 162 +++++- .../datatable/data-table-toolbar.tsx | 10 +- .../src/components/datatable/data-table.tsx | 1 + 7 files changed, 652 insertions(+), 354 deletions(-) diff --git a/modules/customer-invoices/src/web/proformas/change-status/ui/change-status-dialog.tsx b/modules/customer-invoices/src/web/proformas/change-status/ui/change-status-dialog.tsx index 891c727c..7e58783e 100644 --- a/modules/customer-invoices/src/web/proformas/change-status/ui/change-status-dialog.tsx +++ b/modules/customer-invoices/src/web/proformas/change-status/ui/change-status-dialog.tsx @@ -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 ( + + + {t(`catalog.proformas.status.${status}.label`)} + + ); +}; + +interface StatusOptionCardProps { + status: ProformaStatus; + selected: boolean; + onClick: () => void; +} + +const StatusOptionCard = ({ status, selected, onClick }: StatusOptionCardProps) => { + const { t } = useTranslation(); + const Icon = getProformaStatusIcon(status); + + return ( + + ); +}; 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 = ( +
+
+ +
+
+

+ {t("proformas.change_proforma_status_dialog.empty_title", "No hay cambios disponibles")} +

+

+ {t("proformas.change_proforma_status_dialog.empty_available_statuses")} +

+
+
+ ); - if (!currentStatus) { - return availableStatuses.includes(status) ? "available" : "unavailable"; - } + const singleTargetContent = currentTarget ? ( +
+
+ + {t("proformas.change_proforma_status_dialog.current_status", "Estado actual")} + + +
- const currentIndex = STATUS_FLOW.findIndex((item) => item.status === currentStatus); - const statusIndex = STATUS_FLOW.findIndex((item) => item.status === status); + {orderedAvailableStatuses.length === 0 ? ( + emptyState + ) : ( +
+ + {t( + "proformas.change_proforma_status_dialog.select_new_status", + "Selecciona el nuevo estado" + )} + - if (statusIndex < currentIndex) { - return "past"; - } +
+
+ {orderedAvailableStatuses.map((status) => ( + setSelectedStatus(status)} + selected={selectedStatus === status} + status={status} + /> + ))} +
+
- if (availableStatuses.includes(status)) { - return "available"; - } + {selectedStatus ? ( +
+ +
+ ) : null} +
+ )} +
+ ) : null; - return "unavailable"; - }; + const bulkContent = + orderedAvailableStatuses.length === 0 ? ( + emptyState + ) : ( +
+
+ {t( + "proformas.change_proforma_status_dialog.bulk_help", + "Selecciona el estado que quieres aplicar a todas las proformas seleccionadas." + )} +
+ +
+ + {t( + "proformas.change_proforma_status_dialog.select_new_status", + "Selecciona el nuevo estado" + )} + + +
+
+ {orderedAvailableStatuses.map((status) => ( + setSelectedStatus(status)} + selected={selectedStatus === status} + status={status} + /> + ))} +
+
+
+
+ ); return ( - + {t("proformas.change_proforma_status_dialog.title")} - {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 = ({ - {availableStatuses.length === 0 ? ( -
- {t("proformas.change_proforma_status_dialog.empty_available_statuses")} -
- ) : ( -
-
-
- {STATUS_FLOW.map((statusConfig) => { - const statusType = getStatusType(statusConfig.status); - const isSelected = selectedStatus === statusConfig.status; - const isClickable = statusType === "available"; + {isSingleTarget ? singleTargetContent : bulkContent} - return ( - { - if (isClickable) { - setSelectedStatus(statusConfig.status); - } - }} - status={statusConfig.status} - statusType={statusType} - /> - ); - })} -
- - ({ - bgClass: statusConfig.activeBg, - statusType: getStatusType(statusConfig.status), - }))} - /> -
- - -
- )} - - + - } - /> - Ver factura {invoiceId} - - - )} ); }, @@ -216,7 +201,7 @@ export function useProformasGridColumns( ), 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 ( - - {canEdit && ( +
+ {primaryAction && "href" in primaryAction ? ( + + ) : primaryAction ? ( + + ) : null} + + {quickAction ? ( { event.preventDefault(); event.stopPropagation(); - - actionHandlers.onEditClick?.(proforma); + quickAction.onClick(); }} size="icon" variant="ghost" > - - Editar - - } - /> - Editar - - - )} - - {canDuplicate && ( - - - { - event.preventDefault(); - event.stopPropagation(); - actionHandlers.onDuplicateClick?.(proforma); - }} - size="icon" - variant="ghost" - > - - Duplicar - - } - /> - Duplicar - - - )} - - {isArchivable && actionHandlers.onArchiveClick && ( - - - { - event.preventDefault(); - event.stopPropagation(); - actionHandlers.onArchiveClick?.(proforma); - }} - size="icon" - variant="ghost" - > - - - } - /> - Archivar - - - )} - - {isUnarchivable && actionHandlers.onUnarchiveClick && ( - - - { - event.preventDefault(); - event.stopPropagation(); - actionHandlers.onUnarchiveClick?.(proforma); - }} - size="icon" - variant="ghost" - > - - - } - /> - Desarchivar - - - )} - - {/* Cambiar estado */} - {!(isDeletedScope || isIssued) && - availableTransitions.length > 0 && - actionHandlers.onChangeStatusClick && ( - - - { - event.preventDefault(); - event.stopPropagation(); - - actionHandlers.onChangeStatusClick?.( - proforma, - availableTransitions[0] - ); - }} - size="icon" - variant="ghost" - > - - Cambiar estado - - } - /> - - Cambiar a {t(`catalog.proformas.status.${availableTransitions[0]}.label`)} - - - - )} - - {/* Emitir factura: solo si approved */} - {canIssue && ( - - - { - event.preventDefault(); - event.stopPropagation(); - - actionHandlers.onIssueClick?.(proforma); - }} - size="icon" - variant="ghost" - > - - - } - /> - Emitir a factura - - - )} - - {/* Descargar en PDF */} - {canDownloadPdf && ( - - - { - stop(e); - actionHandlers.onDownloadPdf?.(proforma); - }} - size="icon" - variant="ghost" - > - {isPDFLoading ? ( + {quickAction.loading ? ( ) : ( - + )} - Descargar PDF + {quickAction.label} } /> - Descargar PDF + {quickAction.label} - )} + ) : null} - {/* Eliminar */} - {canDelete && ( - - - { - event.preventDefault(); - event.stopPropagation(); - - actionHandlers.onDeleteClick?.(proforma); - }} - size="icon" - variant="ghost" - > - - - } - /> - Eliminar - - - )} - + {menuActions.length > 0 ? ( + + + + Más acciones + + } + /> + + + Acciones + + {menuActions.length && + menuActions.map((action, index) => + action ? ( + + {action.destructive && index > 0 ? : null} + { + event.preventDefault(); + event.stopPropagation(); + action.onClick(); + }} + variant={action.destructive ? "destructive" : "default"} + > + {"loading" in action && action.loading ? ( + + ) : ( + + )} + {action.label} + + + ) : null + )} + + + + ) : null} +
); }, }, diff --git a/modules/customer-invoices/src/web/proformas/list/ui/components/proforma-status-badge.tsx b/modules/customer-invoices/src/web/proformas/list/ui/components/proforma-status-badge.tsx index f04fecb7..0e4910fb 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/components/proforma-status-badge.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/components/proforma-status-badge.tsx @@ -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 = { + 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 ( svg]:pointer-events-none [&>svg]:shrink-0 [&>svg]:size-3.5", + stylesByStatus[normalizedStatus], + className + )} > - +