Agregar acciones y metadatos a la página de detalle de proformas recurrentes, incluyendo pruebas y utilidades de formato de fecha

This commit is contained in:
David Arranz 2026-09-02 13:05:05 +02:00
parent 5775378ca6
commit e670ff4b33
8 changed files with 241 additions and 17 deletions

View File

@ -106,6 +106,7 @@
"no_customer": "No customer",
"created_at": "Created",
"updated_at": "Updated",
"metadata": "Created on {{createdAt}} · Last updated {{updatedAt}}",
"inherited": "Inherited",
"general": {
"title": "General information"

View File

@ -106,6 +106,7 @@
"no_customer": "Sin cliente",
"created_at": "Creada",
"updated_at": "Actualizada",
"metadata": "Creada el {{createdAt}} · Última actualización {{updatedAt}}",
"inherited": "Heredado",
"general": {
"title": "Informacion general"

View File

@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getRecurringProformaDetailActions } from "./recurring-proforma-detail-actions";
test("prioriza la activación de un borrador y conserva las acciones terminales en el menú", () => {
assert.deepEqual(getRecurringProformaDetailActions("draft"), [
{ key: "activate", placement: "primary" },
{ key: "duplicate", placement: "secondary" },
{ key: "complete", placement: "menu", terminal: true },
{ key: "delete", placement: "menu", terminal: true, destructive: true },
]);
});
test("no ofrece acciones de ciclo de vida para recurrencias archivadas", () => {
assert.deepEqual(getRecurringProformaDetailActions("archived"), [
{ key: "duplicate", placement: "primary" },
]);
});

View File

@ -0,0 +1,44 @@
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 RecurringProformaDetailAction = {
key: RecurringProformaAction;
placement: RecurringProformaDetailActionPlacement;
terminal?: boolean;
destructive?: boolean;
};
export const getRecurringProformaDetailActions = (
status: RecurringProformaStatus
): RecurringProformaDetailAction[] => {
switch (status) {
case "draft":
return [
{ key: "activate", placement: "primary" },
{ key: "duplicate", placement: "secondary" },
{ key: "complete", placement: "menu", terminal: true },
{ key: "delete", placement: "menu", terminal: true, destructive: true },
];
case "active":
return [
{ key: "pause", placement: "primary" },
{ key: "duplicate", placement: "secondary" },
{ key: "complete", placement: "menu", terminal: true },
];
case "paused":
return [
{ key: "resume", placement: "primary" },
{ key: "duplicate", placement: "secondary" },
{ key: "complete", placement: "menu", terminal: true },
];
case "completed":
return [
{ key: "duplicate", placement: "primary" },
{ key: "archive", placement: "menu", terminal: true },
];
case "archived":
return [{ key: "duplicate", placement: "primary" }];
}
};

View File

@ -11,9 +11,15 @@ import type {
type Translate = (key: string) => string;
export const formatRecurringProformaDateOnly = (value: string, locale: string) =>
export const formatRecurringProformaDateOnly = (value: string, locale?: string) =>
DateHelper.format(value, locale);
export const formatRecurringProformaDateTime = (value: string, locale: string) =>
new Intl.DateTimeFormat(locale, {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(value));
export const formatRecurringProformaOverride = (value: string | null, t: Translate) =>
value ?? t("recurring_proformas.detail.inherited");

View File

@ -0,0 +1,133 @@
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@repo/shadcn-ui/components";
import {
ArchiveIcon,
CheckCircle2Icon,
CirclePauseIcon,
CopyIcon,
MoreHorizontalIcon,
PlayCircleIcon,
RefreshCwIcon,
Trash2Icon,
} from "lucide-react";
import { useTranslation } from "../../../../i18n";
import type { RecurringProformaAction } from "../../../shared/hooks/use-recurring-proforma-action-mutation";
import type { RecurringProformaDetailAction } from "../../recurring-proforma-detail-actions";
type RecurringProformaDetailHeaderActionsProps = {
actions: RecurringProformaDetailAction[];
isPending: boolean;
onExecuteAction: (action: RecurringProformaAction) => void;
};
const ActionIcon = ({ action }: { action: RecurringProformaAction }) => {
switch (action) {
case "activate":
case "resume":
return <PlayCircleIcon aria-hidden="true" className="mr-2 size-4" />;
case "pause":
return <CirclePauseIcon aria-hidden="true" className="mr-2 size-4" />;
case "complete":
return <CheckCircle2Icon aria-hidden="true" className="mr-2 size-4" />;
case "archive":
return <ArchiveIcon aria-hidden="true" className="mr-2 size-4" />;
case "duplicate":
return <CopyIcon aria-hidden="true" className="mr-2 size-4" />;
case "delete":
return <Trash2Icon aria-hidden="true" className="mr-2 size-4" />;
}
};
export const RecurringProformaDetailHeaderActions = ({
actions,
isPending,
onExecuteAction,
}: RecurringProformaDetailHeaderActionsProps) => {
const { t } = useTranslation();
const primaryAction = actions.find((action) => action.placement === "primary");
const secondaryActions = actions.filter((action) => action.placement === "secondary");
const menuActions = actions.filter((action) => action.placement === "menu");
const hasFunctionalMenuAction = menuActions.some((action) => !action.terminal);
return (
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
{primaryAction ? (
<Button
disabled={isPending}
onClick={() => onExecuteAction(primaryAction.key)}
type="button"
>
{isPending ? (
<RefreshCwIcon aria-hidden="true" className="mr-2 size-4 animate-spin" />
) : (
<ActionIcon action={primaryAction.key} />
)}
{t(`recurring_proformas.actions.${primaryAction.key}`)}
</Button>
) : null}
{secondaryActions.map((action) => {
return (
<Button
disabled={isPending}
key={action.key}
onClick={() => onExecuteAction(action.key)}
type="button"
variant="outline"
>
<ActionIcon action={action.key} />
{t(`recurring_proformas.actions.${action.key}`)}
</Button>
);
})}
{menuActions.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label={t("common.more_actions")}
disabled={isPending}
size="icon"
type="button"
variant="outline"
>
<MoreHorizontalIcon aria-hidden="true" className="size-4" />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-56">
{menuActions.map((action, index) => {
const showSeparator =
action.terminal &&
hasFunctionalMenuAction &&
index > 0 &&
!menuActions[index - 1]?.terminal;
return (
<div key={action.key}>
{showSeparator ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
disabled={isPending}
onClick={() => onExecuteAction(action.key)}
variant={action.destructive ? "destructive" : "default"}
>
<ActionIcon action={action.key} />
{t(`recurring_proformas.actions.${action.key}`)}
</DropdownMenuItem>
</div>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
};

View File

@ -14,14 +14,18 @@ import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
import { ProformaPageHeader } from "../../../../proformas/shared";
import { useRecurringProforma } from "../../../shared/hooks/use-recurring-proforma";
import { useRecurringProformaActionMutation } from "../../../shared/hooks/use-recurring-proforma-action-mutation";
import { getRecurringProformaDetailActions } from "../../recurring-proforma-detail-actions";
import {
buildRecurringProformaDetailMainView,
formatRecurringProformaDateOnly,
formatRecurringProformaDateTime,
formatRecurringProformaOverride,
formatRecurringProformaPercentage,
getRecurringProformaDetailFeedbackState,
} from "../../recurring-proforma-detail-utils";
import { RecurringProformaOccurrencesSection } from "../../recurring-proforma-occurrences-section";
import { RecurringProformaDetailHeaderActions } from "../blocks/recurring-proforma-detail-header-actions";
type DetailFieldProps = {
label: string;
@ -40,6 +44,7 @@ export const RecurringProformaDetailPage = () => {
const navigate = useNavigate();
const { recurring_proforma_id: recurringProformaId } = useParams();
const query = useRecurringProforma(recurringProformaId);
const actionMutation = useRecurringProformaActionMutation();
const feedbackState = getRecurringProformaDetailFeedbackState({
isLoading: query.isLoading,
isError: query.isError,
@ -63,7 +68,9 @@ export const RecurringProformaDetailPage = () => {
description={t("recurring_proformas.detail.error.description")}
primaryAction={{
label: t("recurring_proformas.detail.error.retry"),
onClick: query.refetch,
onClick: async () => {
await query.refetch();
},
}}
title={t("recurring_proformas.detail.error.title")}
variant="unknown"
@ -76,25 +83,46 @@ export const RecurringProformaDetailPage = () => {
if (!recurringProforma) {
return null;
}
const dateLocale = i18n.language === "en" ? "en-US" : "es-ES";
const dateLocale = i18n.language.startsWith("en") ? "en-US" : "es-ES";
const schedule = recurringProforma.schedule;
const mainView = buildRecurringProformaDetailMainView(recurringProforma, (key, options) =>
String(t(key, options))
);
const discount = formatRecurringProformaPercentage(recurringProforma.global_discount_percentage);
const headerActions = getRecurringProformaDetailActions(recurringProforma.status);
const metadata = t("recurring_proformas.detail.metadata", {
createdAt: formatRecurringProformaDateTime(recurringProforma.created_at, dateLocale),
updatedAt: formatRecurringProformaDateTime(recurringProforma.updated_at, dateLocale),
});
return (
<RdxPageStack className="fixed inset-0 min-h-0 overflow-hidden" spacing="none">
<ProformaPageHeader
actions={
<RecurringProformaDetailHeaderActions
actions={headerActions}
isPending={actionMutation.isPending}
onExecuteAction={(action) =>
actionMutation.mutate({ id: recurringProforma.id, action })
}
/>
}
backLabel={t("recurring_proformas.detail.back")}
onBack={() => navigate("/recurring-proformas")}
title={mainView.name}
title={
<span className="inline-flex max-w-full items-center gap-3">
<span className="truncate">{mainView.name}</span>
<Badge className="shrink-0 px-4 py-3 rounded-md" variant="secondary">
{t(mainView.statusKey)}
</Badge>
</span>
}
>
<div className="flex items-center gap-2 px-4 py-3 sm:px-6">
<Badge variant="secondary">{t(mainView.statusKey)}</Badge>
<div className="space-y-3 border-b bg-background px-4 py-4 sm:px-6">
<span className="text-sm text-muted-foreground">
{t("recurring_proformas.detail.context")}
</span>
<p className="text-sm text-muted-foreground">{metadata}</p>
</div>
</ProformaPageHeader>
@ -114,14 +142,6 @@ export const RecurringProformaDetailPage = () => {
label={t("recurring_proformas.detail.customer")}
value={recurringProforma.customer_id ?? t("recurring_proformas.detail.no_customer")}
/>
<DetailField
label={t("recurring_proformas.detail.created_at")}
value={formatRecurringProformaDateOnly(recurringProforma.created_at, dateLocale)}
/>
<DetailField
label={t("recurring_proformas.detail.updated_at")}
value={formatRecurringProformaDateOnly(recurringProforma.updated_at, dateLocale)}
/>
</dl>
</CardContent>
</Card>
@ -139,13 +159,13 @@ export const RecurringProformaDetailPage = () => {
/>
<DetailField
label={t("recurring_proformas.detail.schedule.start_date")}
value={formatRecurringProformaDateOnly(schedule.start_date, dateLocale)}
value={formatRecurringProformaDateOnly(schedule.start_date)}
/>
<DetailField
label={t("recurring_proformas.detail.schedule.end_date")}
value={
schedule.end_date
? formatRecurringProformaDateOnly(schedule.end_date, dateLocale)
? formatRecurringProformaDateOnly(schedule.end_date)
: t("recurring_proformas.detail.schedule.no_end_date")
}
/>

View File

@ -25,7 +25,7 @@ export const useRecurringProformaActionMutation = () => {
data: undefined,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: recurringProformaQueryKeys.lists() });
await queryClient.invalidateQueries({ queryKey: recurringProformaQueryKeys.all });
},
});
};