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

View File

@ -1,3 +1,4 @@
import { RdxBadge } from "@repo/rdx-ui/components";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -8,7 +9,6 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
Badge,
Button, Button,
ButtonGroup, ButtonGroup,
Card, Card,
@ -218,9 +218,9 @@ export default function ShadcnShowcasePage() {
</FieldGroup> </FieldGroup>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex gap-2"> <div className="flex gap-2">
<Badge>Badge</Badge> <RdxBadge>Badge</RdxBadge>
<Badge variant="secondary">Secondary</Badge> <RdxBadge variant="secondary">Secondary</RdxBadge>
<Badge variant="outline">Outline</Badge> <RdxBadge variant="outline">Outline</RdxBadge>
</div> </div>
<RadioGroup className="ml-auto flex w-fit gap-3" defaultValue="apple"> <RadioGroup className="ml-auto flex w-fit gap-3" defaultValue="apple">
<RadioGroupItem value="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 { PageErrorState } from "@erp/core/components";
import { RdxPageContainer } from "@repo/rdx-ui/components"; import { RdxBadge, RdxPageContainer } from "@repo/rdx-ui/components";
import { Badge, Button, Card, CardContent, RadioGroup, RadioGroupItem } from "@repo/shadcn-ui/components"; import { Button, Card, CardContent, RadioGroup, RadioGroupItem } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
@ -129,15 +129,15 @@ export const PaymentMethodsSettingsPage = () => {
</label> </label>
{paymentMethod.isSystem ? ( {paymentMethod.isSystem ? (
<Badge variant="secondary"> <RdxBadge variant="secondary">
{t("settings.paymentMethods.status.system")} {t("settings.paymentMethods.status.system")}
</Badge> </RdxBadge>
) : null} ) : null}
{paymentMethod.isDefault ? ( {paymentMethod.isDefault ? (
<Badge variant="outline"> <RdxBadge variant="outline">
{t("settings.paymentMethods.status.default")} {t("settings.paymentMethods.status.default")}
</Badge> </RdxBadge>
) : null} ) : null}
</div> </div>
@ -204,7 +204,9 @@ export const PaymentMethodsSettingsPage = () => {
isEditMode isEditMode
isSubmitting={featureCtrl.isSaving || featureCtrl.isEditingPaymentMethodLoading} isSubmitting={featureCtrl.isSaving || featureCtrl.isEditingPaymentMethodLoading}
onSubmit={featureCtrl.onSubmit} onSubmit={featureCtrl.onSubmit}
open={Boolean(featureCtrl.editingPaymentMethod) || featureCtrl.isEditingPaymentMethodLoading} open={
Boolean(featureCtrl.editingPaymentMethod) || featureCtrl.isEditingPaymentMethodLoading
}
openChange={(open) => { openChange={(open) => {
if (!open) { if (!open) {
featureCtrl.closeEditDialog(); 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 { import {
RdxBadge,
RdxFormGrid, RdxFormGrid,
SwitchField, SwitchField,
TextAreaField, TextAreaField,
TextField, TextField,
} from "@repo/rdx-ui/components"; } from "@repo/rdx-ui/components";
import { import {
Badge,
Button, Button,
Dialog, Dialog,
DialogContent, DialogContent,
@ -21,6 +16,10 @@ import {
Spinner, Spinner,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { PlusIcon, Trash2Icon } from "lucide-react"; 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 { useTranslation } from "../../../../i18n";
import type { PaymentTermForm } from "../../entities"; import type { PaymentTermForm } from "../../entities";
@ -74,10 +73,12 @@ export const PaymentTermFormDialog = ({
}); });
const dueRules = useMemo(() => { const dueRules = useMemo(() => {
return fields.map((field, index) => { return fields.map((field, index) => {
return watchedDueRules?.[index] ?? { return (
watchedDueRules?.[index] ?? {
dueDays: field.dueDays ?? "", dueDays: field.dueDays ?? "",
percentage: field.percentage ?? "", percentage: field.percentage ?? "",
}; }
);
}); });
}, [fields, watchedDueRules]); }, [fields, watchedDueRules]);
@ -140,13 +141,15 @@ export const PaymentTermFormDialog = ({
<section className="space-y-4"> <section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1"> <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"> <p className="text-sm text-muted-foreground">
{t("settings.paymentTerms.form.rules.description")} {t("settings.paymentTerms.form.rules.description")}
</p> </p>
</div> </div>
<Badge <RdxBadge
className={`border px-3 py-1 text-xs ${totalToneClassNames[totalStatus.tone]}`} className={`border px-3 py-1 text-xs ${totalToneClassNames[totalStatus.tone]}`}
variant="outline" variant="outline"
> >
@ -156,7 +159,7 @@ export const PaymentTermFormDialog = ({
i18n.language i18n.language
), ),
})} })}
</Badge> </RdxBadge>
</div> </div>
<div <div
@ -173,10 +176,7 @@ export const PaymentTermFormDialog = ({
<div className="space-y-2"> <div className="space-y-2">
{fields.map((field, index) => ( {fields.map((field, index) => (
<div <div className="rounded-xl border border-border bg-muted/20 p-3" key={field.id}>
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="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] md:items-start">
<div className="space-y-1"> <div className="space-y-1">
<span className="text-xs font-medium text-muted-foreground md:hidden"> <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 { CalendarClockIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
@ -61,14 +61,16 @@ export const PaymentTermListItem = ({
{paymentTerm.name} {paymentTerm.name}
</h3> </h3>
<Badge variant={paymentTerm.isActive ? "outline" : "secondary"}> <RdxBadge variant={paymentTerm.isActive ? "outline" : "secondary"}>
{paymentTerm.isActive {paymentTerm.isActive
? t("settings.paymentTerms.status.active") ? t("settings.paymentTerms.status.active")
: t("settings.paymentTerms.status.inactive")} : t("settings.paymentTerms.status.inactive")}
</Badge> </RdxBadge>
{paymentTerm.isSystem ? ( {paymentTerm.isSystem ? (
<Badge variant="secondary">{t("settings.paymentTerms.status.system")}</Badge> <RdxBadge variant="secondary">
{t("settings.paymentTerms.status.system")}
</RdxBadge>
) : null} ) : null}
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@ -81,7 +81,28 @@ export class RecurringProformaReader {
params.id, params.id,
transaction 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) => ({ const schedule = source.schedule.map<RecurringProformaScheduleReadModel>((value) => ({
startDate: value.startDate, startDate: value.startDate,
endDate: value.endDate, endDate: value.endDate,
@ -110,6 +135,7 @@ export class RecurringProformaReader {
(value) => value, (value) => value,
() => null () => null
), ),
customer,
description: source.description.match( description: source.description.match(
(value) => value, (value) => value,
() => null () => null
@ -150,6 +176,7 @@ export class RecurringProformaReader {
(value) => value, (value) => value,
() => null () => null
), ),
hasIssue,
items: source.items.getAll().map((item, index) => ({ items: source.items.getAll().map((item, index) => ({
id: item.id, id: item.id,
position: index + 1, position: index + 1,

View File

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

View File

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

View File

@ -107,6 +107,18 @@
"created_at": "Created", "created_at": "Created",
"updated_at": "Updated", "updated_at": "Updated",
"metadata": "Created on {{createdAt}} · Last updated {{updatedAt}}", "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", "inherited": "Inherited",
"general": { "general": {
"title": "General information" "title": "General information"

View File

@ -107,6 +107,18 @@
"created_at": "Creada", "created_at": "Creada",
"updated_at": "Actualizada", "updated_at": "Actualizada",
"metadata": "Creada el {{createdAt}} · Última actualización {{updatedAt}}", "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", "inherited": "Heredado",
"general": { "general": {
"title": "Informacion general" "title": "Informacion general"

View File

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

View File

@ -1,8 +1,4 @@
import { import { DataTable, type DataTableSort, SkeletonDataTable } from "@repo/rdx-ui/components";
DataTable,
type DataTableSort,
SkeletonDataTable,
} from "@repo/rdx-ui/components";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import type * as React from "react"; import type * as React from "react";
@ -121,8 +117,8 @@ export const IssuedInvoicesGrid = ({
manualSorting manualSorting
onPageChange={onPageChange} onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange} onPageSizeChange={onPageSizeChange}
onSortChange={onSortChange}
onRowClick={onRowClick ? (row) => onRowClick(row.id) : undefined} onRowClick={onRowClick ? (row) => onRowClick(row.id) : undefined}
onSortChange={onSortChange}
pageIndex={pageIndex} pageIndex={pageIndex}
pageSize={pageSize} pageSize={pageSize}
sort={sort} 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 { cn } from "@repo/shadcn-ui/lib/utils";
import { import {
AlertTriangleIcon, AlertTriangleIcon,
@ -164,8 +164,13 @@ export const IssuedInvoicesStatusSegmentedFilter = ({
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{option.icon ? <option.icon className="size-3.5" /> : null} {option.icon ? <option.icon className="size-3.5" /> : null}
<span>{t(`catalog.issued_invoices.status.${option.value.toLowerCase()}.label`, option.label)}</span> <span>
<Badge {t(
`catalog.issued_invoices.status.${option.value.toLowerCase()}.label`,
option.label
)}
</span>
<RdxBadge
className={cn( className={cn(
"min-w-5 rounded-full px-1.5 text-[11px] font-semibold tabular-nums", "min-w-5 rounded-full px-1.5 text-[11px] font-semibold tabular-nums",
"bg-muted text-muted-foreground", "bg-muted text-muted-foreground",
@ -174,7 +179,7 @@ export const IssuedInvoicesStatusSegmentedFilter = ({
variant="secondary" variant="secondary"
> >
{counts[option.countKey]} {counts[option.countKey]}
</Badge> </RdxBadge>
</span> </span>
</ToggleGroupItem> </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 { cn } from "@repo/shadcn-ui/lib/utils";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
@ -23,7 +23,7 @@ export const VerifactuStatusBadge = ({ status, className }: VerifactuStatusBadge
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
<Badge <RdxBadge
className={cn( className={cn(
getVerifactuRecordStatusColor(normalizedStatus), getVerifactuRecordStatusColor(normalizedStatus),
"font-semibold", "font-semibold",
@ -35,7 +35,7 @@ export const VerifactuStatusBadge = ({ status, className }: VerifactuStatusBadge
{t(`catalog.issued_invoices.status.${normalizedStatus.toLowerCase()}.label`, { {t(`catalog.issued_invoices.status.${normalizedStatus.toLowerCase()}.label`, {
defaultValue: status, defaultValue: status,
})} })}
</Badge> </RdxBadge>
} }
/> />
<TooltipContent> <TooltipContent>

View File

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

View File

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

View File

@ -6,7 +6,7 @@ import type {
ProformaDetailActionsViewModel, ProformaDetailActionsViewModel,
ProformaDetailHeaderViewModel, ProformaDetailHeaderViewModel,
} from "../../entities"; } from "../../entities";
import { ProformaDetailArchivedBadge, ProformaDetailHeaderMeta } from "../components"; import { ProformaDetailHeaderMeta } from "../components";
import { ProformaDetailHeaderActions } from "./proforma-detail-header-actions"; 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 { CircleAlertIcon, FileClockIcon, FileOutputIcon, Link2OffIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
@ -33,15 +32,18 @@ export const ProformaLinkedInvoiceSection = ({
<Icon aria-hidden="true" className="size-4" /> <Icon aria-hidden="true" className="size-4" />
</span> </span>
<div className="space-y-2"> <div className="space-y-2">
<Badge variant="outline">{linkedInvoice.primaryText}</Badge> <RdxBadge variant="outline">{linkedInvoice.primaryText}</RdxBadge>
{linkedInvoice.secondaryText ? ( {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} ) : null}
</div> </div>
</div> </div>
</div> </div>
{linkedInvoice.state === "issued-linked-without-detail-route" && linkedInvoice.linkedInvoiceId ? ( {linkedInvoice.state === "issued-linked-without-detail-route" &&
linkedInvoice.linkedInvoiceId ? (
<ProformaDetailField <ProformaDetailField
label={t("proformas.detail.fields.linked_invoice_id")} label={t("proformas.detail.fields.linked_invoice_id")}
value={linkedInvoice.linkedInvoiceId} value={linkedInvoice.linkedInvoiceId}

View File

@ -1,5 +1,4 @@
import { InitialsAvatar } from "@repo/rdx-ui/components"; import { InitialsAvatar } from "@repo/rdx-ui/components";
import { Badge } from "@repo/shadcn-ui/components";
import { Building2Icon, CopyIcon, UserIcon } from "lucide-react"; import { Building2Icon, CopyIcon, UserIcon } from "lucide-react";
import type { Proforma } from "../../../../shared"; import type { Proforma } from "../../../../shared";
@ -30,7 +29,7 @@ export const ProformaHeader = ({ proforma }: { proforma: Proforma }) => {
)} )}
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
<Badge className="gap-1" variant="outline"> <RdxBadge className="gap-1" variant="outline">
{proforma.recipient.tin ? ( {proforma.recipient.tin ? (
<> <>
<Building2Icon className="size-3" /> <Building2Icon className="size-3" />
@ -42,7 +41,7 @@ export const ProformaHeader = ({ proforma }: { proforma: Proforma }) => {
Particular Particular
</> </>
)} )}
</Badge> </RdxBadge>
<button <button
className="flex items-center gap-1 text-xs font-mono text-muted-foreground hover:text-foreground" 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 { Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
@ -22,9 +23,9 @@ export const ProformaStatusBadge = ({ status, className }: ProformaStatusBadgePr
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
<span <RdxBadge
className={cn( 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", "[&>svg]:pointer-events-none [&>svg]:shrink-0 [&>svg]:size-3.5",
getProformaStatusBadgeClassName(normalizedStatus), getProformaStatusBadgeClassName(normalizedStatus),
className className
@ -32,7 +33,7 @@ export const ProformaStatusBadge = ({ status, className }: ProformaStatusBadgePr
> >
<Icon aria-hidden="true" /> <Icon aria-hidden="true" />
{t(`catalog.proformas.status.${normalizedStatus}.label`, { defaultValue: status })} {t(`catalog.proformas.status.${normalizedStatus}.label`, { defaultValue: status })}
</span> </RdxBadge>
} }
/> />
<TooltipContent> <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 { cn } from "@repo/shadcn-ui/lib/utils";
import { import {
CheckCircle2Icon, CheckCircle2Icon,
@ -114,7 +115,7 @@ export const ProformaStatusSegmentedFilter = ({
{PROFORMA_STATUS_FILTER_OPTIONS.map((option) => ( {PROFORMA_STATUS_FILTER_OPTIONS.map((option) => (
<ToggleGroupItem <ToggleGroupItem
className={cn( 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", "text-muted-foreground hover:border-border hover:bg-muted/60 hover:text-foreground",
"data-[state=on]:shadow-none", "data-[state=on]:shadow-none",
option.activeClassName option.activeClassName
@ -130,7 +131,7 @@ export const ProformaStatusSegmentedFilter = ({
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{option.icon ? <option.icon className="size-3.5" /> : null} {option.icon ? <option.icon className="size-3.5" /> : null}
<span>{t(option.labelKey)}</span> <span>{t(option.labelKey)}</span>
<Badge <RdxBadge
className={cn( className={cn(
"min-w-5 rounded-full px-1.5 text-[11px] font-semibold tabular-nums", "min-w-5 rounded-full px-1.5 text-[11px] font-semibold tabular-nums",
"bg-muted text-muted-foreground", "bg-muted text-muted-foreground",
@ -139,7 +140,7 @@ export const ProformaStatusSegmentedFilter = ({
variant="secondary" variant="secondary"
> >
{counts[option.countKey]} {counts[option.countKey]}
</Badge> </RdxBadge>
</span> </span>
</ToggleGroupItem> </ToggleGroupItem>
))} ))}

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { RdxBadge } from "@repo/rdx-ui/components";
import { import {
Badge,
Button, Button,
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -144,50 +144,53 @@ const orders = [
function StatusBadge({ status }: { status: string }) { function StatusBadge({ status }: { status: string }) {
if (status === "Paid") { if (status === "Paid") {
return ( 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" /> <CheckCircle2 className="h-3 w-3" />
Paid Paid
</Badge> </RdxBadge>
); );
} }
if (status === "Cancelled") { if (status === "Cancelled") {
return ( 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" /> <XCircle className="h-3 w-3" />
Cancelled Cancelled
</Badge> </RdxBadge>
); );
} }
return ( 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" /> <Clock className="h-3 w-3" />
{status} {status}
</Badge> </RdxBadge>
); );
} }
function FulfillmentBadge({ fulfillment }: { fulfillment: string }) { function FulfillmentBadge({ fulfillment }: { fulfillment: string }) {
if (fulfillment === "Fulfilled") { if (fulfillment === "Fulfilled") {
return ( 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" /> <CheckCircle2 className="h-3 w-3" />
Fulfilled Fulfilled
</Badge> </RdxBadge>
); );
} }
if (fulfillment === "Cancelled") { if (fulfillment === "Cancelled") {
return ( 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" /> <XCircle className="h-3 w-3" />
Cancelled Cancelled
</Badge> </RdxBadge>
); );
} }
return ( 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" /> <Clock className="h-3 w-3" />
Unfulfilled Unfulfilled
</Badge> </RdxBadge>
); );
} }

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { RdxBadge } from "@repo/rdx-ui/components";
import { import {
Badge,
Button, Button,
Popover, Popover,
PopoverContent, PopoverContent,
@ -40,7 +40,12 @@ interface HelpReadinessSectionProps {
translate: (key: string, params?: Record<string, string | number | null | undefined>) => string; 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 ( return (
<Button <Button
{...props} {...props}
@ -52,12 +57,12 @@ const HelpTrigger = ({ ariaLabel, className, pendingBlockersCount, ...props }: H
> >
<CircleHelpIcon aria-hidden="true" className="size-4" /> <CircleHelpIcon aria-hidden="true" className="size-4" />
{pendingBlockersCount > 0 ? ( {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" 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" variant="outline"
> >
{pendingBlockersCount} {pendingBlockersCount}
</Badge> </RdxBadge>
) : null} ) : null}
</Button> </Button>
); );
@ -199,7 +204,9 @@ export const ProformaUpdateHelpPopover = ({
return ( return (
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
render={<HelpTrigger ariaLabel={triggerAriaLabel} pendingBlockersCount={pendingBlockersCount} />} render={
<HelpTrigger ariaLabel={triggerAriaLabel} pendingBlockersCount={pendingBlockersCount} />
}
/> />
<PopoverContent <PopoverContent

View File

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

View File

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

View File

@ -4,13 +4,13 @@ import { QuickCreateCustomerDialog, SelectCustomerDialog } from "@erp/customers"
import { import {
CancelActionButton, CancelActionButton,
PercentageField, PercentageField,
RdxBadge,
RdxEditorStickyFooter, RdxEditorStickyFooter,
RdxPageContainer, RdxPageContainer,
RdxPageStack, RdxPageStack,
RhfSubmitActionButton, RhfSubmitActionButton,
} from "@repo/rdx-ui/components"; } from "@repo/rdx-ui/components";
import { showErrorToast } from "@repo/rdx-ui/helpers"; import { showErrorToast } from "@repo/rdx-ui/helpers";
import { Badge } from "@repo/shadcn-ui/components";
import { FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
@ -114,12 +114,12 @@ export const ProformaUpdatePage = () => {
isSaving={updateCtrl.form.formState.isSubmitting} isSaving={updateCtrl.form.formState.isSubmitting}
labels={{ labels={{
title: proforma ? ( 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> <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} {proforma.proformaReference}
</Badge> </RdxBadge>
<ProformaStatusBadge className="text-base" status={proforma.status} /> <ProformaStatusBadge status={proforma.status} />
</span> </span>
) : ( ) : (
t("pages.proformas.update.title", "Editar proforma") 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"> <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 ? ( {hasPageAlerts ? (
<RdxPageContainer className="pb-0" width="full"> <RdxPageContainer className="pb-0" width="full">
<RdxPageStack spacing="compact"> <RdxPageStack spacing="compact">

View File

@ -16,11 +16,12 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { useCreateRecurringProforma } from "../shared/hooks/use-create-recurring-proforma"; import { useCreateRecurringProforma } from "../shared/hooks/use-create-recurring-proforma";
import { import {
buildCreateRecurringProformaInput,
getCreatedRecurringProformaPath,
type RecurringProformaCreateForm, type RecurringProformaCreateForm,
RecurringProformaCreateFormSchema, RecurringProformaCreateFormSchema,
buildCreateRecurringProformaInput,
getCreatedRecurringProformaPath,
} from "./recurring-proforma-create-utils"; } from "./recurring-proforma-create-utils";
const RECURRING_PROFORMA_CREATE_FORM_ID = "recurring-proforma-create-form"; const RECURRING_PROFORMA_CREATE_FORM_ID = "recurring-proforma-create-form";
@ -77,7 +78,7 @@ export const RecurringProformaCreatePage = () => {
title={t("recurring_proformas.create.title")} title={t("recurring_proformas.create.title")}
/> />
<div className="flex-1 overflow-y-auto bg-muted/20"> <div className="flex-1 overflow-y-auto bg-muted/20">
<RdxEditorPageLayout complexity="simple"> <RdxEditorPageLayout width="simple">
<form <form
className="space-y-6" className="space-y-6"
id={RECURRING_PROFORMA_CREATE_FORM_ID} 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 { RecurringProformaAction } from "../shared/hooks/use-recurring-proforma-action-mutation";
import type { RecurringProformaStatus } from "../shared/recurring-proforma-list";
export type RecurringProformaDetailActionPlacement = "primary" | "secondary" | "menu"; export type RecurringProformaDetailActionPlacement = "primary" | "secondary" | "menu";
@ -17,6 +17,7 @@ export const getRecurringProformaDetailActions = (
case "draft": case "draft":
return [ return [
{ key: "activate", placement: "primary" }, { key: "activate", placement: "primary" },
{ key: "edit", placement: "secondary" },
{ key: "duplicate", placement: "secondary" }, { key: "duplicate", placement: "secondary" },
{ key: "complete", placement: "menu", terminal: true }, { key: "complete", placement: "menu", terminal: true },
{ key: "delete", placement: "menu", terminal: true, destructive: true }, { key: "delete", placement: "menu", terminal: true, destructive: true },
@ -30,6 +31,7 @@ export const getRecurringProformaDetailActions = (
case "paused": case "paused":
return [ return [
{ key: "resume", placement: "primary" }, { key: "resume", placement: "primary" },
{ key: "edit", placement: "secondary" },
{ key: "duplicate", placement: "secondary" }, { key: "duplicate", placement: "secondary" },
{ key: "complete", placement: "menu", terminal: true }, { key: "complete", placement: "menu", terminal: true },
]; ];
@ -40,5 +42,7 @@ export const getRecurringProformaDetailActions = (
]; ];
case "archived": case "archived":
return [{ key: "duplicate", placement: "primary" }]; 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", name: "Mantenimiento mensual",
status: "active", status: "active",
customer_id: null, customer_id: null,
customer: null,
description: null, description: null,
payment_term_id: null, payment_term_id: null,
payment_method_id: null, payment_method_id: null,
@ -27,8 +28,15 @@ test("presenta nombre, estado y recurrencia del detalle principal", () => {
proforma_series: null, proforma_series: null,
issued_invoice_series: null, issued_invoice_series: null,
global_discount_percentage: 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, next_occurrence_date: null,
has_issue: false,
items: [], items: [],
created_at: "2026-09-01T10:00:00.000Z", created_at: "2026-09-01T10:00:00.000Z",
updated_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", () => { test("resuelve loading, error y contenido del detalle", () => {
assert.equal(getRecurringProformaDetailFeedbackState({ isLoading: true, isError: false, hasData: false }), "loading"); assert.equal(
assert.equal(getRecurringProformaDetailFeedbackState({ isLoading: false, isError: true, hasData: false }), "error"); getRecurringProformaDetailFeedbackState({ isLoading: true, isError: false, hasData: false }),
assert.equal(getRecurringProformaDetailFeedbackState({ isLoading: false, isError: false, hasData: true }), "ready"); "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 {
import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components"; 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 type { ColumnDef } from "@tanstack/react-table";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "../../i18n"; 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 { formatRecurringProformaDateOnly } from "./recurring-proforma-detail-utils";
import { import {
buildGeneratedProformaPath, buildGeneratedProformaPath,
formatRecurringProformaOccurrenceDateTime, formatRecurringProformaOccurrenceDateTime,
getRecurringProformaOccurrencesFeedbackState,
getRecurringProformaOccurrenceStatusKey, getRecurringProformaOccurrenceStatusKey,
getRecurringProformaOccurrencesFeedbackState,
} from "./recurring-proforma-occurrences-section-utils"; } 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; const PAGE_SIZE = 10;
@ -38,22 +44,46 @@ export const RecurringProformaOccurrencesSection = ({
() => [ () => [
{ {
accessorKey: "scheduled_date", 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), cell: ({ row }) => formatRecurringProformaDateOnly(row.original.scheduled_date, dateLocale),
}, },
{ {
accessorKey: "status", accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title={t("recurring_proformas.occurrences.columns.status")} />, header: ({ column }) => (
cell: ({ row }) => <Badge variant="secondary">{t(getRecurringProformaOccurrenceStatusKey(row.original.status))}</Badge>, <DataTableColumnHeader
column={column}
title={t("recurring_proformas.occurrences.columns.status")}
/>
),
cell: ({ row }) => (
<RdxBadge variant="secondary">
{t(getRecurringProformaOccurrenceStatusKey(row.original.status))}
</RdxBadge>
),
}, },
{ {
accessorKey: "attempt_count", 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>, cell: ({ row }) => <span className="tabular-nums">{row.original.attempt_count}</span>,
}, },
{ {
id: "proforma", 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 }) => { cell: ({ row }) => {
const proformaPath = buildGeneratedProformaPath(row.original.proforma_id); const proformaPath = buildGeneratedProformaPath(row.original.proforma_id);
return proformaPath ? ( return proformaPath ? (
@ -65,16 +95,32 @@ export const RecurringProformaOccurrencesSection = ({
}, },
{ {
accessorKey: "generated_at", accessorKey: "generated_at",
header: ({ column }) => <DataTableColumnHeader column={column} title={t("recurring_proformas.occurrences.columns.generated_at")} />, header: ({ column }) => (
cell: ({ row }) => formatRecurringProformaOccurrenceDateTime(row.original.generated_at, dateLocale) ?? "-", <DataTableColumnHeader
column={column}
title={t("recurring_proformas.occurrences.columns.generated_at")}
/>
),
cell: ({ row }) =>
formatRecurringProformaOccurrenceDateTime(row.original.generated_at, dateLocale) ?? "-",
}, },
{ {
id: "last_error", id: "last_error",
header: ({ column }) => <DataTableColumnHeader column={column} title={t("recurring_proformas.occurrences.columns.last_error")} />, header: ({ column }) => (
cell: ({ row }) => row.original.status === "failed" && row.original.last_error_message ? ( <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"> <div className="space-y-1">
<p className="max-w-sm text-sm text-destructive">{row.original.last_error_message}</p> <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} {row.original.last_error_code ? (
<span className="text-xs text-muted-foreground">
{row.original.last_error_code}
</span>
) : null}
</div> </div>
) : null, ) : null,
}, },
@ -84,9 +130,13 @@ export const RecurringProformaOccurrencesSection = ({
return ( return (
<Card> <Card>
<CardHeader><CardTitle>{t("recurring_proformas.occurrences.title")}</CardTitle></CardHeader> <CardHeader>
<CardTitle>{t("recurring_proformas.occurrences.title")}</CardTitle>
</CardHeader>
<CardContent> <CardContent>
{feedbackState === "loading" ? <SkeletonDataTable columns={6} rows={3} showFooter={false} /> : null} {feedbackState === "loading" ? (
<SkeletonDataTable columns={6} rows={3} showFooter={false} />
) : null}
{feedbackState === "error" ? ( {feedbackState === "error" ? (
<div className="space-y-3 rounded-md border border-destructive/30 p-4" role="alert"> <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> <p className="text-sm text-destructive">{t("recurring_proformas.occurrences.error")}</p>
@ -95,7 +145,11 @@ export const RecurringProformaOccurrencesSection = ({
</Button> </Button>
</div> </div>
) : null} ) : 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" ? ( {feedbackState === "ready" ? (
<DataTable <DataTable
columns={columns} columns={columns}
@ -104,7 +158,10 @@ export const RecurringProformaOccurrencesSection = ({
isFetching={query.isFetching} isFetching={query.isFetching}
manualPagination manualPagination
onPageChange={(pageIndex) => setPage(pageIndex + 1)} onPageChange={(pageIndex) => setPage(pageIndex + 1)}
onPageSizeChange={(pageSize) => { setLimit(pageSize); setPage(1); }} onPageSizeChange={(pageSize) => {
setLimit(pageSize);
setPage(1);
}}
pageIndex={data.page - 1} pageIndex={data.page - 1}
pageSize={data.per_page} pageSize={data.per_page}
totalItems={data.total_items} 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 { PageErrorState } from "@erp/core/components";
import { RdxPageStack } from "@repo/rdx-ui/components";
import { import {
Badge, RdxBadge,
Button, RdxEditorPageLayout,
Card, RdxPageContainer,
CardContent, RdxPageStack,
CardHeader, } from "@repo/rdx-ui/components";
CardTitle, import { Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
} from "@repo/shadcn-ui/components";
import { ArrowLeftIcon } from "lucide-react"; import { ArrowLeftIcon } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
import { ProformaPageHeader } from "../../../../proformas/shared"; import { ProformaPageHeader } from "../../../../proformas/shared";
import { getRecurringProformaStatusBadgeClassName } from "../../../shared/helpers/recurring-proforma-status-ui";
import { useRecurringProforma } from "../../../shared/hooks/use-recurring-proforma"; import { useRecurringProforma } from "../../../shared/hooks/use-recurring-proforma";
import { useRecurringProformaActionMutation } from "../../../shared/hooks/use-recurring-proforma-action-mutation"; import { useRecurringProformaActionMutation } from "../../../shared/hooks/use-recurring-proforma-action-mutation";
import { getRecurringProformaDetailActions } from "../../recurring-proforma-detail-actions"; import { getRecurringProformaDetailActions } from "../../recurring-proforma-detail-actions";
@ -26,6 +25,7 @@ import {
} from "../../recurring-proforma-detail-utils"; } from "../../recurring-proforma-detail-utils";
import { RecurringProformaOccurrencesSection } from "../../recurring-proforma-occurrences-section"; import { RecurringProformaOccurrencesSection } from "../../recurring-proforma-occurrences-section";
import { RecurringProformaDetailHeaderActions } from "../blocks/recurring-proforma-detail-header-actions"; import { RecurringProformaDetailHeaderActions } from "../blocks/recurring-proforma-detail-header-actions";
import { RecurringProformaOperationalSummary } from "../blocks/recurring-proforma-operational-summary";
type DetailFieldProps = { type DetailFieldProps = {
label: string; label: string;
@ -110,21 +110,35 @@ export const RecurringProformaDetailPage = () => {
backLabel={t("recurring_proformas.detail.back")} backLabel={t("recurring_proformas.detail.back")}
onBack={() => navigate("/recurring-proformas")} onBack={() => navigate("/recurring-proformas")}
title={ title={
<div className="space-y-3">
<span className="inline-flex max-w-full items-center gap-3"> <span className="inline-flex max-w-full items-center gap-3">
<span className="truncate">{mainView.name}</span> <span className="truncate">{mainView.name}</span>
<Badge className="shrink-0 px-4 py-3 rounded-md" variant="secondary"> <RdxBadge
{t(mainView.statusKey)} className={`shrink-0 rounded-md px-4 py-3 ${getRecurringProformaStatusBadgeClassName(recurringProforma.status)}`}
</Badge> variant="secondary"
</span>
}
> >
<div className="space-y-3 border-b bg-background px-4 py-4 sm:px-6"> {t(mainView.statusKey)}
<span className="text-sm text-muted-foreground"> </RdxBadge>
{t("recurring_proformas.detail.context")} <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> </span>
<p className="text-sm text-muted-foreground">{metadata}</p> <p className="text-sm text-muted-foreground">{metadata}</p>
</div> </div>
</ProformaPageHeader> }
/>
<RdxPageContainer className="min-h-0 flex-1 overflow-y-auto" padding="none" width="wide">
<RdxEditorPageLayout width="simple">
<RecurringProformaOperationalSummary
locale={dateLocale}
recurringProforma={recurringProforma}
/>
</RdxEditorPageLayout>
<div className="grid gap-6 xl:grid-cols-2"> <div className="grid gap-6 xl:grid-cols-2">
<Card> <Card>
@ -137,12 +151,6 @@ export const RecurringProformaDetailPage = () => {
{recurringProforma.description} {recurringProforma.description}
</p> </p>
) : null} ) : 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> </CardContent>
</Card> </Card>
@ -214,7 +222,10 @@ export const RecurringProformaDetailPage = () => {
/> />
<DetailField <DetailField
label={t("recurring_proformas.detail.commercial.issued_invoice_series")} label={t("recurring_proformas.detail.commercial.issued_invoice_series")}
value={formatRecurringProformaOverride(recurringProforma.issued_invoice_series, t)} value={formatRecurringProformaOverride(
recurringProforma.issued_invoice_series,
t
)}
/> />
{discount ? ( {discount ? (
<DetailField <DetailField
@ -257,6 +268,7 @@ export const RecurringProformaDetailPage = () => {
<ArrowLeftIcon aria-hidden className="size-4" /> <ArrowLeftIcon aria-hidden className="size-4" />
{t("recurring_proformas.detail.back")} {t("recurring_proformas.detail.back")}
</Button> </Button>
</RdxPageContainer>
</RdxPageStack> </RdxPageStack>
); );
}; };

View File

@ -1,3 +1,3 @@
export * from "./create"; export * from "./create";
export * from "./detail"; 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> </header>
), ),
RdxPageActions: ({ primary }: { primary: ReactNode }) => <div>{primary}</div>, 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>, DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
DataTable: (props: DataTableProps) => { DataTable: (props: DataTableProps) => {
capturedDataTableProps = props; capturedDataTableProps = props;
return <section>{props.topContent}</section>; 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/); 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 () => { test("conecta CTA, detalle, búsqueda, filtro y paginación con el controller", async () => {
events = { events = {
create: false, create: false,

View File

@ -2,11 +2,13 @@ import { PageErrorState, SimpleSearchInput } from "@erp/core/components";
import { import {
DataTable, DataTable,
DataTableColumnHeader, DataTableColumnHeader,
RdxBadge,
RdxPageContainer, RdxPageContainer,
type RdxRowAction, type RdxRowAction,
RdxRowActions, RdxRowActions,
SkeletonDataTable,
} from "@repo/rdx-ui/components"; } 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 type { ColumnDef } from "@tanstack/react-table";
import { import {
ArchiveIcon, ArchiveIcon,
@ -21,6 +23,7 @@ import { useCallback, useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { getRecurringProformaStatusBadgeClassName } from "../shared/helpers/recurring-proforma-status-ui";
import { import {
type RecurringProformaAction, type RecurringProformaAction,
useRecurringProformaActionMutation, useRecurringProformaActionMutation,
@ -161,7 +164,12 @@ export const ListRecurringProformasPage = () => {
/> />
), ),
cell: ({ row }) => ( 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,12 +286,24 @@ export const ListRecurringProformasPage = () => {
<ScopedListRecurringProformasPageView <ScopedListRecurringProformasPageView
content={ content={
<div className="mx-auto w-full space-y-4"> <div className="mx-auto w-full space-y-4">
{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 <DataTable
columns={columns} columns={columns}
data={listCtrl.data.items} data={listCtrl.data.items}
enablePagination enablePagination
isFetching={listCtrl.isFetching} isFetching={listCtrl.isFetching}
loading={listCtrl.isLoading} isSearching={isSearchPending}
manualPagination manualPagination
manualSorting manualSorting
onPageChange={listCtrl.setPageIndex} onPageChange={listCtrl.setPageIndex}
@ -292,7 +312,6 @@ export const ListRecurringProformasPage = () => {
onSortChange={listCtrl.setSort} onSortChange={listCtrl.setSort}
pageIndex={listCtrl.pageIndex} pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize} pageSize={listCtrl.pageSize}
searching={isSearchPending}
sort={listCtrl.sort} sort={listCtrl.sort}
topContent={ topContent={
<div className="space-y-5"> <div className="space-y-5">
@ -317,6 +336,7 @@ export const ListRecurringProformasPage = () => {
} }
totalItems={listCtrl.data.totalItems} totalItems={listCtrl.data.totalItems}
/> />
)}
{listCtrl.data.totalItems === 0 ? ( {listCtrl.data.totalItems === 0 ? (
<div className="rounded border border-dashed border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground"> <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 { ToggleGroup, ToggleGroupItem } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import { CheckCircle2Icon, CirclePauseIcon, FileTextIcon, PlayCircleIcon } from "lucide-react";
import type { ElementType } from "react"; import type { ElementType } from "react";
import { useTranslation } from "../../../../i18n"; 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; export type RecurringProformaStatusFilter = "all" | RecurringProformaStatus;
@ -22,34 +25,12 @@ const RECURRING_PROFORMA_STATUS_FILTER_OPTIONS = [
activeClassName: activeClassName:
"data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground", "data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground",
}, },
{ ...(["draft", "active", "paused", "completed"] as const).map((value) => ({
value: "draft", value,
labelKey: "recurring_proformas.status.draft", labelKey: `recurring_proformas.status.${value}`,
icon: FileTextIcon, icon: getRecurringProformaStatusIcon(value),
activeClassName: activeClassName: getRecurringProformaStatusSegmentedFilterActiveClassName(value),
"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",
},
] as const satisfies readonly { ] as const satisfies readonly {
value: RecurringProformaStatusFilter; value: RecurringProformaStatusFilter;
labelKey: string; labelKey: string;
@ -90,7 +71,7 @@ export const RecurringProformaStatusSegmentedFilter = ({
{RECURRING_PROFORMA_STATUS_FILTER_OPTIONS.map((option) => ( {RECURRING_PROFORMA_STATUS_FILTER_OPTIONS.map((option) => (
<ToggleGroupItem <ToggleGroupItem
className={cn( 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", "text-muted-foreground hover:border-border hover:bg-muted/60 hover:text-foreground",
"data-[state=on]:shadow-none", "data-[state=on]:shadow-none",
option.activeClassName 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"; import { recurringProformaQueryKeys } from "./recurring-proforma-query-keys";
export type RecurringProformaAction = export type RecurringProformaAction =
| "edit"
| "activate" | "activate"
| "pause" | "pause"
| "resume" | "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 = { export type RecurringProformaListFilters = {
page: number; page: number;
@ -86,6 +86,11 @@ export type RecurringProformaDetail = {
name: string; name: string;
status: RecurringProformaStatus; status: RecurringProformaStatus;
customer_id: string | null; customer_id: string | null;
customer: {
id: string;
name: string;
tin: string | null;
} | null;
description: string | null; description: string | null;
payment_term_id: string | null; payment_term_id: string | null;
payment_method_id: string | null; payment_method_id: string | null;
@ -96,6 +101,7 @@ export type RecurringProformaDetail = {
global_discount_percentage: RecurringProformaPercentage | null; global_discount_percentage: RecurringProformaPercentage | null;
schedule: RecurringProformaListItemDTO["schedule"]; schedule: RecurringProformaListItemDTO["schedule"];
next_occurrence_date: string | null; next_occurrence_date: string | null;
has_issue: boolean;
items: RecurringProformaDetailItem[]; items: RecurringProformaDetailItem[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;

View File

@ -44,7 +44,7 @@ export const CustomerCreatePage = () => {
/> />
<div className="flex-1 overflow-y-auto bg-muted/20"> <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="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"> <div className="flex min-w-0 flex-col gap-6">
{createCtrl.isCreateError ? ( {createCtrl.isCreateError ? (

View File

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

View File

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

View File

@ -1,9 +1,8 @@
import { Badge } from "@repo/shadcn-ui/components";
import { Building2Icon, User2Icon } from "lucide-react"; import { Building2Icon, User2Icon } from "lucide-react";
export const KindBadge = ({ isCompany }: { isCompany: boolean }) => ( 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 ? <Building2Icon className="size-3.5" /> : <User2Icon className="size-3.5" />}
{isCompany ? "Company" : "Person"} {isCompany ? "Company" : "Person"}
</Badge> </RdxBadge>
); );

View File

@ -93,16 +93,22 @@ export const CustomerUpdatePage = () => {
/> />
<div className="flex-1 overflow-y-auto bg-muted/20"> <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="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"> <div className="flex min-w-0 flex-col gap-6">
{updateCtrl.isUpdateError ? ( {updateCtrl.isUpdateError ? (
<ErrorAlert <ErrorAlert
message={ message={
(updateCtrl.updateError as Error)?.message ?? (updateCtrl.updateError as Error)?.message ??
t("pages.customers.update.error_msg", "Revisa los datos e inténtalo de nuevo.") 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")} title={t(
"pages.customers.update.error_title",
"No se pudo guardar los cambios"
)}
/> />
) : null} ) : null}

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

View File

@ -35,7 +35,7 @@ export const SupplierCreatePage = () => {
title={t("create.title")} title={t("create.title")}
/> />
<div className="flex-1 overflow-y-auto bg-muted/20"> <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="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"> <div className="flex min-w-0 flex-col gap-6">
{controller.mutation.isError ? ( {controller.mutation.isError ? (

View File

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

View File

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

View File

@ -57,7 +57,7 @@ export const SupplierUpdatePage = () => {
title={t("update.title")} title={t("update.title")}
/> />
<div className="flex-1 overflow-y-auto bg-muted/20"> <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="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"> <div className="flex min-w-0 flex-col gap-6">
{controller.mutation.isError ? ( {controller.mutation.isError ? (

View File

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

View File

@ -1,5 +1,4 @@
import { import {
Badge,
Button, Button,
Command, Command,
CommandEmpty, CommandEmpty,
@ -18,6 +17,8 @@ import type { Column } from "@tanstack/react-table";
import { Check, PlusCircleIcon } from "lucide-react"; import { Check, PlusCircleIcon } from "lucide-react";
import type * as React from "react"; import type * as React from "react";
import { RdxBadge } from "../rdx-badge.tsx";
interface DataTableFacetedFilterProps<TData, TValue> { interface DataTableFacetedFilterProps<TData, TValue> {
column?: Column<TData, TValue>; column?: Column<TData, TValue>;
title?: string; title?: string;
@ -45,25 +46,25 @@ export function DataTableFacetedFilter<TData, TValue>({
{selectedValues?.size > 0 && ( {selectedValues?.size > 0 && (
<> <>
<Separator className="mx-2 h-4" orientation="vertical" /> <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} {selectedValues.size}
</Badge> </RdxBadge>
<div className="hidden gap-1 lg:flex"> <div className="hidden gap-1 lg:flex">
{selectedValues.size > 2 ? ( {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 {selectedValues.size} selected
</Badge> </RdxBadge>
) : ( ) : (
options options
.filter((option) => selectedValues.has(option.value)) .filter((option) => selectedValues.has(option.value))
.map((option) => ( .map((option) => (
<Badge <RdxBadge
className="rounded-sm px-1 font-normal" className="rounded-sm px-1 font-normal"
key={option.value} key={option.value}
variant="secondary" variant="secondary"
> >
{option.label} {option.label}
</Badge> </RdxBadge>
)) ))
)} )}
</div> </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", () => { test("RdxEditorPageLayout soporta aside opcional y complejidad complex", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<RdxEditorPageLayout aside={<div>Aside content</div>} complexity="complex"> <RdxEditorPageLayout aside={<div>Aside content</div>} width="full">
<div>Main content</div> <div>Main content</div>
</RdxEditorPageLayout> </RdxEditorPageLayout>
); );
@ -37,7 +37,7 @@ test("RdxEditorPageLayout soporta aside opcional y complejidad complex", () => {
test("RdxEditorPageLayout no reserva aside cuando no existe incluso en complex", () => { test("RdxEditorPageLayout no reserva aside cuando no existe incluso en complex", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<RdxEditorPageLayout complexity="complex"> <RdxEditorPageLayout width="full">
<div>Main content</div> <div>Main content</div>
</RdxEditorPageLayout> </RdxEditorPageLayout>
); );

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ import {
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from "@repo/shadcn-ui/components/sidebar"; } 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({ export function NavUser({
user, user,
@ -79,7 +79,7 @@ export function NavUser({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem> <DropdownMenuItem>
<BadgeCheck /> <RdxBadgeCheck />
Account Account
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem>

View File

@ -4,8 +4,7 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react";
export type RdxPageWidth = "full" | "wide" | "content"; export type RdxPageWidth = "full" | "wide" | "content";
export type RdxPageContainerPadding = "default" | "none"; export type RdxPageContainerPadding = "default" | "none";
export interface RdxPageContainerProps export interface RdxPageContainerProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
children: ReactNode; children: ReactNode;
width?: RdxPageWidth; width?: RdxPageWidth;
padding?: RdxPageContainerPadding; padding?: RdxPageContainerPadding;
@ -31,7 +30,12 @@ export const RdxPageContainer = ({
}: RdxPageContainerProps) => { }: RdxPageContainerProps) => {
return ( return (
<div <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} {...props}
> >
{children} {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 { cn } from "@repo/shadcn-ui/lib/utils";
import { ArrowLeftIcon } from "lucide-react"; import { ArrowLeftIcon } from "lucide-react";
import type { ComponentPropsWithoutRef, ReactNode } from "react"; import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { RdxBadge } from "../rdx-badge.tsx";
export interface RdxPageHeaderProps export interface RdxPageHeaderProps
extends Omit<ComponentPropsWithoutRef<"header">, "children" | "title"> { extends Omit<ComponentPropsWithoutRef<"header">, "children" | "title"> {
title: ReactNode; title: ReactNode;
@ -72,14 +74,14 @@ export const RdxPageHeader = ({
</h1> </h1>
{statusLabel ? ( {statusLabel ? (
<Badge <RdxBadge
className={cn( className={cn(
"border-amber-500 bg-amber-200 text-amber-900 transition-opacity duration-200", "border-amber-500 bg-amber-200 text-amber-900 transition-opacity duration-200",
showStatus ? "opacity-100" : "pointer-events-none opacity-0" showStatus ? "opacity-100" : "pointer-events-none opacity-0"
)} )}
> >
{statusLabel} {statusLabel}
</Badge> </RdxBadge>
) : null} ) : null}
</div> </div>
</div> </div>

View File

@ -1,5 +1,4 @@
import { import {
Badge,
Button, Button,
Command, Command,
CommandEmpty, CommandEmpty,
@ -20,6 +19,8 @@ import * as React from "react";
import { useTranslation } from "../locales/i18n.ts"; import { useTranslation } from "../locales/i18n.ts";
import { RdxBadge } from "./rdx-badge.tsx";
/** /**
* Variants for the multi-select component to handle different styles. * Variants for the multi-select component to handle different styles.
* Uses class-variance-authority (cva) to define different styles based on "variant" prop. * 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 option = options.find((o) => o.value === value);
const IconComponent = option?.icon; const IconComponent = option?.icon;
return ( return (
<Badge <RdxBadge
className={cn( className={cn(
isAnimating ? "animate-bounce" : "", isAnimating ? "animate-bounce" : "",
multiSelectVariants({ variant }) multiSelectVariants({ variant })
@ -272,11 +273,11 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
toggleOption(value); toggleOption(value);
}} }}
/>*/} />*/}
</Badge> </RdxBadge>
); );
})} })}
{selectedValues.length > maxCount && ( {selectedValues.length > maxCount && (
<Badge <RdxBadge
className={cn( className={cn(
"bg-transparent text-foreground border-foreground/1 hover:bg-transparent", "bg-transparent text-foreground border-foreground/1 hover:bg-transparent",
isAnimating ? "animate-bounce" : "", isAnimating ? "animate-bounce" : "",
@ -292,7 +293,7 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
clearExtraOptions(); clearExtraOptions();
}} }}
/> />
</Badge> </RdxBadge>
)} )}
</div> </div>
<div className="flex items-center justify-between"> <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 { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render" 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 { type VariantProps, cva } from "class-variance-authority";
import { cn } from "@repo/shadcn-ui/lib/utils"
const badgeVariants = cva( 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!", "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: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: 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", "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: outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
"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",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
}, },
@ -25,7 +21,7 @@ const badgeVariants = cva(
variant: "default", variant: "default",
}, },
} }
) );
function Badge({ function Badge({
className, className,
@ -46,7 +42,7 @@ function Badge({
slot: "badge", slot: "badge",
variant, variant,
}, },
}) });
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };

View File

@ -1468,6 +1468,9 @@ importers:
packages/rdx-ui: packages/rdx-ui:
dependencies: 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': '@dnd-kit/core':
specifier: ^6.3.1 specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)