This commit is contained in:
David Arranz 2026-08-03 15:43:08 +02:00
parent 84bb21e527
commit 10a5c25fc1
25 changed files with 316 additions and 263 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@erp/factuges-server",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"scripts": {
"build": "tsup src/index.ts --config tsup.config.ts",

View File

@ -1,7 +1,7 @@
{
"name": "@erp/factuges-web",
"private": true,
"version": "0.10.0",
"version": "0.10.1",
"type": "module",
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",

View File

@ -1,6 +1,6 @@
{
"name": "@erp/auth",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,7 +1,7 @@
{
"name": "@erp/catalogs",
"description": "Catalogs module",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,7 +1,7 @@
{
"name": "@erp/companies",
"description": "Companies module",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@erp/core",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@erp/customer-invoices",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -193,6 +193,27 @@
"title": "Customer proformas",
"description": "List non-archived and non-deleted customer proformas",
"loadErrorTitle": "Unable to load the proformas list",
"actions": {
"view": "View",
"edit": "Edit",
"change_status": "Change status",
"issue": "Issue invoice",
"duplicate": "Duplicate",
"archive": "Archive",
"unarchive": "Unarchive",
"download_pdf": "Download PDF",
"delete": "Delete",
"view_linked_invoice": "View linked invoice",
"disabled_reasons": {
"archived_scope": "This action is not available from the archived proformas list.",
"deleted_scope": "This action is not available from the deleted proformas list.",
"archived_only": "This action is only available for archived proformas.",
"edit_issued": "Issued proformas cannot be edited.",
"status_issued": "Issued proformas do not allow status changes.",
"linked_invoice_unavailable": "This action is only available when the issued proforma has a linked invoice.",
"operation_in_progress": "The operation is already in progress."
}
},
"empty": {
"normal": "There are no proformas.",
"archived": "There are no archived proformas.",

View File

@ -194,6 +194,27 @@
"title": "Proformas",
"description": "Lista las proformas no archivadas ni eliminadas",
"loadErrorTitle": "No se pudo cargar el listado de proformas",
"actions": {
"view": "Ver",
"edit": "Editar",
"change_status": "Cambiar estado",
"issue": "Emitir factura",
"duplicate": "Duplicar",
"archive": "Archivar",
"unarchive": "Desarchivar",
"download_pdf": "Descargar PDF",
"delete": "Eliminar",
"view_linked_invoice": "Ver factura vinculada",
"disabled_reasons": {
"archived_scope": "No se puede ejecutar desde el listado de proformas archivadas.",
"deleted_scope": "No se puede ejecutar desde el listado de proformas eliminadas.",
"archived_only": "Solo está disponible en proformas archivadas.",
"edit_issued": "No se puede editar una proforma emitida.",
"status_issued": "Una proforma emitida no admite cambios de estado.",
"linked_invoice_unavailable": "Solo está disponible cuando la proforma emitida tiene una factura vinculada.",
"operation_in_progress": "La operación está en curso."
}
},
"empty": {
"normal": "No hay proformas.",
"archived": "No hay proformas archivadas.",

View File

@ -20,6 +20,7 @@ import {
import type { ColumnDef } from "@tanstack/react-table";
import {
CopyIcon,
EyeIcon,
ExternalLinkIcon,
FileDownIcon,
FileTextIcon,
@ -44,7 +45,6 @@ import { ProformaStatusBadge } from "../../components";
type GridActionHandlers = {
onViewClick?: (proforma: ProformaListRow) => void;
onPreviewClick?: (proforma: ProformaListRow) => void;
onEditClick?: (proforma: ProformaListRow) => void;
onIssueClick?: (proforma: ProformaListRow) => void;
onArchiveClick?: (proforma: ProformaListRow) => void;
@ -64,6 +64,29 @@ type GridActionHandlers = {
isPdfDownloading?: boolean;
};
type RowAction = {
key: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
disabled: boolean;
disabledReason?: string;
destructive?: boolean;
loading?: boolean;
href?: string;
onClick?: () => void;
};
const stopRowPropagation = (event: React.MouseEvent | React.KeyboardEvent) => {
event.stopPropagation();
};
const stopRowPropagationAndPreventDefault = (
event: React.MouseEvent | React.KeyboardEvent
) => {
event.preventDefault();
event.stopPropagation();
};
export function useProformasGridColumns(
scope: ProformaListScope,
actionHandlers: GridActionHandlers = {}
@ -297,16 +320,8 @@ export function useProformasGridColumns(
? (PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? [])
: [];
const canEdit = isNormalScope && !isIssued && actionHandlers.onEditClick;
const canView = !isDeletedScope && actionHandlers.onViewClick;
const canDuplicate = !isDeletedScope && actionHandlers.onDuplicateClick;
const canIssue = isNormalScope && !isIssued && isApproved && actionHandlers.onIssueClick;
const canDelete = !isDeletedScope && isDraft && actionHandlers.onDeleteClick;
const canDownloadPdf = !isDeletedScope && actionHandlers.onDownloadPdf;
const isPDFLoading =
actionHandlers.isPdfDownloading && actionHandlers.pdfDownloadingId === proforma.id;
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
const nextTransition = availableTransitions[0] ?? null;
const handleView = () => actionHandlers.onViewClick?.(proforma);
@ -325,263 +340,259 @@ export function useProformasGridColumns(
actionHandlers.onChangeStatusClick?.(proforma, nextTransition);
};
const primaryAction = (() => {
if (isIssued && proforma.linkedInvoiceId) {
return {
href: `/issed-invoices/${proforma.linkedInvoiceId}`,
icon: ExternalLinkIcon,
label: "Ver factura",
};
const getScopeReason = () => {
if (isArchivedScope) {
return t("pages.proformas.list.actions.disabled_reasons.archived_scope");
}
if (canIssue) {
return {
icon: FileTextIcon,
label: "Emitir factura",
onClick: handleIssue,
};
if (isDeletedScope) {
return t("pages.proformas.list.actions.disabled_reasons.deleted_scope");
}
if (isUnarchivable) {
return {
icon: Undo2Icon,
label: "Desarchivar",
onClick: handleUnarchive,
};
}
return undefined;
};
if (!isDraft && nextTransition && actionHandlers.onChangeStatusClick) {
return {
icon: RefreshCwIcon,
label: "Cambiar estado",
onClick: handleChangeStatus,
};
}
const visibleActions: RowAction[] = [
{
key: "view",
label: t("pages.proformas.list.actions.view"),
icon: EyeIcon,
disabled: !actionHandlers.onViewClick || isDeletedScope,
disabledReason: isDeletedScope
? t("pages.proformas.list.actions.disabled_reasons.deleted_scope")
: undefined,
onClick: handleView,
},
{
key: "edit",
label: t("pages.proformas.list.actions.edit"),
icon: PencilIcon,
disabled: !actionHandlers.onEditClick || !isNormalScope || isIssued,
disabledReason: !isNormalScope
? getScopeReason()
: isIssued
? t("pages.proformas.list.actions.disabled_reasons.edit_issued")
: undefined,
onClick: handleEdit,
},
];
if (canEdit) {
return {
icon: PencilIcon,
label: "Editar",
onClick: handleEdit,
};
}
const menuActions: RowAction[] = [
{
key: "linked-invoice",
label: t("pages.proformas.list.actions.view_linked_invoice"),
icon: ExternalLinkIcon,
disabled: !isIssued || !proforma.linkedInvoiceId,
disabledReason:
!isIssued || !proforma.linkedInvoiceId
? t("pages.proformas.list.actions.disabled_reasons.linked_invoice_unavailable")
: undefined,
href: proforma.linkedInvoiceId
? `/issed-invoices/${proforma.linkedInvoiceId}`
: undefined,
},
{
key: "change-status",
label: t("pages.proformas.list.actions.change_status"),
icon: RefreshCwIcon,
disabled:
!actionHandlers.onChangeStatusClick || !isNormalScope || !nextTransition || isIssued,
disabledReason: !isNormalScope
? getScopeReason()
: isIssued
? t("pages.proformas.list.actions.disabled_reasons.status_issued")
: !nextTransition
? t("proformas.change_proforma_status_dialog.empty_available_statuses")
: undefined,
onClick: handleChangeStatus,
},
{
key: "issue",
label: t("pages.proformas.list.actions.issue"),
icon: FileTextIcon,
disabled: !actionHandlers.onIssueClick || !isNormalScope || !isApproved || isIssued,
disabledReason: !isNormalScope
? getScopeReason()
: !isApproved
? t("proformas.issue_proforma_dialog.not_allowed_description")
: undefined,
onClick: handleIssue,
},
{
key: "duplicate",
label: t("pages.proformas.list.actions.duplicate"),
icon: CopyIcon,
disabled: !actionHandlers.onDuplicateClick || isDeletedScope,
disabledReason: isDeletedScope
? t("pages.proformas.list.actions.disabled_reasons.deleted_scope")
: undefined,
onClick: handleDuplicate,
},
{
key: "archive",
label: t("pages.proformas.list.actions.archive"),
icon: FolderArchiveIcon,
disabled: !actionHandlers.onArchiveClick || !isArchivable,
disabledReason: !actionHandlers.onArchiveClick
? undefined
: !isNormalScope
? getScopeReason()
: t("proformas.archive.error_conflict_message"),
onClick: handleArchive,
},
{
key: "unarchive",
label: t("pages.proformas.list.actions.unarchive"),
icon: Undo2Icon,
disabled: !actionHandlers.onUnarchiveClick || !isUnarchivable,
disabledReason: !actionHandlers.onUnarchiveClick
? undefined
: !isArchivedScope
? t("pages.proformas.list.actions.disabled_reasons.archived_only")
: t("proformas.unarchive.error_conflict_message"),
onClick: handleUnarchive,
},
{
key: "download-pdf",
label: t("pages.proformas.list.actions.download_pdf"),
icon: FileDownIcon,
disabled: !actionHandlers.onDownloadPdf || isDeletedScope || isPDFLoading,
disabledReason: isDeletedScope
? t("pages.proformas.list.actions.disabled_reasons.deleted_scope")
: isPDFLoading
? t("pages.proformas.list.actions.disabled_reasons.operation_in_progress")
: undefined,
loading: isPDFLoading,
onClick: handleDownloadPdf,
},
{
key: "delete",
label: t("pages.proformas.list.actions.delete"),
icon: Trash2Icon,
destructive: true,
disabled: !actionHandlers.onDeleteClick || isDeletedScope || !isDraft,
disabledReason: isDeletedScope
? t("pages.proformas.list.actions.disabled_reasons.deleted_scope")
: !isDraft
? t("proformas.delete_proforma_dialog.error_conflict_message")
: undefined,
onClick: handleDelete,
},
];
if (nextTransition && actionHandlers.onChangeStatusClick) {
return {
icon: RefreshCwIcon,
label: "Cambiar estado",
onClick: handleChangeStatus,
};
}
const renderIconButton = (action: RowAction) => {
const button = (
<Button
aria-label={action.label}
className="size-8 text-muted-foreground hover:text-foreground"
disabled={action.disabled}
onClick={(event) => {
stopRowPropagationAndPreventDefault(event);
if (!action.disabled) {
action.onClick?.();
}
}}
size="icon-sm"
variant="ghost"
>
<action.icon className="size-4" />
</Button>
);
if (isArchivable && actionHandlers.onArchiveClick) {
return {
icon: FolderArchiveIcon,
label: "Archivar",
onClick: handleArchive,
};
}
return (
<Tooltip key={action.key}>
<TooltipTrigger
render={action.disabled ? <span className="inline-flex">{button}</span> : button}
/>
<TooltipContent>{action.disabledReason ?? action.label}</TooltipContent>
</Tooltip>
);
};
return null;
})();
const renderMenuItem = (action: RowAction) => {
const item = (
<DropdownMenuItem
aria-label={action.label}
className={
action.destructive ? "text-destructive focus:text-destructive" : undefined
}
disabled={action.disabled}
onClick={(event) => {
stopRowPropagationAndPreventDefault(event);
if (action.disabled) {
return;
}
const quickAction = (() => {
if (primaryAction?.label !== "Editar" && canEdit) {
return {
icon: PencilIcon,
label: "Editar",
loading: false,
onClick: handleEdit,
};
}
if (action.href) {
window.location.href = action.href;
return;
}
return null;
})();
action.onClick?.();
}}
variant={action.destructive ? "destructive" : "default"}
>
{action.loading ? (
<Spinner className="mr-2 size-4" />
) : (
<action.icon className="mr-2 size-4" />
)}
<span>{action.label}</span>
</DropdownMenuItem>
);
const menuActions = [
canView
? {
icon: ExternalLinkIcon,
label: "Ver",
onClick: handleView,
}
: null,
!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 (
<Tooltip key={action.key}>
<TooltipTrigger render={action.disabled ? <div className="w-full">{item}</div> : item} />
<TooltipContent side="left">{action.disabledReason ?? action.label}</TooltipContent>
</Tooltip>
);
};
return (
<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>
<TooltipProvider>
<div className="flex items-center justify-end gap-1">
{visibleActions.map(renderIconButton)}
<DropdownMenu>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-8 text-muted-foreground hover:text-primary"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
quickAction.onClick();
}}
size="icon"
variant="ghost"
>
{quickAction.loading ? (
<Spinner className="size-4 cursor-progress" />
) : (
<quickAction.icon className="size-4" />
)}
<span className="sr-only">{quickAction.label}</span>
</Button>
<DropdownMenuTrigger
render={
<Button
aria-label={t("common.more_actions")}
className="size-8 text-muted-foreground hover:text-foreground"
onClick={stopRowPropagation}
size="icon-sm"
variant="ghost"
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
}
/>
<TooltipContent>{quickAction.label}</TooltipContent>
<TooltipContent>{t("common.more_actions")}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
{menuActions.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
className="size-8 text-muted-foreground hover:text-primary"
onClick={stop}
size="icon"
variant="ghost"
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">Más acciones</span>
</Button>
}
/>
<DropdownMenuContent align="end" className="w-52" onClick={stop}>
<DropdownMenuContent
align="end"
className="w-56"
onClick={stopRowPropagation}
>
<DropdownMenuGroup>
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
<DropdownMenuLabel>{t("common.actions")}</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" />
)}
<span>{action.label}</span>
</DropdownMenuItem>
</React.Fragment>
) : null
)}
{menuActions.map((action, index) => (
<React.Fragment key={action.key}>
{action.destructive && index > 0 ? <DropdownMenuSeparator /> : null}
{renderMenuItem(action)}
</React.Fragment>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
</TooltipProvider>
);
},
},

View File

@ -1,7 +1,7 @@
{
"name": "@erp/customers",
"description": "Customers",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,7 +1,7 @@
{
"name": "@erp/document-series",
"description": "Document series module",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@erp/factuges",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,7 +1,7 @@
{
"name": "@erp/identity",
"description": "Identity module",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,7 +1,7 @@
{
"name": "@erp/supplier-invoices",
"description": "Supplier invoices",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,7 +1,7 @@
{
"name": "@erp/suppliers",
"description": "Suppliers",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,7 +1,7 @@
{
"name": "uecko-erp-2025",
"private": true,
"version": "0.10.0",
"version": "0.10.1",
"workspaces": [
"apps/*",
"modules/*",

View File

@ -1,6 +1,6 @@
{
"name": "@repo/i18next",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@repo/rdx-criteria",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@repo/rdx-ddd",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@repo/rdx-logger",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@repo/rdx-ui",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@repo/rdx-utils",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@repo/shadcn-ui",
"version": "0.10.0",
"version": "0.10.1",
"type": "module",
"private": true,
"exports": {

View File

@ -1,6 +1,6 @@
{
"name": "@repo/typescript-config",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"publishConfig": {
"access": "public"