feat: agregar soporte para descripciones en métodos de pago y mejorar la interfaz de usuario

This commit is contained in:
David Arranz 2026-09-04 20:35:28 +02:00
parent 685472b3ef
commit 021c19fc40
17 changed files with 317 additions and 239 deletions

View File

@ -24,13 +24,13 @@ export const SettingsLayout = ({ sections }: SettingsLayoutProps) => {
return (
<RdxPageFrame>
<RdxPageContainer width="full">
<RdxPageStack spacing="default">
<RdxPageContainer className="space-y-6" width="full">
<RdxPageHeader
description={t("web.settings.description")}
title={t("web.settings.title")}
/>
<RdxPageStack spacing="default">
<div className="grid gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
<aside className="min-w-0 lg:sticky lg:top-6 lg:self-start">
<SettingsNavigation sections={sections} />

View File

@ -4,6 +4,7 @@ export type PaymentMethodSummary = {
id: UniqueID;
companyId: UniqueID;
name: Name;
description: string | null;
isActive: boolean;
isSystem: boolean;
isDefault: boolean;

View File

@ -13,6 +13,7 @@ export class PaymentMethodSummarySnapshotBuilder implements IPaymentMethodSummar
company_id: paymentMethod.companyId.toString(),
name: paymentMethod.name.toString(),
description: paymentMethod.description,
is_system: paymentMethod.isSystem,
is_active: paymentMethod.isActive,

View File

@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Sequelize } from "sequelize";
import { PaymentMethodSummarySchema } from "../../../../../../common/dto/payment-methods/shared/payment-method-summary.dto";
import { PaymentMethodSummarySnapshotBuilder } from "../../../../../application/payment-methods/snapshot-builders/summary/payment-method-summary-snapshot-builder";
import initializePaymentMethodModel, {
PaymentMethodModel,
} from "../models/sequelize-payment-method.model";
import { SequelizePaymentMethodSummaryMapper } from "./sequelize-payment-method-summary.mapper";
const database = new Sequelize({ dialect: "mysql", logging: false });
initializePaymentMethodModel(database);
for (const description of [
"Cargo automático en cuenta bancaria.",
"Descripción extensa ".repeat(10),
null,
]) {
test(`conserva la descripción ${description === null ? "ausente" : "textual"} desde persistencia hasta el DTO (${description?.length ?? 0})`, () => {
const source = {
id: "11111111-1111-4111-8111-111111111111",
company_id: "22222222-2222-4222-8222-222222222222",
name: "Método de prueba",
description,
is_active: true,
is_system: false,
is_default: true,
};
const result = new SequelizePaymentMethodSummaryMapper().mapToReadModel(
PaymentMethodModel.build(source)
);
assert.equal(result.isFailure, false);
const dto = new PaymentMethodSummarySnapshotBuilder().toOutput(result.data);
assert.deepEqual(PaymentMethodSummarySchema.parse(dto), source);
});
}

View File

@ -40,6 +40,7 @@ export class SequelizePaymentMethodSummaryMapper extends SequelizeQueryMapper<
id: id!,
companyId: companyId!,
name: name!,
description: raw.description ?? null,
isActive: isActive,
isSystem: isSystem,
isDefault: isDefault,

View File

@ -5,6 +5,7 @@ export const PaymentMethodSummarySchema = z.object({
company_id: z.uuid(),
name: z.string(),
description: z.string().nullable(),
is_active: z.boolean(),
is_system: z.boolean(),
is_default: z.boolean(),

View File

@ -3,6 +3,10 @@
"paymentMethods": {
"title": "Payment methods",
"description": "Manage the payment methods available for the company.",
"add": {
"title": "Add a new payment method",
"description": "Set up a new payment method for your company."
},
"search": {
"placeholder": "Search method..."
},
@ -46,6 +50,7 @@
"active": "Active",
"inactive": "Inactive",
"system": "System",
"manual": "Manual",
"default": "Default"
},
"create": {

View File

@ -3,6 +3,10 @@
"paymentMethods": {
"title": "Métodos de pago",
"description": "Gestiona las formas de pago disponibles para la empresa.",
"add": {
"title": "Añadir un nuevo método de pago",
"description": "Configura una nueva forma de pago para tu empresa."
},
"search": {
"placeholder": "Buscar método..."
},
@ -46,6 +50,7 @@
"active": "Activo",
"inactive": "Inactivo",
"system": "Sistema",
"manual": "Manual",
"default": "Predeterminado"
},
"create": {

View File

@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ListPaymentMethodsResponseDTO } from "../../../../common";
import { ListPaymentMethodsAdapter } from "./list-payment-methods.adapter";
test("conserva descripciones largas y null sin alterar estados ni paginación", () => {
const descriptions = [
"Pago por transferencia bancaria.",
"Descripción extensa ".repeat(10),
null,
];
const dto: ListPaymentMethodsResponseDTO = {
page: 0,
per_page: 10,
total_pages: 1,
total_items: 3,
metadata: {
entity: "payment_methods",
criteria: {
q: "",
filters: [],
orderBy: "name",
orderType: "ASC",
pageSize: 10,
pageNumber: 0,
},
},
items: descriptions.map((description, index) => ({
id: `11111111-1111-4111-8111-11111111111${index}`,
company_id: "22222222-2222-4222-8222-222222222222",
name: "Método de prueba",
description,
is_active: index !== 2,
is_system: index === 0,
is_default: index === 0,
})),
};
const result = ListPaymentMethodsAdapter.fromDto(dto);
assert.deepEqual(
result.items,
dto.items.map((item) => ({
id: item.id,
companyId: item.company_id,
name: item.name,
description: item.description,
isActive: item.is_active,
isSystem: item.is_system,
isDefault: item.is_default,
}))
);
assert.equal(result.page, dto.page);
assert.equal(result.perPage, dto.per_page);
assert.equal(result.totalPages, dto.total_pages);
assert.equal(result.totalItems, dto.total_items);
});

View File

@ -45,6 +45,7 @@ const PaymentMehodListRowAdapter = {
companyId: dto.company_id,
name: dto.name,
description: dto.description,
isSystem: dto.is_system,
isActive: dto.is_active,

View File

@ -10,6 +10,7 @@ export interface PaymentMethodListRow {
companyId: string;
name: string;
description: string | null;
isSystem: boolean;
isActive: boolean;

View File

@ -1,4 +1,5 @@
import { Button } from "@repo/shadcn-ui/components";
import { CirclePlusIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
@ -6,17 +7,18 @@ export const PaymentMethodsEmptyState = ({ onCreate }: { onCreate: () => void })
const { t } = useTranslation();
return (
<div className="rounded-xl border border-dashed border-border bg-muted/20 p-8 text-center">
<div className="space-y-2">
<h3 className="text-lg font-semibold">{t("settings.paymentMethods.empty.title")}</h3>
<p className="text-sm text-muted-foreground">
{t("settings.paymentMethods.empty.description")}
</p>
</div>
<Button className="mt-6" onClick={onCreate}>
{t("settings.paymentMethods.actions.create")}
<Button
className="h-auto min-h-28 w-full flex-col gap-2 whitespace-normal rounded-xl border-dashed px-4 py-6 text-center shadow-none transition-colors hover:bg-muted/50"
onClick={onCreate}
variant="outline"
>
<span className="flex items-center justify-center gap-2">
<CirclePlusIcon aria-hidden className="size-5 text-muted-foreground" />
{t("settings.paymentMethods.add.title")}
</span>
<span className="text-sm font-normal text-muted-foreground">
{t("settings.paymentMethods.add.description")}
</span>
</Button>
</div>
);
};

View File

@ -1,7 +1,15 @@
import { PageErrorState } from "@erp/core/components";
import { RdxBadge, RdxPageContainer } from "@repo/rdx-ui/components";
import { Button, Card, CardContent, RadioGroup, RadioGroupItem } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import {
Button,
Card,
CardContent,
CardHeader,
RadioGroup,
RadioGroupItem,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { CircleIcon, CreditCardIcon, PlusIcon, StarIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
import { usePaymentMethodsSettingsPageController } from "../../controllers";
@ -71,30 +79,34 @@ export const PaymentMethodsSettingsPage = () => {
};
return (
<RdxPageContainer width="full">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<>
<Card>
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<CreditCardIcon aria-hidden className="size-6" />
</div>
<div className="min-w-0 space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">
{t("settings.paymentMethods.title")}
</h2>
<p className="max-w-3xl text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground">
{t("settings.paymentMethods.description")}
</p>
</div>
<Button onClick={featureCtrl.openCreateDialog}>
<PlusIcon aria-hidden className="mr-2 size-4" />
</div>
{ctrl.list.length > 0 && (
<Button className="shrink-0 self-start" onClick={featureCtrl.openCreateDialog}>
<PlusIcon aria-hidden className="size-4" />
{t("settings.paymentMethods.actions.create")}
</Button>
</div>
{ctrl.list.length === 0 ? (
<PaymentMethodsEmptyState onCreate={featureCtrl.openCreateDialog} />
) : (
)}
</CardHeader>
<CardContent className="@container space-y-3">
{ctrl.list.length > 0 && (
<RadioGroup
aria-label={t("settings.paymentMethods.default.label")}
className="space-y-3"
className="min-w-0 grid-cols-1 gap-3"
disabled={featureCtrl.isChangingDefault}
onValueChange={(paymentMethodId) => {
const paymentMethod = ctrl.list.find((item) => item.id === paymentMethodId);
@ -108,9 +120,14 @@ export const PaymentMethodsSettingsPage = () => {
value={ctrl.list.find((item) => item.isDefault)?.id}
>
{ctrl.list.map((paymentMethod) => (
<Card key={paymentMethod.id}>
<CardContent className="flex flex-col gap-4 p-4 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 items-start gap-4">
<div
className={cn(
"flex min-w-0 flex-wrap items-center gap-4 rounded-xl border border-border p-4 @min-[52rem]:flex-nowrap",
paymentMethod.isDefault ? "border-primary/20 bg-primary/5" : "bg-card"
)}
key={paymentMethod.id}
>
<div className="flex min-w-0 basis-full items-center gap-3 @min-[52rem]:basis-0 @min-[52rem]:flex-1">
<RadioGroupItem
aria-label={t("settings.paymentMethods.default.rowLabel", {
name: paymentMethod.name,
@ -118,39 +135,52 @@ export const PaymentMethodsSettingsPage = () => {
id={`payment-method-default-${paymentMethod.id}`}
value={paymentMethod.id}
/>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<CreditCardIcon aria-hidden className="size-5" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<label
className="cursor-pointer text-sm font-medium"
className="cursor-pointer break-words text-sm font-semibold text-foreground"
htmlFor={`payment-method-default-${paymentMethod.id}`}
>
{paymentMethod.name}
</label>
{paymentMethod.isSystem ? (
<RdxBadge variant="secondary">
{t("settings.paymentMethods.status.system")}
</RdxBadge>
) : null}
{paymentMethod.isDefault ? (
<RdxBadge variant="outline">
{paymentMethod.description && (
<p
className="truncate text-sm text-muted-foreground"
title={paymentMethod.description}
>
{paymentMethod.description}
</p>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{paymentMethod.isDefault && (
<RdxBadge className="gap-1.5 rounded-full bg-primary/10 px-3 text-primary">
<StarIcon aria-hidden className="fill-current" />
{t("settings.paymentMethods.status.default")}
</RdxBadge>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>
)}
<RdxBadge
className={cn(
"gap-1.5 rounded-full px-3",
paymentMethod.isActive && "bg-primary/10 text-primary"
)}
variant={paymentMethod.isActive ? "secondary" : "destructive"}
>
<CircleIcon aria-hidden className="fill-current" />
{paymentMethod.isActive
? t("settings.paymentMethods.status.active")
: t("settings.paymentMethods.status.inactive")}
</span>
</RdxBadge>
<RdxBadge className="rounded-full px-3" variant="secondary">
{paymentMethod.isSystem
? t("settings.paymentMethods.status.system")
: t("settings.paymentMethods.status.manual")}
</RdxBadge>
</div>
</div>
</div>
<div className="ml-auto shrink-0">
<PaymentMethodRowActions
activeDisabledReason={activeDisabledReason(
paymentMethod.isDefault,
@ -173,11 +203,14 @@ export const PaymentMethodsSettingsPage = () => {
}}
paymentMethod={paymentMethod}
/>
</CardContent>
</Card>
</div>
</div>
))}
</RadioGroup>
)}
<PaymentMethodsEmptyState onCreate={featureCtrl.openCreateDialog} />
</CardContent>
</Card>
<PaymentMethodFormDialog
description={t("settings.paymentMethods.create.description")}
@ -227,7 +260,6 @@ export const PaymentMethodsSettingsPage = () => {
open={Boolean(featureCtrl.deletingPaymentMethod)}
target={featureCtrl.deletingPaymentMethod}
/>
</div>
</RdxPageContainer>
</>
);
};

View File

@ -2,5 +2,4 @@ export * from "./empty-cell-value";
export * from "./error-alert";
export * from "./error-state";
export * from "./form";
export * from "./page-header";
export * from "./page-error-state";

View File

@ -1,71 +0,0 @@
import { Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ChevronLeftIcon } from "lucide-react";
import type { ReactNode } from "react";
interface PageHeaderProps {
title: ReactNode;
description?: ReactNode;
rightSlot?: ReactNode;
showBackButton?: boolean;
onBackClick?: () => void;
backButtonLabel?: string;
statusSlot?: ReactNode;
className?: string;
}
export const PageHeader = ({
title,
description,
rightSlot,
showBackButton = false,
onBackClick,
backButtonLabel = "Volver",
statusSlot,
className,
}: PageHeaderProps) => {
return (
<header
className={cn(
"mb-6 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between",
className
)}
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start gap-4">
{showBackButton && (
<Button
aria-label={backButtonLabel}
className="shrink-0 cursor-pointer"
onClick={onBackClick}
size="default"
type="button"
variant="outline"
>
<ChevronLeftIcon aria-hidden="true" className="size-5" />
</Button>
)}
<div className="min-w-0 space-y-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h1 className="min-w-0 truncate text-2xl font-semibold tracking-tight text-foreground xl:text-3xl font-heading">
{title}
</h1>
{statusSlot}
</div>
{description && (
<p className="text-sm font-medium text-muted-foreground">{description}</p>
)}
</div>
</div>
</div>
{rightSlot && <div className="flex shrink-0 items-center lg:pt-0">{rightSlot}</div>}
</header>
);
};

View File

@ -1,6 +1,11 @@
import { ErrorAlert, PageErrorState, PageHeader, SimpleSearchInput } from "@erp/core/components";
import { ErrorAlert, PageErrorState, SimpleSearchInput } from "@erp/core/components";
import { useReturnToNavigation } from "@erp/core/hooks";
import { LogoVerifactu, RdxPageContainer, RdxPageFrame } from "@repo/rdx-ui/components";
import {
LogoVerifactu,
RdxPageContainer,
RdxPageFrame,
RdxPageHeader,
} from "@repo/rdx-ui/components";
import { Alert, AlertDescription, AlertTitle, Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
@ -96,9 +101,8 @@ export const ListIssuedInvoicesPage = () => {
return (
<RdxPageFrame>
<RdxPageContainer className="space-y-6" width="full">
<PageHeader
description={t("pages.issued_invoices.list.description")}
rightSlot={
<RdxPageHeader
actions={
<Button
aria-label={t("pages.issued_invoices.create.title")}
className="hidden"
@ -108,6 +112,7 @@ export const ListIssuedInvoicesPage = () => {
{t("pages.issued_invoices.create.title")}
</Button>
}
description={t("pages.issued_invoices.list.description")}
title={t("pages.issued_invoices.list.title")}
/>

View File

@ -39,14 +39,13 @@ export const RdxPageHeader = ({
{leading ? <div className="shrink-0">{leading}</div> : null}
<div className="min-w-0 space-y-2">
<h1 className="min-w-0 text-2xl font-semibold tracking-tight text-foreground font-heading xl:text-3xl">
<h1 className="min-w-0 text-2xl font-bold tracking-tight text-foreground font-heading xl:text-3xl">
{title}
</h1>
{description ? (
<p className="text-sm font-medium text-muted-foreground">{description}</p>
) : null}
{metadata ? (
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm text-muted-foreground">
{metadata}