feat: refactor badge component and update related usages

- Replaced instances of Badge with RdxBadge in SupplierStatusBadge, DataTableFacetedFilter, MultiSelectField, and other components.
- Updated RdxEditorPageLayout to use width prop instead of complexity.
- Added new recurring proforma operational summary feature with tests.
- Introduced recurring proforma status entity and related UI helpers.
- Enhanced recurring proforma list and detail views with new components and hooks.
- Added tests for new functionality and components.
This commit is contained in:
David Arranz 2026-09-03 18:50:57 +02:00
parent e670ff4b33
commit d92ab05f35
86 changed files with 1231 additions and 518 deletions

View File

@ -1,11 +1,11 @@
import { useTheme } from "@/lib/hooks";
import {
RdxBadge,
RdxContextPanel,
RdxEditorPageLayout,
RdxEditorSection,
RdxEditorStickyFooter,
RdxFormGrid,
RdxPageActions,
RdxContextPanel,
RdxPageContainer,
RdxPageHeader,
RdxPageStack,
@ -13,7 +13,6 @@ import {
RightPanel,
} from "@repo/rdx-ui/components";
import {
Badge,
Button,
Card,
CardContent,
@ -34,6 +33,8 @@ import {
import { ArrowLeftIcon, MoreHorizontalIcon, PlusIcon, XIcon } from "lucide-react";
import type { ReactNode } from "react";
import { useTheme } from "@/lib/hooks";
import { UiKitSection } from "./sections/ui-kit-section";
type ThemeValue = "dark" | "light" | "system";
@ -60,11 +61,7 @@ const ExampleCard = ({ title, notes, children }: ExampleCardProps) => {
};
const SurfaceExample = ({ children }: { children: ReactNode }) => {
return (
<div className="rounded-lg border border-border bg-background p-4">
{children}
</div>
);
return <div className="rounded-lg border border-border bg-background p-4">{children}</div>;
};
const OverflowMenuExample = () => {
@ -94,27 +91,34 @@ export default function UiKitPage() {
<RdxPageHeader
actions={
<RdxPageActions
overflow={<OverflowMenuExample />}
primary={
<Button type="button">
<PlusIcon className="size-4" />
Primary action
</Button>
}
secondary={<Button type="button" variant="outline">Secondary</Button>}
overflow={<OverflowMenuExample />}
secondary={
<Button type="button" variant="outline">
Secondary
</Button>
}
/>
}
description="Infraestructura mínima del sistema visual Rdx para validar foundations estructurales antes de migrar pantallas funcionales."
metadata={
<>
<Badge variant="outline">Development only</Badge>
<Badge variant="outline">Static data</Badge>
<RdxBadge variant="outline">Development only</RdxBadge>
<RdxBadge variant="outline">Static data</RdxBadge>
</>
}
title="Rdx UI Kit"
/>
<nav aria-label="UI Kit sections" className="rounded-lg border border-border bg-background p-4">
<nav
aria-label="UI Kit sections"
className="rounded-lg border border-border bg-background p-4"
>
<div className="flex flex-wrap gap-2">
<a
className="inline-flex h-8 items-center rounded-md px-2.5 text-sm font-medium hover:bg-muted"
@ -242,19 +246,28 @@ export default function UiKitPage() {
>
<div className="grid gap-4 xl:grid-cols-3">
<ExampleCard notes="`width=&quot;full&quot;`" title="Full">
<RdxPageContainer className="rounded-lg border border-dashed border-border bg-muted/40" width="full">
<RdxPageContainer
className="rounded-lg border border-dashed border-border bg-muted/40"
width="full"
>
<div className="text-sm text-foreground">Available width</div>
</RdxPageContainer>
</ExampleCard>
<ExampleCard notes="`width=&quot;wide&quot;`" title="Wide">
<RdxPageContainer className="rounded-lg border border-dashed border-border bg-muted/40" width="wide">
<RdxPageContainer
className="rounded-lg border border-dashed border-border bg-muted/40"
width="wide"
>
<div className="text-sm text-foreground">Max width screen 2xl</div>
</RdxPageContainer>
</ExampleCard>
<ExampleCard notes="`width=&quot;content&quot;`" title="Content">
<RdxPageContainer className="rounded-lg border border-dashed border-border bg-muted/40" width="content">
<RdxPageContainer
className="rounded-lg border border-dashed border-border bg-muted/40"
width="content"
>
<div className="text-sm text-foreground">Centered content editor</div>
</RdxPageContainer>
</ExampleCard>
@ -270,7 +283,10 @@ export default function UiKitPage() {
>
<div className="grid gap-4 xl:grid-cols-3">
<ExampleCard notes="`spacing=&quot;compact&quot;`" title="Compact">
<RdxPageStack className="rounded-lg border border-border bg-background p-4" spacing="compact">
<RdxPageStack
className="rounded-lg border border-border bg-background p-4"
spacing="compact"
>
<SurfaceExample>Block 1</SurfaceExample>
<SurfaceExample>Block 2</SurfaceExample>
<SurfaceExample>Block 3</SurfaceExample>
@ -278,7 +294,10 @@ export default function UiKitPage() {
</ExampleCard>
<ExampleCard notes="`spacing=&quot;default&quot;`" title="Default">
<RdxPageStack className="rounded-lg border border-border bg-background p-4" spacing="default">
<RdxPageStack
className="rounded-lg border border-border bg-background p-4"
spacing="default"
>
<SurfaceExample>Block 1</SurfaceExample>
<SurfaceExample>Block 2</SurfaceExample>
<SurfaceExample>Block 3</SurfaceExample>
@ -286,7 +305,10 @@ export default function UiKitPage() {
</ExampleCard>
<ExampleCard notes="`spacing=&quot;relaxed&quot;`" title="Relaxed">
<RdxPageStack className="rounded-lg border border-border bg-background p-4" spacing="relaxed">
<RdxPageStack
className="rounded-lg border border-border bg-background p-4"
spacing="relaxed"
>
<SurfaceExample>Block 1</SurfaceExample>
<SurfaceExample>Block 2</SurfaceExample>
<SurfaceExample>Block 3</SurfaceExample>
@ -323,9 +345,13 @@ export default function UiKitPage() {
<RdxPageHeader
actions={
<RdxPageActions
primary={<Button type="button">Save</Button>}
secondary={<Button type="button" variant="outline">Cancel</Button>}
overflow={<OverflowMenuExample />}
primary={<Button type="button">Save</Button>}
secondary={
<Button type="button" variant="outline">
Cancel
</Button>
}
/>
}
description="Header with leading control, metadata and grouped actions."
@ -336,7 +362,7 @@ export default function UiKitPage() {
}
metadata={
<>
<Badge variant="outline">Draft</Badge>
<RdxBadge variant="outline">Draft</RdxBadge>
<span>Last updated 5 minutes ago</span>
</>
}
@ -376,7 +402,11 @@ export default function UiKitPage() {
<SurfaceExample>
<RdxPageActions
primary={<Button type="button">Save</Button>}
secondary={<Button type="button" variant="outline">Cancel</Button>}
secondary={
<Button type="button" variant="outline">
Cancel
</Button>
}
/>
</SurfaceExample>
</ExampleCard>
@ -386,7 +416,11 @@ export default function UiKitPage() {
<RdxPageActions
overflow={<OverflowMenuExample />}
primary={<Button type="button">Approve</Button>}
secondary={<Button type="button" variant="outline">Back to list</Button>}
secondary={
<Button type="button" variant="outline">
Back to list
</Button>
}
/>
</SurfaceExample>
</ExampleCard>
@ -398,8 +432,12 @@ export default function UiKitPage() {
primary={<Button type="button">Primary action with long label</Button>}
secondary={
<>
<Button type="button" variant="outline">Secondary one</Button>
<Button type="button" variant="outline">Secondary two</Button>
<Button type="button" variant="outline">
Secondary one
</Button>
<Button type="button" variant="outline">
Secondary two
</Button>
</>
}
/>
@ -495,12 +533,19 @@ export default function UiKitPage() {
<div className="rounded-lg border border-border bg-background p-4 shadow-sm">
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
<div className="space-y-1">
<div className="text-sm font-semibold text-foreground">Ficha de cliente</div>
<div className="text-sm font-semibold text-foreground">
Ficha de cliente
</div>
<div className="text-xs text-muted-foreground">
Panel contextual opcional
</div>
</div>
<Button aria-label="Cerrar panel" size="icon-sm" type="button" variant="ghost">
<Button
aria-label="Cerrar panel"
size="icon-sm"
type="button"
variant="ghost"
>
<XIcon className="size-4" />
</Button>
</div>
@ -819,7 +864,7 @@ export default function UiKitPage() {
Sticky header remains outside `rdx-ui`.
</div>
</div>
<Badge variant="outline">Modified</Badge>
<RdxBadge variant="outline">Modified</RdxBadge>
</div>
</div>
@ -897,7 +942,11 @@ export default function UiKitPage() {
</div>
}
primary={<Button type="button">Guardar</Button>}
secondary={<Button type="button" variant="outline">Cancelar</Button>}
secondary={
<Button type="button" variant="outline">
Cancelar
</Button>
}
status="Modificada"
/>
</div>
@ -935,10 +984,7 @@ export default function UiKitPage() {
<div className="rounded-lg border border-border bg-muted/30">
<RdxPageContainer width="content">
<RdxPageStack>
<RdxPageHeader
description="Simple centered editor"
title="Editor page"
/>
<RdxPageHeader description="Simple centered editor" title="Editor page" />
<SurfaceExample>Form content</SurfaceExample>
</RdxPageStack>
</RdxPageContainer>
@ -947,10 +993,7 @@ export default function UiKitPage() {
<div className="rounded-lg border border-border bg-muted/30">
<RdxPageContainer width="wide">
<RdxPageStack>
<RdxPageHeader
description="Wide detail composition"
title="Detail page"
/>
<RdxPageHeader description="Wide detail composition" title="Detail page" />
<SurfaceExample>Detail content</SurfaceExample>
</RdxPageStack>
</RdxPageContainer>

View File

@ -1,3 +1,4 @@
import { RdxBadge } from "@repo/rdx-ui/components";
import {
AlertDialog,
AlertDialogAction,
@ -8,7 +9,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Badge,
Button,
ButtonGroup,
Card,
@ -218,9 +218,9 @@ export default function ShadcnShowcasePage() {
</FieldGroup>
<div className="flex items-center gap-2">
<div className="flex gap-2">
<Badge>Badge</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="outline">Outline</Badge>
<RdxBadge>Badge</RdxBadge>
<RdxBadge variant="secondary">Secondary</RdxBadge>
<RdxBadge variant="outline">Outline</RdxBadge>
</div>
<RadioGroup className="ml-auto flex w-fit gap-3" defaultValue="apple">
<RadioGroupItem value="apple" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -1,6 +1,6 @@
import { PageErrorState } from "@erp/core/components";
import { RdxPageContainer } from "@repo/rdx-ui/components";
import { Badge, Button, Card, CardContent, RadioGroup, RadioGroupItem } from "@repo/shadcn-ui/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 { useTranslation } from "../../../../i18n";
@ -129,15 +129,15 @@ export const PaymentMethodsSettingsPage = () => {
</label>
{paymentMethod.isSystem ? (
<Badge variant="secondary">
<RdxBadge variant="secondary">
{t("settings.paymentMethods.status.system")}
</Badge>
</RdxBadge>
) : null}
{paymentMethod.isDefault ? (
<Badge variant="outline">
<RdxBadge variant="outline">
{t("settings.paymentMethods.status.default")}
</Badge>
</RdxBadge>
) : null}
</div>
@ -204,7 +204,9 @@ export const PaymentMethodsSettingsPage = () => {
isEditMode
isSubmitting={featureCtrl.isSaving || featureCtrl.isEditingPaymentMethodLoading}
onSubmit={featureCtrl.onSubmit}
open={Boolean(featureCtrl.editingPaymentMethod) || featureCtrl.isEditingPaymentMethodLoading}
open={
Boolean(featureCtrl.editingPaymentMethod) || featureCtrl.isEditingPaymentMethodLoading
}
openChange={(open) => {
if (!open) {
featureCtrl.closeEditDialog();

View File

@ -1,16 +1,11 @@
import { useEffect, useMemo } from "react";
import type { FormEventHandler } from "react";
import { FormProvider, useFieldArray, useWatch } from "react-hook-form";
import type { UseFormReturn } from "react-hook-form";
import {
RdxBadge,
RdxFormGrid,
SwitchField,
TextAreaField,
TextField,
} from "@repo/rdx-ui/components";
import {
Badge,
Button,
Dialog,
DialogContent,
@ -21,6 +16,10 @@ import {
Spinner,
} from "@repo/shadcn-ui/components";
import { PlusIcon, Trash2Icon } from "lucide-react";
import type { FormEventHandler } from "react";
import { useEffect, useMemo } from "react";
import type { UseFormReturn } from "react-hook-form";
import { FormProvider, useFieldArray, useWatch } from "react-hook-form";
import { useTranslation } from "../../../../i18n";
import type { PaymentTermForm } from "../../entities";
@ -74,10 +73,12 @@ export const PaymentTermFormDialog = ({
});
const dueRules = useMemo(() => {
return fields.map((field, index) => {
return watchedDueRules?.[index] ?? {
dueDays: field.dueDays ?? "",
percentage: field.percentage ?? "",
};
return (
watchedDueRules?.[index] ?? {
dueDays: field.dueDays ?? "",
percentage: field.percentage ?? "",
}
);
});
}, [fields, watchedDueRules]);
@ -140,13 +141,15 @@ export const PaymentTermFormDialog = ({
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<h3 className="text-sm font-semibold">{t("settings.paymentTerms.form.rules.title")}</h3>
<h3 className="text-sm font-semibold">
{t("settings.paymentTerms.form.rules.title")}
</h3>
<p className="text-sm text-muted-foreground">
{t("settings.paymentTerms.form.rules.description")}
</p>
</div>
<Badge
<RdxBadge
className={`border px-3 py-1 text-xs ${totalToneClassNames[totalStatus.tone]}`}
variant="outline"
>
@ -156,7 +159,7 @@ export const PaymentTermFormDialog = ({
i18n.language
),
})}
</Badge>
</RdxBadge>
</div>
<div
@ -173,10 +176,7 @@ export const PaymentTermFormDialog = ({
<div className="space-y-2">
{fields.map((field, index) => (
<div
className="rounded-xl border border-border bg-muted/20 p-3"
key={field.id}
>
<div className="rounded-xl border border-border bg-muted/20 p-3" key={field.id}>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] md:items-start">
<div className="space-y-1">
<span className="text-xs font-medium text-muted-foreground md:hidden">

View File

@ -1,4 +1,4 @@
import { Badge, Card, CardContent } from "@repo/shadcn-ui/components";
import { Card, CardContent, RdxBadge } from "@repo/shadcn-ui/components";
import { CalendarClockIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
@ -61,14 +61,16 @@ export const PaymentTermListItem = ({
{paymentTerm.name}
</h3>
<Badge variant={paymentTerm.isActive ? "outline" : "secondary"}>
<RdxBadge variant={paymentTerm.isActive ? "outline" : "secondary"}>
{paymentTerm.isActive
? t("settings.paymentTerms.status.active")
: t("settings.paymentTerms.status.inactive")}
</Badge>
</RdxBadge>
{paymentTerm.isSystem ? (
<Badge variant="secondary">{t("settings.paymentTerms.status.system")}</Badge>
<RdxBadge variant="secondary">
{t("settings.paymentTerms.status.system")}
</RdxBadge>
) : null}
</div>

View File

@ -1,6 +1,6 @@
import { PageErrorState } from "@erp/core/components";
import { RdxPageContainer } from "@repo/rdx-ui/components";
import { Badge, Button, Card, CardContent } from "@repo/shadcn-ui/components";
import { Button, Card, CardContent, RdxBadge } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
@ -95,14 +95,16 @@ export const TaxRegimesSettingsPage = () => {
</span>
{taxRegime.isSystem ? (
<Badge variant="secondary">{t("settings.taxRegimes.status.system")}</Badge>
<RdxBadge variant="secondary">
{t("settings.taxRegimes.status.system")}
</RdxBadge>
) : null}
<Badge variant={taxRegime.isActive ? "outline" : "secondary"}>
<RdxBadge variant={taxRegime.isActive ? "outline" : "secondary"}>
{taxRegime.isActive
? t("settings.taxRegimes.status.active")
: t("settings.taxRegimes.status.inactive")}
</Badge>
</RdxBadge>
</div>
<div className="space-y-1">

View File

@ -1,5 +1,5 @@
// packages/rdx-ui/src/components/form-actions/erp-form-header.tsx
import { Badge, Button } from "@repo/shadcn-ui/components";
import { RdxBadge } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ArrowLeftIcon } from "lucide-react";
import type { ReactNode } from "react";
@ -62,14 +62,14 @@ export const PageFormHeader = ({
</h1>
{statusLabel ? (
<Badge
<RdxBadge
className={cn(
"border-amber-500 bg-amber-200 text-amber-900 transition-opacity duration-200",
showStatus ? "opacity-100" : "pointer-events-none opacity-0"
)}
>
{statusLabel}
</Badge>
</RdxBadge>
) : null}
</div>
</div>

View File

@ -50,6 +50,7 @@ export type RecurringProformaDetailReadModel = {
name: string;
status: RecurringProformaStatus;
customerId: UniqueID | null;
customer: CustomerPublicSummary | null;
description: string | null;
paymentTermId: UniqueID | null;
paymentMethodId: UniqueID | null;
@ -60,6 +61,7 @@ export type RecurringProformaDetailReadModel = {
globalDiscountPercentage: DiscountPercentage | null;
schedule: RecurringProformaScheduleReadModel | null;
nextOccurrenceDate: Date | null;
hasIssue: boolean;
items: readonly RecurringProformaItemReadModel[];
createdAt: Date;
updatedAt: Date;

View File

@ -15,6 +15,7 @@ import {
RecurringProformaOccurrence,
} from "../../../domain";
import { toRecurringProformaAuditTarget } from "../audit";
import {
RecurringProformaCommands,
RecurringProformaReader,
@ -146,6 +147,7 @@ test("RecurringProformaReader propaga company, filtros, paginación y conserva e
test("RecurringProformaReader conserva overrides nulos, schedule e items ordenados en detail", async () => {
let receivedCompany: UniqueID | undefined;
let receivedOccurrenceTransaction: unknown;
const recurring = buildRecurring();
const reader = new RecurringProformaReader(
{
@ -156,13 +158,25 @@ test("RecurringProformaReader conserva overrides nulos, schedule e items ordenad
return Result.ok(recurring);
},
} as never,
{} as never,
{
hasFailedOccurrences: async (
id: UniqueID,
companyId: UniqueID,
currentTransaction: unknown
) => {
assert.equal(id.equals(recurringId), true);
assert.equal(companyId.equals(companyA), true);
receivedOccurrenceTransaction = currentTransaction;
return Result.ok(true);
},
} as never,
{} as never,
transactionManager()
);
const result = await reader.detail({ companyId: companyA, id: recurringId });
assert.equal(result.isSuccess, true);
assert.equal(receivedCompany?.equals(companyA), true);
assert.equal(receivedOccurrenceTransaction, transaction);
assert.equal(result.data.customerId, null);
assert.equal(result.data.paymentTermId, null);
assert.equal(result.data.language, null);
@ -172,6 +186,7 @@ test("RecurringProformaReader conserva overrides nulos, schedule e items ordenad
assert.equal(result.data.items[0]?.ivaCode, "general");
assert.equal(result.data.items[0]?.recCode, "rec");
assert.equal(result.data.items[0]?.retentionCode, "ret");
assert.equal(result.data.hasIssue, true);
});
test("RecurringProformaReader propaga parent, empresa y paginación de occurrences", async () => {

View File

@ -81,7 +81,28 @@ export class RecurringProformaReader {
params.id,
transaction
);
return found.isFailure ? Result.fail(found.error) : Result.ok(this.toDetail(found.data));
if (found.isFailure) return Result.fail(found.error);
const hasIssue = await this.occurrenceRepository.hasFailedOccurrences(
params.id,
params.companyId,
transaction
);
if (hasIssue.isFailure) return Result.fail(hasIssue.error);
const customerId = found.data.customerId.match(
(value) => value,
() => null
);
if (!customerId) return Result.ok(this.toDetail(found.data, hasIssue.data, null));
const customers = await this.customerServices.findSummariesByIds([customerId], {
companyId: params.companyId,
transaction,
});
if (customers.isFailure) return Result.fail(customers.error);
return Result.ok(this.toDetail(found.data, hasIssue.data, customers.data[0] ?? null));
});
}
@ -93,7 +114,11 @@ export class RecurringProformaReader {
);
}
private toDetail(source: RecurringProforma): RecurringProformaDetailReadModel {
private toDetail(
source: RecurringProforma,
hasIssue: boolean,
customer: RecurringProformaDetailReadModel["customer"]
): RecurringProformaDetailReadModel {
const schedule = source.schedule.map<RecurringProformaScheduleReadModel>((value) => ({
startDate: value.startDate,
endDate: value.endDate,
@ -110,6 +135,7 @@ export class RecurringProformaReader {
(value) => value,
() => null
),
customer,
description: source.description.match(
(value) => value,
() => null
@ -150,6 +176,7 @@ export class RecurringProformaReader {
(value) => value,
() => null
),
hasIssue,
items: source.items.getAll().map((item, index) => ({
id: item.id,
position: index + 1,

View File

@ -44,6 +44,13 @@ export class RecurringProformaDetailPresenter {
name: item.name,
status: item.status,
customer_id: id(item.customerId),
customer: item.customer
? {
id: id(item.customer.id),
name: item.customer.name,
tin: item.customer.tin,
}
: null,
description: item.description,
payment_term_id: id(item.paymentTermId),
payment_method_id: id(item.paymentMethodId),
@ -62,6 +69,7 @@ export class RecurringProformaDetailPresenter {
}
: null,
next_occurrence_date: dateOnly(item.nextOccurrenceDate),
has_issue: item.hasIssue,
items: item.items.map((recurringItem: any) => ({
...recurringItem,
id: id(recurringItem.id),

View File

@ -10,11 +10,13 @@ import { RecurringProformaDetailPresenter } from "./index";
test("RecurringProformaDetailPresenter conserva PercentageSchema como strings value y scale", () => {
const discount = DiscountPercentage.create({ value: 500 }).data;
const customerId = UniqueID.generateNewID();
const dto = new RecurringProformaDetailPresenter().present({
id: UniqueID.generateNewID(),
name: "Cuota",
status: "draft",
customerId: null,
customerId,
customer: { id: customerId, name: "ACME Ingeniería", tin: "B12345678" },
description: null,
paymentTermId: null,
paymentMethodId: null,
@ -25,6 +27,7 @@ test("RecurringProformaDetailPresenter conserva PercentageSchema como strings va
globalDiscountPercentage: discount,
schedule: null,
nextOccurrenceDate: null,
hasIssue: false,
items: [],
createdAt: new Date("2027-01-01T10:00:00.000Z"),
updatedAt: new Date("2027-01-01T10:00:00.000Z"),
@ -32,6 +35,12 @@ test("RecurringProformaDetailPresenter conserva PercentageSchema como strings va
assert.deepEqual(dto.global_discount_percentage, { value: "500", scale: "2" });
assert.equal(typeof dto.global_discount_percentage.value, "string");
assert.equal(typeof dto.global_discount_percentage.scale, "string");
assert.equal(dto.has_issue, false);
assert.deepEqual(dto.customer, {
id: customerId.toPrimitive(),
name: "ACME Ingeniería",
tin: "B12345678",
});
});
test("RecurringProformaDetailPresenter serializa Value Objects antes de responder HTTP", () => {
@ -45,6 +54,7 @@ test("RecurringProformaDetailPresenter serializa Value Objects antes de responde
name: "Cuota",
status: "draft",
customerId: null,
customer: null,
description: null,
paymentTermId: null,
paymentMethodId: null,
@ -55,6 +65,7 @@ test("RecurringProformaDetailPresenter serializa Value Objects antes de responde
globalDiscountPercentage: null,
schedule: null,
nextOccurrenceDate: null,
hasIssue: true,
items: [{ id: UniqueID.generateNewID(), position: 1, description: itemDescription }],
createdAt: new Date("2027-01-01T10:00:00.000Z"),
updatedAt: new Date("2027-01-01T10:00:00.000Z"),
@ -65,4 +76,5 @@ test("RecurringProformaDetailPresenter serializa Value Objects antes de responde
assert.equal(dto.proforma_series, "PRO");
assert.equal(dto.issued_invoice_series, "FAC");
assert.equal(dto.items[0]?.description, "Servicio mensual");
assert.equal(dto.has_issue, true);
});

View File

@ -107,6 +107,18 @@
"created_at": "Created",
"updated_at": "Updated",
"metadata": "Created on {{createdAt}} · Last updated {{updatedAt}}",
"operational_summary": {
"customer": "Customer",
"status": "Status",
"frequency": "Frequency",
"issues": "Issues",
"next_generation": {
"label": "Next generation",
"today": "Today",
"in_days_one": "In {{count}} day",
"in_days_other": "In {{count}} days"
}
},
"inherited": "Inherited",
"general": {
"title": "General information"

View File

@ -107,6 +107,18 @@
"created_at": "Creada",
"updated_at": "Actualizada",
"metadata": "Creada el {{createdAt}} · Última actualización {{updatedAt}}",
"operational_summary": {
"customer": "Cliente",
"status": "Estado",
"frequency": "Frecuencia",
"issues": "Incidencias",
"next_generation": {
"label": "Próxima generación",
"today": "Hoy",
"in_days_one": "En {{count}} día",
"in_days_other": "En {{count}} días"
}
},
"inherited": "Heredado",
"general": {
"title": "Informacion general"

View File

@ -1,5 +1,3 @@
import { Badge } from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../../i18n";
import type { IssuedInvoiceDetailCommercialViewModel } from "../../entities";
import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
@ -8,9 +6,7 @@ type IssuedInvoicePaymentSectionProps = {
commercial: IssuedInvoiceDetailCommercialViewModel;
};
export const IssuedInvoicePaymentSection = ({
commercial,
}: IssuedInvoicePaymentSectionProps) => {
export const IssuedInvoicePaymentSection = ({ commercial }: IssuedInvoicePaymentSectionProps) => {
const { t } = useTranslation();
return (
@ -21,7 +17,9 @@ export const IssuedInvoicePaymentSection = ({
<div className="space-y-3">
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.payment_method")}
value={commercial.paymentMethodName ?? t("pages.issued_invoices.detail.empty.not_available")}
value={
commercial.paymentMethodName ?? t("pages.issued_invoices.detail.empty.not_available")
}
/>
{commercial.paymentMethodDescription ? (
<IssuedInvoiceDetailField
@ -32,7 +30,9 @@ export const IssuedInvoicePaymentSection = ({
) : null}
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.payment_term")}
value={commercial.paymentTermName ?? t("pages.issued_invoices.detail.empty.not_available")}
value={
commercial.paymentTermName ?? t("pages.issued_invoices.detail.empty.not_available")
}
/>
{commercial.paymentTermDescription ? (
<IssuedInvoiceDetailField
@ -47,9 +47,9 @@ export const IssuedInvoicePaymentSection = ({
commercial.paymentTermDueRules.length > 0 ? (
<div className="flex flex-wrap gap-2 sm:justify-end">
{commercial.paymentTermDueRules.map((rule) => (
<Badge className="text-xs" key={rule.key} variant="secondary">
<RdxBadge className="text-xs" key={rule.key} variant="secondary">
{rule.percentageLabel} · {rule.dueDaysLabel}
</Badge>
</RdxBadge>
))}
</div>
) : (

View File

@ -1,8 +1,4 @@
import {
DataTable,
type DataTableSort,
SkeletonDataTable,
} from "@repo/rdx-ui/components";
import { DataTable, type DataTableSort, SkeletonDataTable } from "@repo/rdx-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import type * as React from "react";
@ -121,8 +117,8 @@ export const IssuedInvoicesGrid = ({
manualSorting
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onSortChange={onSortChange}
onRowClick={onRowClick ? (row) => onRowClick(row.id) : undefined}
onSortChange={onSortChange}
pageIndex={pageIndex}
pageSize={pageSize}
sort={sort}

View File

@ -1,4 +1,4 @@
import { Badge, ToggleGroup, ToggleGroupItem } from "@repo/shadcn-ui/components";
import { ToggleGroup, ToggleGroupItem } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
AlertTriangleIcon,
@ -164,8 +164,13 @@ export const IssuedInvoicesStatusSegmentedFilter = ({
>
<span className="flex items-center gap-2">
{option.icon ? <option.icon className="size-3.5" /> : null}
<span>{t(`catalog.issued_invoices.status.${option.value.toLowerCase()}.label`, option.label)}</span>
<Badge
<span>
{t(
`catalog.issued_invoices.status.${option.value.toLowerCase()}.label`,
option.label
)}
</span>
<RdxBadge
className={cn(
"min-w-5 rounded-full px-1.5 text-[11px] font-semibold tabular-nums",
"bg-muted text-muted-foreground",
@ -174,7 +179,7 @@ export const IssuedInvoicesStatusSegmentedFilter = ({
variant="secondary"
>
{counts[option.countKey]}
</Badge>
</RdxBadge>
</span>
</ToggleGroupItem>
))}

View File

@ -1,4 +1,4 @@
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 { useTranslation } from "../../../../i18n";
@ -23,7 +23,7 @@ export const VerifactuStatusBadge = ({ status, className }: VerifactuStatusBadge
<Tooltip>
<TooltipTrigger
render={
<Badge
<RdxBadge
className={cn(
getVerifactuRecordStatusColor(normalizedStatus),
"font-semibold",
@ -35,7 +35,7 @@ export const VerifactuStatusBadge = ({ status, className }: VerifactuStatusBadge
{t(`catalog.issued_invoices.status.${normalizedStatus.toLowerCase()}.label`, {
defaultValue: status,
})}
</Badge>
</RdxBadge>
}
/>
<TooltipContent>

View File

@ -1,9 +1,4 @@
import {
ErrorAlert,
PageErrorState,
PageHeader,
SimpleSearchInput,
} from "@erp/core/components";
import { ErrorAlert, PageErrorState, PageHeader, SimpleSearchInput } from "@erp/core/components";
import { useReturnToNavigation } from "@erp/core/hooks";
import { LogoVerifactu } from "@repo/rdx-ui/components";
import { Alert, AlertDescription, AlertTitle, Button } from "@repo/shadcn-ui/components";
@ -14,10 +9,7 @@ import { createSearchParams, useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
import { useIssuedInvoiceListPageController } from "../../controllers";
import { IssuedInvoicesGrid, useIssuedInvoicesGridColumns } from "../blocks";
import {
IssuedInvoicesEmptyGrid,
IssuedInvoicesStatusSegmentedFilter,
} from "../components";
import { IssuedInvoicesEmptyGrid, IssuedInvoicesStatusSegmentedFilter } from "../components";
export const ListIssuedInvoicesPage = () => {
const { t } = useTranslation();
@ -143,8 +135,8 @@ export const ListIssuedInvoicesPage = () => {
loading={listCtrl.isLoading}
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}
onSortChange={listCtrl.setSort}
onRowClick={handleOpenDetail}
onSortChange={listCtrl.setSort}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
searching={isSearchPending}

View File

@ -35,7 +35,7 @@ export const ProformaCreatePage = () => {
padding="none"
width="full"
>
<RdxEditorPageLayout complexity="simple">
<RdxEditorPageLayout width="simple">
<RdxPageStack spacing="default">
{createCtrl.isCreateError && (
<ErrorAlert

View File

@ -6,7 +6,7 @@ import type {
ProformaDetailActionsViewModel,
ProformaDetailHeaderViewModel,
} from "../../entities";
import { ProformaDetailArchivedBadge, ProformaDetailHeaderMeta } from "../components";
import { ProformaDetailHeaderMeta } from "../components";
import { ProformaDetailHeaderActions } from "./proforma-detail-header-actions";

View File

@ -1,4 +1,3 @@
import { Badge } from "@repo/shadcn-ui/components";
import { CircleAlertIcon, FileClockIcon, FileOutputIcon, Link2OffIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
@ -33,15 +32,18 @@ export const ProformaLinkedInvoiceSection = ({
<Icon aria-hidden="true" className="size-4" />
</span>
<div className="space-y-2">
<Badge variant="outline">{linkedInvoice.primaryText}</Badge>
<RdxBadge variant="outline">{linkedInvoice.primaryText}</RdxBadge>
{linkedInvoice.secondaryText ? (
<p className="text-sm leading-6 text-muted-foreground">{linkedInvoice.secondaryText}</p>
<p className="text-sm leading-6 text-muted-foreground">
{linkedInvoice.secondaryText}
</p>
) : null}
</div>
</div>
</div>
{linkedInvoice.state === "issued-linked-without-detail-route" && linkedInvoice.linkedInvoiceId ? (
{linkedInvoice.state === "issued-linked-without-detail-route" &&
linkedInvoice.linkedInvoiceId ? (
<ProformaDetailField
label={t("proformas.detail.fields.linked_invoice_id")}
value={linkedInvoice.linkedInvoiceId}

View File

@ -1,5 +1,4 @@
import { InitialsAvatar } from "@repo/rdx-ui/components";
import { Badge } from "@repo/shadcn-ui/components";
import { Building2Icon, CopyIcon, UserIcon } from "lucide-react";
import type { Proforma } from "../../../../shared";
@ -30,7 +29,7 @@ export const ProformaHeader = ({ proforma }: { proforma: Proforma }) => {
)}
<div className="mt-2 flex items-center gap-2">
<Badge className="gap-1" variant="outline">
<RdxBadge className="gap-1" variant="outline">
{proforma.recipient.tin ? (
<>
<Building2Icon className="size-3" />
@ -42,7 +41,7 @@ export const ProformaHeader = ({ proforma }: { proforma: Proforma }) => {
Particular
</>
)}
</Badge>
</RdxBadge>
<button
className="flex items-center gap-1 text-xs font-mono text-muted-foreground hover:text-foreground"

View File

@ -1,3 +1,4 @@
import { RdxBadge } from "@repo/rdx-ui/components";
import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
@ -22,9 +23,9 @@ export const ProformaStatusBadge = ({ status, className }: ProformaStatusBadgePr
<Tooltip>
<TooltipTrigger
render={
<span
<RdxBadge
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium leading-none whitespace-nowrap",
"inline-flex items-center gap-1.5 border-0 leading-none whitespace-nowrap",
"[&>svg]:pointer-events-none [&>svg]:shrink-0 [&>svg]:size-3.5",
getProformaStatusBadgeClassName(normalizedStatus),
className
@ -32,7 +33,7 @@ export const ProformaStatusBadge = ({ status, className }: ProformaStatusBadgePr
>
<Icon aria-hidden="true" />
{t(`catalog.proformas.status.${normalizedStatus}.label`, { defaultValue: status })}
</span>
</RdxBadge>
}
/>
<TooltipContent>

View File

@ -1,4 +1,5 @@
import { Badge, ToggleGroup, ToggleGroupItem } from "@repo/shadcn-ui/components";
import { RdxBadge } from "@repo/rdx-ui/components";
import { ToggleGroup, ToggleGroupItem } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
CheckCircle2Icon,
@ -114,7 +115,7 @@ export const ProformaStatusSegmentedFilter = ({
{PROFORMA_STATUS_FILTER_OPTIONS.map((option) => (
<ToggleGroupItem
className={cn(
"group h-auto min-w-fit rounded-full border-transparent px-3 py-2 text-xs sm:text-sm shadow-none transition-colors",
"group h-auto min-w-fit rounded-md border-transparent px-3 py-2 text-xs sm:text-sm shadow-none transition-colors",
"text-muted-foreground hover:border-border hover:bg-muted/60 hover:text-foreground",
"data-[state=on]:shadow-none",
option.activeClassName
@ -130,7 +131,7 @@ export const ProformaStatusSegmentedFilter = ({
<span className="flex items-center gap-2">
{option.icon ? <option.icon className="size-3.5" /> : null}
<span>{t(option.labelKey)}</span>
<Badge
<RdxBadge
className={cn(
"min-w-5 rounded-full px-1.5 text-[11px] font-semibold tabular-nums",
"bg-muted text-muted-foreground",
@ -139,7 +140,7 @@ export const ProformaStatusSegmentedFilter = ({
variant="secondary"
>
{counts[option.countKey]}
</Badge>
</RdxBadge>
</span>
</ToggleGroupItem>
))}

View File

@ -1,7 +1,7 @@
"use client";
import { RdxBadge } from "@repo/rdx-ui/components";
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
@ -144,50 +144,53 @@ const orders = [
function StatusBadge({ status }: { status: string }) {
if (status === "Paid") {
return (
<Badge className="gap-1 border-emerald-200 bg-emerald-50 text-emerald-700" variant="outline">
<RdxBadge
className="gap-1 border-emerald-200 bg-emerald-50 text-emerald-700"
variant="outline"
>
<CheckCircle2 className="h-3 w-3" />
Paid
</Badge>
</RdxBadge>
);
}
if (status === "Cancelled") {
return (
<Badge className="gap-1 border-red-200 bg-red-50 text-red-600" variant="outline">
<RdxBadge className="gap-1 border-red-200 bg-red-50 text-red-600" variant="outline">
<XCircle className="h-3 w-3" />
Cancelled
</Badge>
</RdxBadge>
);
}
return (
<Badge className="gap-1 border-amber-200 bg-amber-50 text-amber-700" variant="outline">
<RdxBadge className="gap-1 border-amber-200 bg-amber-50 text-amber-700" variant="outline">
<Clock className="h-3 w-3" />
{status}
</Badge>
</RdxBadge>
);
}
function FulfillmentBadge({ fulfillment }: { fulfillment: string }) {
if (fulfillment === "Fulfilled") {
return (
<Badge className="gap-1 border-blue-200 bg-blue-50 text-blue-700" variant="outline">
<RdxBadge className="gap-1 border-blue-200 bg-blue-50 text-blue-700" variant="outline">
<CheckCircle2 className="h-3 w-3" />
Fulfilled
</Badge>
</RdxBadge>
);
}
if (fulfillment === "Cancelled") {
return (
<Badge className="gap-1 border-red-200 bg-red-50 text-red-600" variant="outline">
<RdxBadge className="gap-1 border-red-200 bg-red-50 text-red-600" variant="outline">
<XCircle className="h-3 w-3" />
Cancelled
</Badge>
</RdxBadge>
);
}
return (
<Badge className="gap-1 border-border bg-muted text-muted-foreground" variant="outline">
<RdxBadge className="gap-1 border-border bg-muted text-muted-foreground" variant="outline">
<Clock className="h-3 w-3" />
Unfulfilled
</Badge>
</RdxBadge>
);
}

View File

@ -1,5 +1,5 @@
import { RdxBadge } from "@repo/rdx-ui/components";
import {
Badge,
Button,
Card,
CardContent,
@ -54,9 +54,9 @@ export const CustomerViewDialog = ({ customerId, open, onOpenChange }: CustomerV
</DialogTitle>
<DialogDescription className="px-0">
{customer?.tin ? (
<Badge className="ml-0 font-mono" variant="secondary">
<RdxBadge className="ml-0 font-mono" variant="secondary">
{customer.tin}
</Badge>
</RdxBadge>
) : (
<span className="text-muted-foreground">Ficha del cliente</span>
)}
@ -106,9 +106,9 @@ export const CustomerViewDialog = ({ customerId, open, onOpenChange }: CustomerV
<dt className="text-sm text-muted-foreground">Impuestos por defecto</dt>
<dd className="mt-1 flex flex-wrap gap-1">
{customer.default_taxes.map((tax: string) => (
<Badge key={tax} variant="secondary">
<RdxBadge key={tax} variant="secondary">
{tax}
</Badge>
</RdxBadge>
))}
</dd>
</div>

View File

@ -1,6 +1,6 @@
import type { CustomerSelectionOption } from "@erp/customers";
import { RdxBadge } from "@repo/rdx-ui/components";
import {
Badge,
Tooltip,
TooltipContent,
TooltipProvider,
@ -75,7 +75,7 @@ export const SelectedRecipientSummary = ({
<p className="min-w-0 wrap-break-word text-base font-semibold">{recipient.name}</p>
{recipient.status ? (
<Badge
<RdxBadge
className={cn(
"shrink-0 border",
getCustomerStatusBadgeClassName(recipient.status)
@ -83,7 +83,7 @@ export const SelectedRecipientSummary = ({
variant="outline"
>
{recipient.status}
</Badge>
</RdxBadge>
) : null}
</div>

View File

@ -1,5 +1,5 @@
import { RdxBadge } from "@repo/rdx-ui/components";
import {
Badge,
Button,
Popover,
PopoverContent,
@ -40,7 +40,12 @@ interface HelpReadinessSectionProps {
translate: (key: string, params?: Record<string, string | number | null | undefined>) => string;
}
const HelpTrigger = ({ ariaLabel, className, pendingBlockersCount, ...props }: HelpTriggerProps) => {
const HelpTrigger = ({
ariaLabel,
className,
pendingBlockersCount,
...props
}: HelpTriggerProps) => {
return (
<Button
{...props}
@ -52,12 +57,12 @@ const HelpTrigger = ({ ariaLabel, className, pendingBlockersCount, ...props }: H
>
<CircleHelpIcon aria-hidden="true" className="size-4" />
{pendingBlockersCount > 0 ? (
<Badge
<RdxBadge
className="pointer-events-none absolute -right-1 -top-1 min-w-5 rounded-full border-amber-200 bg-amber-50 px-1 text-[11px] leading-4 text-amber-700 shadow-none"
variant="outline"
>
{pendingBlockersCount}
</Badge>
</RdxBadge>
) : null}
</Button>
);
@ -199,7 +204,9 @@ export const ProformaUpdateHelpPopover = ({
return (
<Popover>
<PopoverTrigger
render={<HelpTrigger ariaLabel={triggerAriaLabel} pendingBlockersCount={pendingBlockersCount} />}
render={
<HelpTrigger ariaLabel={triggerAriaLabel} pendingBlockersCount={pendingBlockersCount} />
}
/>
<PopoverContent

View File

@ -75,7 +75,7 @@ export const ProformaUpdateEditorForm = ({
</div>
}
className={className}
complexity="complex"
width="full"
>
<div className="min-w-0">
<form id={formId} noValidate onKeyDown={preventEnterKeySubmitForm} onSubmit={onSubmit}>

View File

@ -1,5 +1,6 @@
import {
DatePickerField,
RdxBadge,
RdxEditorSection,
RdxFormGrid,
SelectField,
@ -7,7 +8,6 @@ import {
TextAreaField,
TextField,
} from "@repo/rdx-ui/components";
import { Badge } from "@repo/shadcn-ui/components";
import { FileTextIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
@ -45,9 +45,9 @@ export const ProformaUpdateHeaderEditor = ({
placeholder={t("form_fields.proformas.invoice_number.placeholder")}
readOnly={true}
rightIcon={
<Badge className="text-sm" variant="secondary">
<RdxBadge className="text-sm" variant="secondary">
Autom.
</Badge>
</RdxBadge>
}
/>

View File

@ -4,13 +4,13 @@ import { QuickCreateCustomerDialog, SelectCustomerDialog } from "@erp/customers"
import {
CancelActionButton,
PercentageField,
RdxBadge,
RdxEditorStickyFooter,
RdxPageContainer,
RdxPageStack,
RhfSubmitActionButton,
} from "@repo/rdx-ui/components";
import { showErrorToast } from "@repo/rdx-ui/helpers";
import { Badge } from "@repo/shadcn-ui/components";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "../../../../i18n";
@ -114,12 +114,12 @@ export const ProformaUpdatePage = () => {
isSaving={updateCtrl.form.formState.isSubmitting}
labels={{
title: proforma ? (
<span className="flex flex-wrap items-center gap-2">
<span className="inline-flex max-w-full items-center gap-3">
<span>{t("pages.proformas.update.title", "Editar proforma")}</span>
<Badge className="border-border/80 bg-muted px-2.5 py-1 font-mono text-base text-muted-foreground shadow-none">
<RdxBadge className="border-border/80 bg-muted text-muted-foreground">
{proforma.proformaReference}
</Badge>
<ProformaStatusBadge className="text-base" status={proforma.status} />
</RdxBadge>
<ProformaStatusBadge status={proforma.status} />
</span>
) : (
t("pages.proformas.update.title", "Editar proforma")
@ -142,7 +142,11 @@ export const ProformaUpdatePage = () => {
/>
<div className="flex min-h-0 flex-1 flex-col bg-muted/20">
<RdxPageContainer className="min-h-0 flex-1 overflow-y-auto" padding="none" width="full">
<RdxPageContainer
className="min-h-0 flex-1 overflow-y-auto"
padding="default"
width="full"
>
{hasPageAlerts ? (
<RdxPageContainer className="pb-0" width="full">
<RdxPageStack spacing="compact">

View File

@ -16,11 +16,12 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../i18n";
import { useCreateRecurringProforma } from "../shared/hooks/use-create-recurring-proforma";
import {
buildCreateRecurringProformaInput,
getCreatedRecurringProformaPath,
type RecurringProformaCreateForm,
RecurringProformaCreateFormSchema,
buildCreateRecurringProformaInput,
getCreatedRecurringProformaPath,
} from "./recurring-proforma-create-utils";
const RECURRING_PROFORMA_CREATE_FORM_ID = "recurring-proforma-create-form";
@ -77,7 +78,7 @@ export const RecurringProformaCreatePage = () => {
title={t("recurring_proformas.create.title")}
/>
<div className="flex-1 overflow-y-auto bg-muted/20">
<RdxEditorPageLayout complexity="simple">
<RdxEditorPageLayout width="simple">
<form
className="space-y-6"
id={RECURRING_PROFORMA_CREATE_FORM_ID}

View File

@ -1,5 +1,5 @@
import type { RecurringProformaStatus } from "../shared";
import type { RecurringProformaAction } from "../shared/hooks/use-recurring-proforma-action-mutation";
import type { RecurringProformaStatus } from "../shared/recurring-proforma-list";
export type RecurringProformaDetailActionPlacement = "primary" | "secondary" | "menu";
@ -17,6 +17,7 @@ export const getRecurringProformaDetailActions = (
case "draft":
return [
{ key: "activate", placement: "primary" },
{ key: "edit", placement: "secondary" },
{ key: "duplicate", placement: "secondary" },
{ key: "complete", placement: "menu", terminal: true },
{ key: "delete", placement: "menu", terminal: true, destructive: true },
@ -30,6 +31,7 @@ export const getRecurringProformaDetailActions = (
case "paused":
return [
{ key: "resume", placement: "primary" },
{ key: "edit", placement: "secondary" },
{ key: "duplicate", placement: "secondary" },
{ key: "complete", placement: "menu", terminal: true },
];
@ -40,5 +42,7 @@ export const getRecurringProformaDetailActions = (
];
case "archived":
return [{ key: "duplicate", placement: "primary" }];
default:
throw new Error(`Unsupported recurring proforma status: ${status}`);
}
};

View File

@ -19,6 +19,7 @@ test("presenta nombre, estado y recurrencia del detalle principal", () => {
name: "Mantenimiento mensual",
status: "active",
customer_id: null,
customer: null,
description: null,
payment_term_id: null,
payment_method_id: null,
@ -27,8 +28,15 @@ test("presenta nombre, estado y recurrencia del detalle principal", () => {
proforma_series: null,
issued_invoice_series: null,
global_discount_percentage: null,
schedule: { start_date: "2026-09-01", end_date: null, unit: "month", interval: 1, monthly_rule: "same_day" },
schedule: {
start_date: "2026-09-01",
end_date: null,
unit: "month",
interval: 1,
monthly_rule: "same_day",
},
next_occurrence_date: null,
has_issue: false,
items: [],
created_at: "2026-09-01T10:00:00.000Z",
updated_at: "2026-09-01T10:00:00.000Z",
@ -53,7 +61,16 @@ test("presenta null como herencia y conserva overrides explicitos", () => {
});
test("resuelve loading, error y contenido del detalle", () => {
assert.equal(getRecurringProformaDetailFeedbackState({ isLoading: true, isError: false, hasData: false }), "loading");
assert.equal(getRecurringProformaDetailFeedbackState({ isLoading: false, isError: true, hasData: false }), "error");
assert.equal(getRecurringProformaDetailFeedbackState({ isLoading: false, isError: false, hasData: true }), "ready");
assert.equal(
getRecurringProformaDetailFeedbackState({ isLoading: true, isError: false, hasData: false }),
"loading"
);
assert.equal(
getRecurringProformaDetailFeedbackState({ isLoading: false, isError: true, hasData: false }),
"error"
);
assert.equal(
getRecurringProformaDetailFeedbackState({ isLoading: false, isError: false, hasData: true }),
"ready"
);
});

View File

@ -1,19 +1,25 @@
import { DataTable, DataTableColumnHeader, SkeletonDataTable } from "@repo/rdx-ui/components";
import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
import {
DataTable,
DataTableColumnHeader,
RdxBadge,
SkeletonDataTable,
} from "@repo/rdx-ui/components";
import { Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "../../i18n";
import { useRecurringProformaOccurrences } from "../shared/hooks/use-recurring-proforma-occurrences";
import type { RecurringProformaOccurrence } from "../shared/recurring-proforma-list";
import { formatRecurringProformaDateOnly } from "./recurring-proforma-detail-utils";
import {
buildGeneratedProformaPath,
formatRecurringProformaOccurrenceDateTime,
getRecurringProformaOccurrencesFeedbackState,
getRecurringProformaOccurrenceStatusKey,
getRecurringProformaOccurrencesFeedbackState,
} from "./recurring-proforma-occurrences-section-utils";
import { useRecurringProformaOccurrences } from "../shared/hooks/use-recurring-proforma-occurrences";
import type { RecurringProformaOccurrence } from "../shared/recurring-proforma-list";
const PAGE_SIZE = 10;
@ -38,22 +44,46 @@ export const RecurringProformaOccurrencesSection = ({
() => [
{
accessorKey: "scheduled_date",
header: ({ column }) => <DataTableColumnHeader column={column} title={t("recurring_proformas.occurrences.columns.scheduled_date")} />,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("recurring_proformas.occurrences.columns.scheduled_date")}
/>
),
cell: ({ row }) => formatRecurringProformaDateOnly(row.original.scheduled_date, dateLocale),
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title={t("recurring_proformas.occurrences.columns.status")} />,
cell: ({ row }) => <Badge variant="secondary">{t(getRecurringProformaOccurrenceStatusKey(row.original.status))}</Badge>,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("recurring_proformas.occurrences.columns.status")}
/>
),
cell: ({ row }) => (
<RdxBadge variant="secondary">
{t(getRecurringProformaOccurrenceStatusKey(row.original.status))}
</RdxBadge>
),
},
{
accessorKey: "attempt_count",
header: ({ column }) => <DataTableColumnHeader column={column} title={t("recurring_proformas.occurrences.columns.attempt_count")} />,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("recurring_proformas.occurrences.columns.attempt_count")}
/>
),
cell: ({ row }) => <span className="tabular-nums">{row.original.attempt_count}</span>,
},
{
id: "proforma",
header: ({ column }) => <DataTableColumnHeader column={column} title={t("recurring_proformas.occurrences.columns.proforma")} />,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("recurring_proformas.occurrences.columns.proforma")}
/>
),
cell: ({ row }) => {
const proformaPath = buildGeneratedProformaPath(row.original.proforma_id);
return proformaPath ? (
@ -65,18 +95,34 @@ export const RecurringProformaOccurrencesSection = ({
},
{
accessorKey: "generated_at",
header: ({ column }) => <DataTableColumnHeader column={column} title={t("recurring_proformas.occurrences.columns.generated_at")} />,
cell: ({ row }) => formatRecurringProformaOccurrenceDateTime(row.original.generated_at, dateLocale) ?? "-",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("recurring_proformas.occurrences.columns.generated_at")}
/>
),
cell: ({ row }) =>
formatRecurringProformaOccurrenceDateTime(row.original.generated_at, dateLocale) ?? "-",
},
{
id: "last_error",
header: ({ column }) => <DataTableColumnHeader column={column} title={t("recurring_proformas.occurrences.columns.last_error")} />,
cell: ({ row }) => row.original.status === "failed" && row.original.last_error_message ? (
<div className="space-y-1">
<p className="max-w-sm text-sm text-destructive">{row.original.last_error_message}</p>
{row.original.last_error_code ? <span className="text-xs text-muted-foreground">{row.original.last_error_code}</span> : null}
</div>
) : null,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("recurring_proformas.occurrences.columns.last_error")}
/>
),
cell: ({ row }) =>
row.original.status === "failed" && row.original.last_error_message ? (
<div className="space-y-1">
<p className="max-w-sm text-sm text-destructive">{row.original.last_error_message}</p>
{row.original.last_error_code ? (
<span className="text-xs text-muted-foreground">
{row.original.last_error_code}
</span>
) : null}
</div>
) : null,
},
],
[dateLocale, t]
@ -84,9 +130,13 @@ export const RecurringProformaOccurrencesSection = ({
return (
<Card>
<CardHeader><CardTitle>{t("recurring_proformas.occurrences.title")}</CardTitle></CardHeader>
<CardHeader>
<CardTitle>{t("recurring_proformas.occurrences.title")}</CardTitle>
</CardHeader>
<CardContent>
{feedbackState === "loading" ? <SkeletonDataTable columns={6} rows={3} showFooter={false} /> : null}
{feedbackState === "loading" ? (
<SkeletonDataTable columns={6} rows={3} showFooter={false} />
) : null}
{feedbackState === "error" ? (
<div className="space-y-3 rounded-md border border-destructive/30 p-4" role="alert">
<p className="text-sm text-destructive">{t("recurring_proformas.occurrences.error")}</p>
@ -95,7 +145,11 @@ export const RecurringProformaOccurrencesSection = ({
</Button>
</div>
) : null}
{feedbackState === "empty" ? <p className="text-sm text-muted-foreground">{t("recurring_proformas.occurrences.empty")}</p> : null}
{feedbackState === "empty" ? (
<p className="text-sm text-muted-foreground">
{t("recurring_proformas.occurrences.empty")}
</p>
) : null}
{feedbackState === "ready" ? (
<DataTable
columns={columns}
@ -104,7 +158,10 @@ export const RecurringProformaOccurrencesSection = ({
isFetching={query.isFetching}
manualPagination
onPageChange={(pageIndex) => setPage(pageIndex + 1)}
onPageSizeChange={(pageSize) => { setLimit(pageSize); setPage(1); }}
onPageSizeChange={(pageSize) => {
setLimit(pageSize);
setPage(1);
}}
pageIndex={data.page - 1}
pageSize={data.per_page}
totalItems={data.total_items}

View File

@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildRecurringProformaOperationalSummary } from "./recurring-proforma-operational-summary";
test("resume frecuencia y próxima generación con fecha civil", () => {
const summary = buildRecurringProformaOperationalSummary({
recurringProforma: {
id: "recurring-id",
name: "Mantenimiento",
status: "active",
customer_id: "customer-id",
customer: { id: "customer-id", name: "ACME Ingeniería", tin: "B12345678" },
description: null,
payment_term_id: null,
payment_method_id: null,
language: null,
currency: null,
proforma_series: null,
issued_invoice_series: null,
global_discount_percentage: null,
schedule: {
start_date: "2026-09-01",
end_date: null,
unit: "month",
interval: 2,
monthly_rule: "last_day_of_month",
},
next_occurrence_date: "2026-09-15",
has_issue: false,
items: [],
created_at: "2026-09-01T10:00:00.000Z",
updated_at: "2026-09-01T10:00:00.000Z",
},
locale: "es-ES",
now: new Date(2026, 8, 1),
t: (key, options) => `${key}:${options?.count ?? ""}`,
});
assert.equal(summary.nextGeneration.date, "15/09/2026");
assert.equal(
summary.nextGeneration.relativeDate,
"recurring_proformas.detail.operational_summary.next_generation.in_days:14"
);
assert.equal(summary.frequency.primary, "recurring_proformas.list.schedule.month:2");
assert.equal(summary.frequency.secondary, "recurring_proformas.list.schedule.last_day_of_month:");
});
test("omite la distancia relativa cuando la próxima generación ya ha pasado", () => {
const summary = buildRecurringProformaOperationalSummary({
recurringProforma: {
id: "recurring-id",
name: "Mantenimiento",
status: "paused",
customer_id: null,
customer: null,
description: null,
payment_term_id: null,
payment_method_id: null,
language: null,
currency: null,
proforma_series: null,
issued_invoice_series: null,
global_discount_percentage: null,
schedule: null,
next_occurrence_date: "2026-09-01",
has_issue: true,
items: [],
created_at: "2026-09-01T10:00:00.000Z",
updated_at: "2026-09-01T10:00:00.000Z",
},
locale: "es-ES",
now: new Date(2026, 8, 2),
t: (key) => key,
});
assert.equal(summary.frequency.primary, "—");
assert.equal(summary.nextGeneration.relativeDate, null);
});

View File

@ -0,0 +1,70 @@
import type { RecurringProformaDetail } from "../shared/recurring-proforma-list";
type Translate = (key: string, options?: { count?: number }) => string;
export type RecurringProformaOperationalSummary = {
frequency: {
primary: string;
secondary: string | null;
};
nextGeneration: {
date: string;
relativeDate: string | null;
};
};
const formatCivilDate = (value: string, locale: string) => {
const [year, month, day] = value.split("-").map(Number);
return new Intl.DateTimeFormat(locale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(new Date(year, month - 1, day));
};
const getDaysUntil = (value: string, now: Date) => {
const [year, month, day] = value.split("-").map(Number);
const target = new Date(year, month - 1, day);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
};
export const buildRecurringProformaOperationalSummary = (params: {
recurringProforma: RecurringProformaDetail;
locale: string;
now?: Date;
t: Translate;
}): RecurringProformaOperationalSummary => {
const { recurringProforma, locale, t } = params;
const schedule = recurringProforma.schedule;
const nextOccurrenceDate = recurringProforma.next_occurrence_date;
const daysUntil = nextOccurrenceDate
? getDaysUntil(nextOccurrenceDate, params.now ?? new Date())
: null;
return {
frequency: schedule
? {
primary: t(`recurring_proformas.list.schedule.${schedule.unit}`, {
count: schedule.interval,
}),
secondary: schedule.monthly_rule
? t(`recurring_proformas.list.schedule.${schedule.monthly_rule}`)
: null,
}
: { primary: "—", secondary: null },
nextGeneration: nextOccurrenceDate
? {
date: formatCivilDate(nextOccurrenceDate, locale),
relativeDate:
daysUntil === 0
? t("recurring_proformas.detail.operational_summary.next_generation.today")
: daysUntil && daysUntil > 0
? t("recurring_proformas.detail.operational_summary.next_generation.in_days", {
count: daysUntil,
})
: null,
}
: { date: "—", relativeDate: null },
};
};

View File

@ -0,0 +1,129 @@
import { RdxBadge } from "@repo/rdx-ui/components";
import {
Building2Icon,
CalendarClockIcon,
CircleAlertIcon,
CircleCheckIcon,
Repeat2Icon,
} from "lucide-react";
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
import { getRecurringProformaStatusKey } from "../../../list/recurring-proforma-list-utils";
import { getRecurringProformaStatusBadgeClassName } from "../../../shared/helpers/recurring-proforma-status-ui";
import type { RecurringProformaDetail } from "../../../shared/recurring-proforma-list";
import { buildRecurringProformaOperationalSummary } from "../../recurring-proforma-operational-summary";
type RecurringProformaOperationalSummaryProps = {
recurringProforma: RecurringProformaDetail;
locale: string;
};
type SummaryBlockProps = {
icon: ReactNode;
label: string;
children: ReactNode;
};
const SummaryBlock = ({ icon, label, children }: SummaryBlockProps) => (
<section className="min-w-0 space-y-2 p-4 sm:p-5">
<p className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{icon}
{label}
</p>
{children}
</section>
);
export const RecurringProformaOperationalSummary = ({
recurringProforma,
locale,
}: RecurringProformaOperationalSummaryProps) => {
const { t } = useTranslation();
const summary = buildRecurringProformaOperationalSummary({
recurringProforma,
locale,
t: (key, options) => String(t(key, options)),
});
return (
<div className="grid overflow-hidden rounded-lg border bg-card divide-y sm:grid-cols-2 sm:divide-y-0 sm:[&>*:nth-child(odd)]:border-r xl:grid-cols-5 xl:[&>*]:border-r xl:[&>*:last-child]:border-r-0">
<SummaryBlock
icon={<Building2Icon aria-hidden="true" className="size-4" />}
label={t("recurring_proformas.detail.operational_summary.customer")}
>
{recurringProforma.customer ? (
<div className="min-w-0 space-y-0.5">
<Link
className="block truncate text-sm font-semibold text-primary underline-offset-4 hover:underline"
to={`/customers/${recurringProforma.customer.id}/edit`}
>
{recurringProforma.customer.name}
</Link>
<p className="text-sm text-muted-foreground">{recurringProforma.customer.tin ?? "—"}</p>
</div>
) : (
<p className="text-sm font-medium text-muted-foreground">
{t("recurring_proformas.detail.no_customer")}
</p>
)}
</SummaryBlock>
<SummaryBlock
icon={<CircleCheckIcon aria-hidden="true" className="size-4" />}
label={t("recurring_proformas.detail.operational_summary.status")}
>
<RdxBadge
className={getRecurringProformaStatusBadgeClassName(recurringProforma.status)}
variant="secondary"
>
{t(getRecurringProformaStatusKey(recurringProforma.status))}
</RdxBadge>
</SummaryBlock>
<SummaryBlock
icon={<CalendarClockIcon aria-hidden="true" className="size-4" />}
label={t("recurring_proformas.detail.operational_summary.next_generation.label")}
>
<div className="space-y-0.5">
<p className="text-sm font-semibold tabular-nums">{summary.nextGeneration.date}</p>
{summary.nextGeneration.relativeDate ? (
<p className="text-sm text-muted-foreground">{summary.nextGeneration.relativeDate}</p>
) : null}
</div>
</SummaryBlock>
<SummaryBlock
icon={<Repeat2Icon aria-hidden="true" className="size-4" />}
label={t("recurring_proformas.detail.operational_summary.frequency")}
>
<div className="space-y-0.5">
<p className="text-sm font-semibold">{summary.frequency.primary}</p>
{summary.frequency.secondary ? (
<p className="text-sm text-muted-foreground">{summary.frequency.secondary}</p>
) : null}
</div>
</SummaryBlock>
<SummaryBlock
icon={
recurringProforma.has_issue ? (
<CircleAlertIcon aria-hidden="true" className="size-4 text-destructive" />
) : (
<CircleCheckIcon aria-hidden="true" className="size-4" />
)
}
label={t("recurring_proformas.detail.operational_summary.issues")}
>
<p className="text-sm font-semibold">
{t(
recurringProforma.has_issue
? "recurring_proformas.list.issues.issue"
: "recurring_proformas.list.issues.none"
)}
</p>
</SummaryBlock>
</div>
);
};

View File

@ -1,18 +1,17 @@
import { PageErrorState } from "@erp/core/components";
import { RdxPageStack } from "@repo/rdx-ui/components";
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
} from "@repo/shadcn-ui/components";
RdxBadge,
RdxEditorPageLayout,
RdxPageContainer,
RdxPageStack,
} from "@repo/rdx-ui/components";
import { Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
import { ArrowLeftIcon } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
import { ProformaPageHeader } from "../../../../proformas/shared";
import { getRecurringProformaStatusBadgeClassName } from "../../../shared/helpers/recurring-proforma-status-ui";
import { useRecurringProforma } from "../../../shared/hooks/use-recurring-proforma";
import { useRecurringProformaActionMutation } from "../../../shared/hooks/use-recurring-proforma-action-mutation";
import { getRecurringProformaDetailActions } from "../../recurring-proforma-detail-actions";
@ -26,6 +25,7 @@ import {
} from "../../recurring-proforma-detail-utils";
import { RecurringProformaOccurrencesSection } from "../../recurring-proforma-occurrences-section";
import { RecurringProformaDetailHeaderActions } from "../blocks/recurring-proforma-detail-header-actions";
import { RecurringProformaOperationalSummary } from "../blocks/recurring-proforma-operational-summary";
type DetailFieldProps = {
label: string;
@ -110,153 +110,165 @@ export const RecurringProformaDetailPage = () => {
backLabel={t("recurring_proformas.detail.back")}
onBack={() => navigate("/recurring-proformas")}
title={
<span className="inline-flex max-w-full items-center gap-3">
<span className="truncate">{mainView.name}</span>
<Badge className="shrink-0 px-4 py-3 rounded-md" variant="secondary">
{t(mainView.statusKey)}
</Badge>
</span>
<div className="space-y-3">
<span className="inline-flex max-w-full items-center gap-3">
<span className="truncate">{mainView.name}</span>
<RdxBadge
className={`shrink-0 rounded-md px-4 py-3 ${getRecurringProformaStatusBadgeClassName(recurringProforma.status)}`}
variant="secondary"
>
{t(mainView.statusKey)}
</RdxBadge>
<RdxBadge className="shrink-0 px-4 py-3 rounded-md" variant="secondary">
{t(
recurringProforma.has_issue
? "recurring_proformas.list.issues.issue"
: "recurring_proformas.list.issues.none"
)}
</RdxBadge>
</span>
<p className="text-sm text-muted-foreground">{metadata}</p>
</div>
}
>
<div className="space-y-3 border-b bg-background px-4 py-4 sm:px-6">
<span className="text-sm text-muted-foreground">
{t("recurring_proformas.detail.context")}
</span>
<p className="text-sm text-muted-foreground">{metadata}</p>
</div>
</ProformaPageHeader>
/>
<div className="grid gap-6 xl:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{t("recurring_proformas.detail.general.title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{recurringProforma.description ? (
<p className="text-sm leading-6 text-muted-foreground">
{recurringProforma.description}
</p>
) : null}
<dl className="space-y-3">
<DetailField
label={t("recurring_proformas.detail.customer")}
value={recurringProforma.customer_id ?? t("recurring_proformas.detail.no_customer")}
/>
</dl>
</CardContent>
</Card>
<RdxPageContainer className="min-h-0 flex-1 overflow-y-auto" padding="none" width="wide">
<RdxEditorPageLayout width="simple">
<RecurringProformaOperationalSummary
locale={dateLocale}
recurringProforma={recurringProforma}
/>
</RdxEditorPageLayout>
<Card>
<CardHeader>
<CardTitle>{t("recurring_proformas.detail.schedule.title")}</CardTitle>
</CardHeader>
<CardContent>
{schedule ? (
<div className="grid gap-6 xl:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{t("recurring_proformas.detail.general.title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{recurringProforma.description ? (
<p className="text-sm leading-6 text-muted-foreground">
{recurringProforma.description}
</p>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("recurring_proformas.detail.schedule.title")}</CardTitle>
</CardHeader>
<CardContent>
{schedule ? (
<dl className="space-y-3">
<DetailField
label={t("recurring_proformas.detail.schedule.interval")}
value={mainView.scheduleLabel}
/>
<DetailField
label={t("recurring_proformas.detail.schedule.start_date")}
value={formatRecurringProformaDateOnly(schedule.start_date)}
/>
<DetailField
label={t("recurring_proformas.detail.schedule.end_date")}
value={
schedule.end_date
? formatRecurringProformaDateOnly(schedule.end_date)
: t("recurring_proformas.detail.schedule.no_end_date")
}
/>
<DetailField
label={t("recurring_proformas.detail.schedule.monthly_rule")}
value={
schedule.monthly_rule
? t(`recurring_proformas.list.schedule.${schedule.monthly_rule}`)
: t("recurring_proformas.detail.schedule.not_applicable")
}
/>
</dl>
) : (
<p className="text-sm text-muted-foreground">
{t("recurring_proformas.list.schedule.none")}
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("recurring_proformas.detail.commercial.title")}</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
<DetailField
label={t("recurring_proformas.detail.schedule.interval")}
value={mainView.scheduleLabel}
label={t("recurring_proformas.detail.commercial.payment_term")}
value={formatRecurringProformaOverride(recurringProforma.payment_term_id, t)}
/>
<DetailField
label={t("recurring_proformas.detail.schedule.start_date")}
value={formatRecurringProformaDateOnly(schedule.start_date)}
label={t("recurring_proformas.detail.commercial.payment_method")}
value={formatRecurringProformaOverride(recurringProforma.payment_method_id, t)}
/>
<DetailField
label={t("recurring_proformas.detail.schedule.end_date")}
value={
schedule.end_date
? formatRecurringProformaDateOnly(schedule.end_date)
: t("recurring_proformas.detail.schedule.no_end_date")
}
label={t("recurring_proformas.detail.commercial.language")}
value={formatRecurringProformaOverride(recurringProforma.language, t)}
/>
<DetailField
label={t("recurring_proformas.detail.schedule.monthly_rule")}
value={
schedule.monthly_rule
? t(`recurring_proformas.list.schedule.${schedule.monthly_rule}`)
: t("recurring_proformas.detail.schedule.not_applicable")
}
label={t("recurring_proformas.detail.commercial.currency")}
value={formatRecurringProformaOverride(recurringProforma.currency, t)}
/>
<DetailField
label={t("recurring_proformas.detail.commercial.proforma_series")}
value={formatRecurringProformaOverride(recurringProforma.proforma_series, t)}
/>
<DetailField
label={t("recurring_proformas.detail.commercial.issued_invoice_series")}
value={formatRecurringProformaOverride(
recurringProforma.issued_invoice_series,
t
)}
/>
{discount ? (
<DetailField
label={t("recurring_proformas.detail.commercial.global_discount")}
value={discount}
/>
) : null}
</dl>
) : (
<p className="text-sm text-muted-foreground">
{t("recurring_proformas.list.schedule.none")}
</p>
)}
</CardContent>
</Card>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("recurring_proformas.detail.commercial.title")}</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
<DetailField
label={t("recurring_proformas.detail.commercial.payment_term")}
value={formatRecurringProformaOverride(recurringProforma.payment_term_id, t)}
/>
<DetailField
label={t("recurring_proformas.detail.commercial.payment_method")}
value={formatRecurringProformaOverride(recurringProforma.payment_method_id, t)}
/>
<DetailField
label={t("recurring_proformas.detail.commercial.language")}
value={formatRecurringProformaOverride(recurringProforma.language, t)}
/>
<DetailField
label={t("recurring_proformas.detail.commercial.currency")}
value={formatRecurringProformaOverride(recurringProforma.currency, t)}
/>
<DetailField
label={t("recurring_proformas.detail.commercial.proforma_series")}
value={formatRecurringProformaOverride(recurringProforma.proforma_series, t)}
/>
<DetailField
label={t("recurring_proformas.detail.commercial.issued_invoice_series")}
value={formatRecurringProformaOverride(recurringProforma.issued_invoice_series, t)}
/>
{discount ? (
<DetailField
label={t("recurring_proformas.detail.commercial.global_discount")}
value={discount}
/>
) : null}
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("recurring_proformas.detail.items.title")}</CardTitle>
</CardHeader>
<CardContent>
{recurringProforma.items.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("recurring_proformas.detail.items.empty")}
</p>
) : (
<ol className="space-y-3">
{recurringProforma.items.map((item) => (
<li className="flex gap-3 text-sm" key={item.id}>
<span className="tabular-nums text-muted-foreground">{item.position}</span>
<span>
{item.description ?? t("recurring_proformas.detail.items.no_description")}
</span>
</li>
))}
</ol>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>{t("recurring_proformas.detail.items.title")}</CardTitle>
</CardHeader>
<CardContent>
{recurringProforma.items.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("recurring_proformas.detail.items.empty")}
</p>
) : (
<ol className="space-y-3">
{recurringProforma.items.map((item) => (
<li className="flex gap-3 text-sm" key={item.id}>
<span className="tabular-nums text-muted-foreground">{item.position}</span>
<span>
{item.description ?? t("recurring_proformas.detail.items.no_description")}
</span>
</li>
))}
</ol>
)}
</CardContent>
</Card>
</div>
<RecurringProformaOccurrencesSection recurringProformaId={recurringProforma.id} />
<RecurringProformaOccurrencesSection recurringProformaId={recurringProforma.id} />
<Button onClick={() => navigate("/recurring-proformas")} type="button" variant="outline">
<ArrowLeftIcon aria-hidden className="size-4" />
{t("recurring_proformas.detail.back")}
</Button>
<Button onClick={() => navigate("/recurring-proformas")} type="button" variant="outline">
<ArrowLeftIcon aria-hidden className="size-4" />
{t("recurring_proformas.detail.back")}
</Button>
</RdxPageContainer>
</RdxPageStack>
);
};

View File

@ -1,3 +1,3 @@
export * from "./create";
export * from "./detail";
export * from "./list/list-recurring-proformas-page";
export * from "./list";

View File

@ -0,0 +1,2 @@
export * from "./list-recurring-proformas-page";
export * from "./recurring-proforma-list-utils";

View File

@ -141,11 +141,14 @@ mock.module("@repo/rdx-ui/components", {
</header>
),
RdxPageActions: ({ primary }: { primary: ReactNode }) => <div>{primary}</div>,
RdxBadge: ({ children }: { children: ReactNode }) => <span>{children}</span>,
RdxRowActions: () => <span>Acciones de fila</span>,
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
DataTable: (props: DataTableProps) => {
capturedDataTableProps = props;
return <section>{props.topContent}</section>;
},
SkeletonDataTable: () => <section>Cargando tabla</section>,
},
});
@ -234,6 +237,17 @@ test("usa PageErrorState para un error inicial bloqueante", async () => {
assert.doesNotMatch(html, /Buscador/);
});
test("muestra un skeleton sin montar DataTable durante la carga inicial", async () => {
controllerState = createControllerState();
controllerState.listCtrl.isLoading = true;
capturedDataTableProps = undefined;
const html = await renderPage();
assert.match(html, /Cargando tabla/);
assert.equal(capturedDataTableProps, undefined);
});
test("conecta CTA, detalle, búsqueda, filtro y paginación con el controller", async () => {
events = {
create: false,

View File

@ -2,11 +2,13 @@ import { PageErrorState, SimpleSearchInput } from "@erp/core/components";
import {
DataTable,
DataTableColumnHeader,
RdxBadge,
RdxPageContainer,
type RdxRowAction,
RdxRowActions,
SkeletonDataTable,
} from "@repo/rdx-ui/components";
import { Badge, Button } from "@repo/shadcn-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import {
ArchiveIcon,
@ -21,6 +23,7 @@ import { useCallback, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "../../i18n";
import { getRecurringProformaStatusBadgeClassName } from "../shared/helpers/recurring-proforma-status-ui";
import {
type RecurringProformaAction,
useRecurringProformaActionMutation,
@ -161,7 +164,12 @@ export const ListRecurringProformasPage = () => {
/>
),
cell: ({ row }) => (
<Badge variant="secondary">{t(getRecurringProformaStatusKey(row.original.status))}</Badge>
<RdxBadge
className={getRecurringProformaStatusBadgeClassName(row.original.status)}
variant="secondary"
>
{t(getRecurringProformaStatusKey(row.original.status))}
</RdxBadge>
),
},
{
@ -278,45 +286,57 @@ export const ListRecurringProformasPage = () => {
<ScopedListRecurringProformasPageView
content={
<div className="mx-auto w-full space-y-4">
<DataTable
columns={columns}
data={listCtrl.data.items}
enablePagination
isFetching={listCtrl.isFetching}
loading={listCtrl.isLoading}
manualPagination
manualSorting
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}
onRowClick={(row) => openDetailPage(row.id)}
onSortChange={listCtrl.setSort}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
searching={isSearchPending}
sort={listCtrl.sort}
topContent={
<div className="space-y-5">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="w-full">
<SimpleSearchInput
loading={listCtrl.isLoading}
onPendingChange={setIsSearchPending}
onSearchChange={listCtrl.setSearchValue}
placeholder={t("recurring_proformas.list.search_placeholder")}
value={listCtrl.search}
/>
{listCtrl.isLoading ? (
<SkeletonDataTable
columns={columns.length}
footerProps={{
pageIndex: listCtrl.pageIndex,
pageSize: listCtrl.pageSize,
totalItems: listCtrl.data.totalItems,
}}
rows={Math.max(6, listCtrl.pageSize)}
showFooter
/>
) : (
<DataTable
columns={columns}
data={listCtrl.data.items}
enablePagination
isFetching={listCtrl.isFetching}
isSearching={isSearchPending}
manualPagination
manualSorting
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}
onRowClick={(row) => openDetailPage(row.id)}
onSortChange={listCtrl.setSort}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
sort={listCtrl.sort}
topContent={
<div className="space-y-5">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="w-full">
<SimpleSearchInput
loading={listCtrl.isLoading}
onPendingChange={setIsSearchPending}
onSearchChange={listCtrl.setSearchValue}
placeholder={t("recurring_proformas.list.search_placeholder")}
value={listCtrl.search}
/>
</div>
</div>
</div>
<RecurringProformaStatusSegmentedFilter
disabled={listCtrl.isLoading}
onValueChange={listCtrl.setStatusFilter}
value={listCtrl.statusFilter}
/>
</div>
}
totalItems={listCtrl.data.totalItems}
/>
<RecurringProformaStatusSegmentedFilter
disabled={listCtrl.isLoading}
onValueChange={listCtrl.setStatusFilter}
value={listCtrl.statusFilter}
/>
</div>
}
totalItems={listCtrl.data.totalItems}
/>
)}
{listCtrl.data.totalItems === 0 ? (
<div className="rounded border border-dashed border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">

View File

@ -0,0 +1 @@
export * from './recurring-proforma-status-segmented-filter';

View File

@ -1,10 +1,13 @@
import { ToggleGroup, ToggleGroupItem } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { CheckCircle2Icon, CirclePauseIcon, FileTextIcon, PlayCircleIcon } from "lucide-react";
import type { ElementType } from "react";
import { useTranslation } from "../../../../i18n";
import type { RecurringProformaStatus } from "../../../shared/recurring-proforma-list";
import type { RecurringProformaStatus } from "../../../shared";
import {
getRecurringProformaStatusIcon,
getRecurringProformaStatusSegmentedFilterActiveClassName,
} from "../../../shared/helpers/recurring-proforma-status-ui";
export type RecurringProformaStatusFilter = "all" | RecurringProformaStatus;
@ -22,34 +25,12 @@ const RECURRING_PROFORMA_STATUS_FILTER_OPTIONS = [
activeClassName:
"data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground",
},
{
value: "draft",
labelKey: "recurring_proformas.status.draft",
icon: FileTextIcon,
activeClassName:
"data-[state=on]:border-slate-300 data-[state=on]:bg-slate-100 data-[state=on]:text-slate-700",
},
{
value: "active",
labelKey: "recurring_proformas.status.active",
icon: PlayCircleIcon,
activeClassName:
"data-[state=on]:border-emerald-300 data-[state=on]:bg-emerald-50 data-[state=on]:text-emerald-700",
},
{
value: "paused",
labelKey: "recurring_proformas.status.paused",
icon: CirclePauseIcon,
activeClassName:
"data-[state=on]:border-amber-300 data-[state=on]:bg-amber-50 data-[state=on]:text-amber-700",
},
{
value: "completed",
labelKey: "recurring_proformas.status.completed",
icon: CheckCircle2Icon,
activeClassName:
"data-[state=on]:border-sky-300 data-[state=on]:bg-sky-50 data-[state=on]:text-sky-700",
},
...(["draft", "active", "paused", "completed"] as const).map((value) => ({
value,
labelKey: `recurring_proformas.status.${value}`,
icon: getRecurringProformaStatusIcon(value),
activeClassName: getRecurringProformaStatusSegmentedFilterActiveClassName(value),
})),
] as const satisfies readonly {
value: RecurringProformaStatusFilter;
labelKey: string;
@ -90,7 +71,7 @@ export const RecurringProformaStatusSegmentedFilter = ({
{RECURRING_PROFORMA_STATUS_FILTER_OPTIONS.map((option) => (
<ToggleGroupItem
className={cn(
"group h-auto min-w-fit rounded-full border-transparent px-3 py-2 text-xs shadow-none transition-colors sm:text-sm",
"group h-auto min-w-fit rounded-md border-transparent px-3 py-2 text-xs shadow-none transition-colors sm:text-sm",
"text-muted-foreground hover:border-border hover:bg-muted/60 hover:text-foreground",
"data-[state=on]:shadow-none",
option.activeClassName

View File

@ -0,0 +1,2 @@
export * from "./components";
export * from "./pages";

View File

@ -0,0 +1 @@
export * from './scoped-list-recurring-proformas-page-view';

View File

@ -0,0 +1 @@
export * from "./list-recurring-proformas.adapter";

View File

@ -0,0 +1,5 @@
export * from "./create-recurring-proforma.api";
export * from "./create-recurring-proforma-from-proforma.api";
export * from "./get-recurring-proforma.api";
export * from "./list-recurring-proforma-occurrences.api";
export * from "./list-recurring-proformas.api";

View File

@ -0,0 +1 @@
export * from "./recurring-proforma-status.entity";

View File

@ -0,0 +1,14 @@
/**
* Enumeración que representa
* los posibles estados de una proforma recurrente en el sistema.
*/
export enum RECURRING_PROFORMA_STATUS {
DRAFT = "draft",
ACTIVE = "active",
PAUSED = "paused",
COMPLETED = "completed",
ARCHIVED = "archived",
}
export type RecurringProformaStatus = `${RECURRING_PROFORMA_STATUS}`;

View File

@ -0,0 +1 @@
export * from "./recurring-proforma-status-ui";

View File

@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getRecurringProformaStatusBadgeClassName,
getRecurringProformaStatusIcon,
getRecurringProformaStatusSegmentedFilterActiveClassName,
} from "./recurring-proforma-status-ui";
test("centraliza icono y colores para todos los estados de recurrencia", () => {
for (const status of ["draft", "active", "paused", "completed", "archived"] as const) {
assert.ok(getRecurringProformaStatusIcon(status));
assert.match(getRecurringProformaStatusBadgeClassName(status), /border-/);
assert.match(
getRecurringProformaStatusSegmentedFilterActiveClassName(status),
/data-\[state=on\]/
);
}
});

View File

@ -0,0 +1,62 @@
import {
ArchiveIcon,
CheckCircle2Icon,
CirclePauseIcon,
FileTextIcon,
PlayCircleIcon,
} from "lucide-react";
import type { ElementType } from "react";
import type { RecurringProformaStatus } from "../entities";
type RecurringProformaStatusUI = {
icon: ElementType;
badgeClassName: string;
segmentedFilterActiveClassName: string;
};
export const RECURRING_PROFORMA_STATUS_UI: Record<
RecurringProformaStatus,
RecurringProformaStatusUI
> = {
draft: {
icon: FileTextIcon,
badgeClassName: "border-slate-300 bg-slate-100 text-slate-700",
segmentedFilterActiveClassName:
"data-[state=on]:border-slate-300 data-[state=on]:bg-slate-100 data-[state=on]:text-slate-700",
},
active: {
icon: PlayCircleIcon,
badgeClassName: "border-emerald-300 bg-emerald-50 text-emerald-700",
segmentedFilterActiveClassName:
"data-[state=on]:border-emerald-300 data-[state=on]:bg-emerald-50 data-[state=on]:text-emerald-700",
},
paused: {
icon: CirclePauseIcon,
badgeClassName: "border-amber-300 bg-amber-50 text-amber-700",
segmentedFilterActiveClassName:
"data-[state=on]:border-amber-300 data-[state=on]:bg-amber-50 data-[state=on]:text-amber-700",
},
completed: {
icon: CheckCircle2Icon,
badgeClassName: "border-sky-300 bg-sky-50 text-sky-700",
segmentedFilterActiveClassName:
"data-[state=on]:border-sky-300 data-[state=on]:bg-sky-50 data-[state=on]:text-sky-700",
},
archived: {
icon: ArchiveIcon,
badgeClassName: "border-slate-300 bg-slate-50 text-slate-700",
segmentedFilterActiveClassName:
"data-[state=on]:border-slate-300 data-[state=on]:bg-slate-50 data-[state=on]:text-slate-700",
},
};
export const getRecurringProformaStatusBadgeClassName = (status: RecurringProformaStatus) =>
RECURRING_PROFORMA_STATUS_UI[status].badgeClassName;
export const getRecurringProformaStatusIcon = (status: RecurringProformaStatus) =>
RECURRING_PROFORMA_STATUS_UI[status].icon;
export const getRecurringProformaStatusSegmentedFilterActiveClassName = (
status: RecurringProformaStatus
) => RECURRING_PROFORMA_STATUS_UI[status].segmentedFilterActiveClassName;

View File

@ -0,0 +1,7 @@
export * from './recurring-proforma-query-keys';
export * from './use-create-recurring-proforma-from-proforma';
export * from './use-create-recurring-proforma';
export * from './use-recurring-proforma-action-mutation';
export * from './use-recurring-proforma-occurrences';
export * from './use-recurring-proforma';
export * from './use-recurring-proformas';

View File

@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { recurringProformaQueryKeys } from "./recurring-proforma-query-keys";
export type RecurringProformaAction =
| "edit"
| "activate"
| "pause"
| "resume"

View File

@ -0,0 +1,9 @@
export * from "./adapters";
export * from "./api";
export * from "./entities";
export * from "./helpers";
export * from "./hooks";
export * from "./recurring-proforma-list";
export * from "./recurring-proforma-navigation";
export * from "./recurring-proforma-schedule-summary";
export * from "./ui";

View File

@ -1,4 +1,4 @@
export type RecurringProformaStatus = "draft" | "active" | "paused" | "completed" | "archived";
import type { RecurringProformaStatus } from "./entities";
export type RecurringProformaListFilters = {
page: number;
@ -86,6 +86,11 @@ export type RecurringProformaDetail = {
name: string;
status: RecurringProformaStatus;
customer_id: string | null;
customer: {
id: string;
name: string;
tin: string | null;
} | null;
description: string | null;
payment_term_id: string | null;
payment_method_id: string | null;
@ -96,6 +101,7 @@ export type RecurringProformaDetail = {
global_discount_percentage: RecurringProformaPercentage | null;
schedule: RecurringProformaListItemDTO["schedule"];
next_occurrence_date: string | null;
has_issue: boolean;
items: RecurringProformaDetailItem[];
created_at: string;
updated_at: string;

View File

@ -44,7 +44,7 @@ export const CustomerCreatePage = () => {
/>
<div className="flex-1 overflow-y-auto bg-muted/20">
<RdxEditorPageLayout complexity="simple">
<RdxEditorPageLayout width="simple">
<div className="rounded-2xl border border-border/60 bg-background px-4 py-4 shadow-xs md:px-6 md:py-6">
<div className="flex min-w-0 flex-col gap-6">
{createCtrl.isCreateError ? (

View File

@ -1,5 +1,4 @@
import { InitialsAvatar } from "@repo/rdx-ui/components";
import { Badge } from "@repo/shadcn-ui/components";
import { Building2Icon, CopyIcon, UserIcon } from "lucide-react";
import type { Customer } from "../../../../shared";
@ -21,9 +20,9 @@ export const CustomerHeader = ({ customer }: { customer: Customer }) => {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-lg font-semibold">{customer.name}</h3>
<Badge variant={customer.status === "active" ? "default" : "secondary"}>
<RdxBadge variant={customer.status === "active" ? "default" : "secondary"}>
{customer.status === "active" ? "Activo" : "Inactivo"}
</Badge>
</RdxBadge>
</div>
{customer.tradeName && (
@ -31,7 +30,7 @@ export const CustomerHeader = ({ customer }: { customer: Customer }) => {
)}
<div className="mt-2 flex items-center gap-2">
<Badge className="gap-1" variant="outline">
<RdxBadge className="gap-1" variant="outline">
{customer.isCompany ? (
<>
<Building2Icon className="size-3" />
@ -43,7 +42,7 @@ export const CustomerHeader = ({ customer }: { customer: Customer }) => {
Particular
</>
)}
</Badge>
</RdxBadge>
<button
className="flex items-center gap-1 text-xs font-mono text-muted-foreground hover:text-foreground"

View File

@ -1,26 +1,27 @@
import { RdxBadge } from "@repo/rdx-ui/components";
import { DateHelper } from "@repo/rdx-utils";
import { Badge, Button } from "@repo/shadcn-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { FileTextIcon } from "lucide-react";
const getInvoiceStatusBadge = (status: "paid" | "pending" | "overdue") => {
switch (status) {
case "paid":
return (
<Badge className="bg-emerald-50 text-emerald-700 border-emerald-200" variant="outline">
<RdxBadge className="bg-emerald-50 text-emerald-700 border-emerald-200" variant="outline">
Pagada
</Badge>
</RdxBadge>
);
case "pending":
return (
<Badge className="bg-amber-50 text-amber-700 border-amber-200" variant="outline">
<RdxBadge className="bg-amber-50 text-amber-700 border-amber-200" variant="outline">
Pendiente
</Badge>
</RdxBadge>
);
case "overdue":
return (
<Badge className="bg-red-50 text-red-700 border-red-200" variant="outline">
<RdxBadge className="bg-red-50 text-red-700 border-red-200" variant="outline">
Vencida
</Badge>
</RdxBadge>
);
}
};

View File

@ -1,10 +1,11 @@
import {
DataTableColumnHeader,
InitialsAvatar,
RdxRowActions,
RdxBadge,
type RdxRowAction,
RdxRowActions,
} from "@repo/rdx-ui/components";
import { Badge, Checkbox } from "@repo/shadcn-ui/components";
import { Checkbox } from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import { Building2Icon, EyeIcon, PencilIcon, Trash2Icon, UserIcon } from "lucide-react";
import * as React from "react";
@ -97,7 +98,7 @@ export function useCustomersGridColumns(
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-mono">{customer.tin}</span>
<Badge className="gap-1 px-1.5 py-0 text-xs font-normal" variant="outline">
<RdxBadge className="gap-1 px-1.5 py-0 text-xs font-normal" variant="outline">
{customer.isCompany ? (
<>
<Building2Icon className="size-3" />
@ -109,7 +110,7 @@ export function useCustomersGridColumns(
{t("fields.customer_type.options.individual")}
</>
)}
</Badge>
</RdxBadge>
</div>
</div>
</div>

View File

@ -1,9 +1,8 @@
import { Badge } from "@repo/shadcn-ui/components";
import { Building2Icon, User2Icon } from "lucide-react";
export const KindBadge = ({ isCompany }: { isCompany: boolean }) => (
<Badge className="gap-1 tracking-wide text-xs text-foreground/70" variant="outline">
<RdxBadge className="gap-1 tracking-wide text-xs text-foreground/70" variant="outline">
{isCompany ? <Building2Icon className="size-3.5" /> : <User2Icon className="size-3.5" />}
{isCompany ? "Company" : "Person"}
</Badge>
</RdxBadge>
);

View File

@ -93,26 +93,32 @@ export const CustomerUpdatePage = () => {
/>
<div className="flex-1 overflow-y-auto bg-muted/20">
<RdxEditorPageLayout complexity="simple">
<RdxEditorPageLayout width="simple">
<div className="rounded-2xl border border-border/60 bg-background px-4 py-4 shadow-xs md:px-6 md:py-6">
<div className="flex min-w-0 flex-col gap-6">
{updateCtrl.isUpdateError ? (
<ErrorAlert
message={
(updateCtrl.updateError as Error)?.message ??
t("pages.customers.update.error_msg", "Revisa los datos e inténtalo de nuevo.")
}
title={t("pages.customers.update.error_title", "No se pudo guardar los cambios")}
/>
) : null}
{updateCtrl.isUpdateError ? (
<ErrorAlert
message={
(updateCtrl.updateError as Error)?.message ??
t(
"pages.customers.update.error_msg",
"Revisa los datos e inténtalo de nuevo."
)
}
title={t(
"pages.customers.update.error_title",
"No se pudo guardar los cambios"
)}
/>
) : null}
<CustomerUpdateEditorForm
fiscalCtrl={updateCtrl.fiscalCtrl}
formId={updateCtrl.formId}
isSubmitting={updateCtrl.isUpdating}
onReset={updateCtrl.resetForm}
onSubmit={updateCtrl.onSubmit}
/>
<CustomerUpdateEditorForm
fiscalCtrl={updateCtrl.fiscalCtrl}
formId={updateCtrl.formId}
isSubmitting={updateCtrl.isUpdating}
onReset={updateCtrl.resetForm}
onSubmit={updateCtrl.onSubmit}
/>
</div>
</div>
</RdxEditorPageLayout>

View File

@ -1,4 +1,4 @@
import { ErrorAlert, PageFormHeader, PageErrorState } from "@erp/core/components";
import { ErrorAlert, PageErrorState, PageFormHeader } from "@erp/core/components";
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
import {
CancelActionButton,
@ -29,7 +29,7 @@ import type { AccountPasswordForm, AccountProfileForm } from "../../entities";
const AccountLoadingState = ({ label }: { label: string }) => {
return (
<div className="flex-1 overflow-y-auto bg-muted/20">
<RdxEditorPageLayout className="min-h-full" complexity="simple">
<RdxEditorPageLayout className="min-h-full" width="simple">
<div className="flex min-h-[16rem] items-center justify-center text-sm text-muted-foreground">
<div aria-busy="true" role="status">
{label}
@ -63,7 +63,7 @@ const AccountErrorState = ({
}) => {
return (
<div className="flex-1 overflow-y-auto bg-muted/20">
<RdxEditorPageLayout complexity="simple">
<RdxEditorPageLayout width="simple">
<div className="mx-auto w-full max-w-3xl">
<PageErrorState
description={description}
@ -227,8 +227,8 @@ export const AccountPage = () => {
isRetrying={ctrl.isRetrying}
onRetry={ctrl.retry}
reference={ctrl.pageError.reference}
retryLabel={t("account.pageError.actions.retry")}
retryingLabel={t("account.pageError.actions.retrying")}
retryLabel={t("account.pageError.actions.retry")}
title={ctrl.pageError.title}
variant={ctrl.pageError.variant}
/>
@ -259,7 +259,7 @@ export const AccountPage = () => {
/>
<div className="flex-1 overflow-y-auto bg-muted/20">
<RdxEditorPageLayout complexity="simple">
<RdxEditorPageLayout width="simple">
<div className="rounded-2xl border border-border/60 bg-background px-4 py-4 shadow-xs md:px-6 md:py-6">
<div className="flex min-w-0 flex-col gap-6">
<FormProvider {...ctrl.profileForm}>
@ -311,22 +311,30 @@ export const AccountPage = () => {
<SelectField<AccountProfileForm>
className="md:col-span-12"
description={t("account.sections.personal.fields.languageCode.description")}
description={t(
"account.sections.personal.fields.languageCode.description"
)}
disabled={ctrl.isSavingProfile}
inputClassName="bg-background"
items={[
{
value: "es",
label: t("account.sections.personal.fields.languageCode.options.es"),
label: t(
"account.sections.personal.fields.languageCode.options.es"
),
},
{
value: "en",
label: t("account.sections.personal.fields.languageCode.options.en"),
label: t(
"account.sections.personal.fields.languageCode.options.en"
),
},
]}
label={t("account.sections.personal.fields.languageCode.label")}
name="languageCode"
placeholder={t("account.sections.personal.fields.languageCode.placeholder")}
placeholder={t(
"account.sections.personal.fields.languageCode.placeholder"
)}
/>
</div>
</RdxEditorSection>
@ -367,7 +375,9 @@ export const AccountPage = () => {
>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h2 className="text-base font-medium">{t("account.sections.security.passwordTitle")}</h2>
<h2 className="text-base font-medium">
{t("account.sections.security.passwordTitle")}
</h2>
<p className="text-sm text-muted-foreground">
{t("account.sections.security.passwordDescription")}
</p>

View File

@ -35,7 +35,7 @@ export const SupplierCreatePage = () => {
title={t("create.title")}
/>
<div className="flex-1 overflow-y-auto bg-muted/20">
<RdxEditorPageLayout complexity="simple">
<RdxEditorPageLayout width="simple">
<div className="rounded-2xl border border-border/60 bg-background p-4 shadow-xs md:p-6">
<div className="flex min-w-0 flex-col gap-6">
{controller.mutation.isError ? (

View File

@ -7,7 +7,6 @@ import {
RdxRowActions,
SkeletonDataTable,
} from "@repo/rdx-ui/components";
import { Badge } from "@repo/shadcn-ui/components";
import type { ColumnDef, OnChangeFn, VisibilityState } from "@tanstack/react-table";
import { Building2Icon, EyeIcon, MailIcon, PencilIcon, PhoneIcon, UserIcon } from "lucide-react";
import { type ReactNode, useMemo } from "react";
@ -48,14 +47,14 @@ export const useSuppliersGridColumns = ({
<span className="block truncate font-medium text-foreground">{supplier.name}</span>
<span className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{supplier.tradeName || supplier.tin || "-"}
<Badge className="gap-1 px-1.5 py-0 font-normal" variant="outline">
<RdxBadge className="gap-1 px-1.5 py-0 font-normal" variant="outline">
{supplier.isCompany ? (
<Building2Icon aria-hidden className="size-3" />
) : (
<UserIcon aria-hidden className="size-3" />
)}
{t(supplier.isCompany ? "fields.company" : "fields.individual")}
</Badge>
</RdxBadge>
</span>
</span>
</button>

View File

@ -1,5 +1,3 @@
import { Badge } from "@repo/shadcn-ui/components";
import { useTranslation } from "../../i18n";
export const SupplierStatusBadge = ({ status }: { status: string }) => {
@ -7,8 +5,8 @@ export const SupplierStatusBadge = ({ status }: { status: string }) => {
const isActive = status.toLowerCase() === "active";
return (
<Badge variant={isActive ? "default" : "secondary"}>
<RdxBadge variant={isActive ? "default" : "secondary"}>
{t(isActive ? "status.active" : "status.inactive")}
</Badge>
</RdxBadge>
);
};

View File

@ -57,7 +57,7 @@ export const SupplierUpdatePage = () => {
title={t("update.title")}
/>
<div className="flex-1 overflow-y-auto bg-muted/20">
<RdxEditorPageLayout complexity="simple">
<RdxEditorPageLayout width="simple">
<div className="rounded-2xl border border-border/60 bg-background p-4 shadow-xs md:p-6">
<div className="flex min-w-0 flex-col gap-6">
{controller.mutation.isError ? (

View File

@ -46,6 +46,7 @@
"vite-tsconfig-paths": "^6.1.1"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",

View File

@ -1,5 +1,4 @@
import {
Badge,
Button,
Command,
CommandEmpty,
@ -18,6 +17,8 @@ import type { Column } from "@tanstack/react-table";
import { Check, PlusCircleIcon } from "lucide-react";
import type * as React from "react";
import { RdxBadge } from "../rdx-badge.tsx";
interface DataTableFacetedFilterProps<TData, TValue> {
column?: Column<TData, TValue>;
title?: string;
@ -45,25 +46,25 @@ export function DataTableFacetedFilter<TData, TValue>({
{selectedValues?.size > 0 && (
<>
<Separator className="mx-2 h-4" orientation="vertical" />
<Badge className="rounded-sm px-1 font-normal lg:hidden" variant="secondary">
<RdxBadge className="rounded-sm px-1 font-normal lg:hidden" variant="secondary">
{selectedValues.size}
</Badge>
</RdxBadge>
<div className="hidden gap-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge className="rounded-sm px-1 font-normal" variant="secondary">
<RdxBadge className="rounded-sm px-1 font-normal" variant="secondary">
{selectedValues.size} selected
</Badge>
</RdxBadge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
<RdxBadge
className="rounded-sm px-1 font-normal"
key={option.value}
variant="secondary"
>
{option.label}
</Badge>
</RdxBadge>
))
)}
</div>

View File

@ -24,7 +24,7 @@ test("RdxEditorPageLayout aplica complejidad simple por defecto y renderiza chil
test("RdxEditorPageLayout soporta aside opcional y complejidad complex", () => {
const html = renderToStaticMarkup(
<RdxEditorPageLayout aside={<div>Aside content</div>} complexity="complex">
<RdxEditorPageLayout aside={<div>Aside content</div>} width="full">
<div>Main content</div>
</RdxEditorPageLayout>
);
@ -37,7 +37,7 @@ test("RdxEditorPageLayout soporta aside opcional y complejidad complex", () => {
test("RdxEditorPageLayout no reserva aside cuando no existe incluso en complex", () => {
const html = renderToStaticMarkup(
<RdxEditorPageLayout complexity="complex">
<RdxEditorPageLayout width="full">
<div>Main content</div>
</RdxEditorPageLayout>
);

View File

@ -1,38 +1,41 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
export type RdxEditorComplexity = "simple" | "complex";
export type RdxEditorWidth = "wide" | "full";
export interface RdxEditorPageLayoutProps
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
children: ReactNode;
aside?: ReactNode;
complexity?: RdxEditorComplexity;
width?: RdxEditorWidth;
}
const CONTENT_WIDTH_CLASSNAME: Record<RdxEditorComplexity, string> = {
simple: "mx-auto w-full max-w-5xl",
complex: "w-full",
const CONTENT_WIDTH_CLASSNAME: Record<RdxEditorWidth, string> = {
wide: "mx-auto w-full max-w-5xl",
full: "w-full",
};
const ASIDE_GRID_CLASSNAME: Record<RdxEditorComplexity, string> = {
simple: "xl:grid-cols-[minmax(0,1fr)_280px]",
complex: "xl:grid-cols-[minmax(0,1fr)_22rem]",
const ASIDE_GRID_CLASSNAME: Record<RdxEditorWidth, string> = {
wide: "xl:grid-cols-[minmax(0,1fr)_280px]",
full: "xl:grid-cols-[minmax(0,1fr)_22rem]",
};
export const RdxEditorPageLayout = ({
children,
aside,
complexity = "simple",
width = "wide",
className,
...props
}: RdxEditorPageLayoutProps) => {
const contentClassName = CONTENT_WIDTH_CLASSNAME[complexity];
const contentClassName = CONTENT_WIDTH_CLASSNAME[width];
return (
<div className={cn("min-w-0 w-full px-4 py-4 md:px-6 md:py-6", className)} {...props}>
<div
className={cn("rdx-editor-page-layout min-w-0 w-full px-4 py-4 md:px-6 md:py-6", className)}
{...props}
>
{aside ? (
<div className={cn(contentClassName, "grid min-w-0 gap-6", ASIDE_GRID_CLASSNAME[complexity])}>
<div className={cn(contentClassName, "grid min-w-0 gap-6", ASIDE_GRID_CLASSNAME[width])}>
<div className="min-w-0">{children}</div>
<aside className="min-w-0">{aside}</aside>
</div>

View File

@ -1,5 +1,4 @@
import {
Badge,
Button,
Command,
CommandEmpty,
@ -24,6 +23,7 @@ import * as React from "react";
import { type FieldPath, type FieldValues, useController, useFormContext } from "react-hook-form";
import { useTranslation } from "../../locales/i18n.ts";
import { RdxBadge } from "../rdx-badge.tsx";
import { FormFieldLabel } from "./form-field-label.tsx";
@ -182,20 +182,22 @@ export const MultiSelectField = <T extends FieldValues>({
const Icon = option?.icon;
return (
<Badge
<RdxBadge
className={cn(multiSelectFieldVariants({ variant }), badgeClassName)}
key={value}
>
{Icon ? <Icon className="mr-2 h-4 w-4" /> : null}
{option?.label}
</Badge>
</RdxBadge>
);
})}
{selectedValues.length > maxCount ? (
<Badge className={cn(multiSelectFieldVariants({ variant }), badgeClassName)}>
<RdxBadge
className={cn(multiSelectFieldVariants({ variant }), badgeClassName)}
>
+{selectedValues.length - maxCount}
</Badge>
</RdxBadge>
) : null}
</div>

View File

@ -15,6 +15,7 @@ export * from "./logo-verifactu.tsx";
export * from "./lookup-dialog/index.ts";
export * from "./multi-select.tsx";
export * from "./pdf-viewer.tsx";
export * from "./rdx-badge.tsx";
export * from "./right-panel/index.ts";
export * from "./row-actions/index.ts";
export * from "./scroll-to-top.tsx";

View File

@ -16,7 +16,7 @@ import {
SidebarMenuItem,
useSidebar,
} from "@repo/shadcn-ui/components/sidebar";
import { BadgeCheck, Bell, ChevronsUpDownIcon, CreditCard, LogOut, Sparkles } from "lucide-react";
import { Bell, ChevronsUpDownIcon, CreditCard, LogOut, Sparkles } from "lucide-react";
export function NavUser({
user,
@ -79,7 +79,7 @@ export function NavUser({
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
<RdxBadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>

View File

@ -4,8 +4,7 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react";
export type RdxPageWidth = "full" | "wide" | "content";
export type RdxPageContainerPadding = "default" | "none";
export interface RdxPageContainerProps
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
export interface RdxPageContainerProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
children: ReactNode;
width?: RdxPageWidth;
padding?: RdxPageContainerPadding;
@ -31,7 +30,12 @@ export const RdxPageContainer = ({
}: RdxPageContainerProps) => {
return (
<div
className={cn("min-w-0 w-full", WIDTH_CLASSNAME[width], PADDING_CLASSNAME[padding], className)}
className={cn(
"rdx-page-container min-w-0 w-full",
WIDTH_CLASSNAME[width],
PADDING_CLASSNAME[padding],
className
)}
{...props}
>
{children}

View File

@ -1,8 +1,10 @@
import { Badge, Button } from "@repo/shadcn-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ArrowLeftIcon } from "lucide-react";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { RdxBadge } from "../rdx-badge.tsx";
export interface RdxPageHeaderProps
extends Omit<ComponentPropsWithoutRef<"header">, "children" | "title"> {
title: ReactNode;
@ -72,14 +74,14 @@ export const RdxPageHeader = ({
</h1>
{statusLabel ? (
<Badge
<RdxBadge
className={cn(
"border-amber-500 bg-amber-200 text-amber-900 transition-opacity duration-200",
showStatus ? "opacity-100" : "pointer-events-none opacity-0"
)}
>
{statusLabel}
</Badge>
</RdxBadge>
) : null}
</div>
</div>

View File

@ -1,5 +1,4 @@
import {
Badge,
Button,
Command,
CommandEmpty,
@ -20,6 +19,8 @@ import * as React from "react";
import { useTranslation } from "../locales/i18n.ts";
import { RdxBadge } from "./rdx-badge.tsx";
/**
* Variants for the multi-select component to handle different styles.
* Uses class-variance-authority (cva) to define different styles based on "variant" prop.
@ -255,7 +256,7 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
const option = options.find((o) => o.value === value);
const IconComponent = option?.icon;
return (
<Badge
<RdxBadge
className={cn(
isAnimating ? "animate-bounce" : "",
multiSelectVariants({ variant })
@ -272,11 +273,11 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
toggleOption(value);
}}
/>*/}
</Badge>
</RdxBadge>
);
})}
{selectedValues.length > maxCount && (
<Badge
<RdxBadge
className={cn(
"bg-transparent text-foreground border-foreground/1 hover:bg-transparent",
isAnimating ? "animate-bounce" : "",
@ -292,7 +293,7 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
clearExtraOptions();
}}
/>
</Badge>
</RdxBadge>
)}
</div>
<div className="flex items-center justify-between">

View File

@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { RdxBadge } from "./rdx-badge.tsx";
test("renderiza el contenido sin recurrir al propio componente", () => {
const html = renderToStaticMarkup(<RdxBadge variant="secondary">Activa</RdxBadge>);
assert.match(html, /Activa/);
});

View File

@ -0,0 +1,11 @@
import type { useRender } from "@base-ui/react/use-render";
import { Badge, type badgeVariants } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { VariantProps } from "class-variance-authority";
export const RdxBadge = ({
className,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) => (
<Badge className={cn("tracking-wide px-4 h-6 rounded-md", className)} {...props} />
);

View File

@ -1,8 +1,7 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@repo/shadcn-ui/lib/utils"
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { type VariantProps, cva } from "class-variance-authority";
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
@ -10,14 +9,11 @@ const badgeVariants = cva(
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
@ -25,7 +21,7 @@ const badgeVariants = cva(
variant: "default",
},
}
)
);
function Badge({
className,
@ -46,7 +42,7 @@ function Badge({
slot: "badge",
variant,
},
})
});
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View File

@ -1468,6 +1468,9 @@ importers:
packages/rdx-ui:
dependencies:
'@base-ui/react':
specifier: ^1.3.0
version: 1.5.0(@date-fns/tz@1.5.0)(@types/react@19.2.15)(date-fns@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)