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:
parent
5775378ca6
commit
e670ff4b33
@ -106,6 +106,7 @@
|
|||||||
"no_customer": "No customer",
|
"no_customer": "No customer",
|
||||||
"created_at": "Created",
|
"created_at": "Created",
|
||||||
"updated_at": "Updated",
|
"updated_at": "Updated",
|
||||||
|
"metadata": "Created on {{createdAt}} · Last updated {{updatedAt}}",
|
||||||
"inherited": "Inherited",
|
"inherited": "Inherited",
|
||||||
"general": {
|
"general": {
|
||||||
"title": "General information"
|
"title": "General information"
|
||||||
|
|||||||
@ -106,6 +106,7 @@
|
|||||||
"no_customer": "Sin cliente",
|
"no_customer": "Sin cliente",
|
||||||
"created_at": "Creada",
|
"created_at": "Creada",
|
||||||
"updated_at": "Actualizada",
|
"updated_at": "Actualizada",
|
||||||
|
"metadata": "Creada el {{createdAt}} · Última actualización {{updatedAt}}",
|
||||||
"inherited": "Heredado",
|
"inherited": "Heredado",
|
||||||
"general": {
|
"general": {
|
||||||
"title": "Informacion general"
|
"title": "Informacion general"
|
||||||
|
|||||||
@ -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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -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" }];
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -11,9 +11,15 @@ import type {
|
|||||||
|
|
||||||
type Translate = (key: string) => string;
|
type Translate = (key: string) => string;
|
||||||
|
|
||||||
export const formatRecurringProformaDateOnly = (value: string, locale: string) =>
|
export const formatRecurringProformaDateOnly = (value: string, locale?: string) =>
|
||||||
DateHelper.format(value, locale);
|
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) =>
|
export const formatRecurringProformaOverride = (value: string | null, t: Translate) =>
|
||||||
value ?? t("recurring_proformas.detail.inherited");
|
value ?? t("recurring_proformas.detail.inherited");
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -14,14 +14,18 @@ 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 { 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 { getRecurringProformaDetailActions } from "../../recurring-proforma-detail-actions";
|
||||||
import {
|
import {
|
||||||
buildRecurringProformaDetailMainView,
|
buildRecurringProformaDetailMainView,
|
||||||
formatRecurringProformaDateOnly,
|
formatRecurringProformaDateOnly,
|
||||||
|
formatRecurringProformaDateTime,
|
||||||
formatRecurringProformaOverride,
|
formatRecurringProformaOverride,
|
||||||
formatRecurringProformaPercentage,
|
formatRecurringProformaPercentage,
|
||||||
getRecurringProformaDetailFeedbackState,
|
getRecurringProformaDetailFeedbackState,
|
||||||
} 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";
|
||||||
|
|
||||||
type DetailFieldProps = {
|
type DetailFieldProps = {
|
||||||
label: string;
|
label: string;
|
||||||
@ -40,6 +44,7 @@ export const RecurringProformaDetailPage = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { recurring_proforma_id: recurringProformaId } = useParams();
|
const { recurring_proforma_id: recurringProformaId } = useParams();
|
||||||
const query = useRecurringProforma(recurringProformaId);
|
const query = useRecurringProforma(recurringProformaId);
|
||||||
|
const actionMutation = useRecurringProformaActionMutation();
|
||||||
const feedbackState = getRecurringProformaDetailFeedbackState({
|
const feedbackState = getRecurringProformaDetailFeedbackState({
|
||||||
isLoading: query.isLoading,
|
isLoading: query.isLoading,
|
||||||
isError: query.isError,
|
isError: query.isError,
|
||||||
@ -63,7 +68,9 @@ export const RecurringProformaDetailPage = () => {
|
|||||||
description={t("recurring_proformas.detail.error.description")}
|
description={t("recurring_proformas.detail.error.description")}
|
||||||
primaryAction={{
|
primaryAction={{
|
||||||
label: t("recurring_proformas.detail.error.retry"),
|
label: t("recurring_proformas.detail.error.retry"),
|
||||||
onClick: query.refetch,
|
onClick: async () => {
|
||||||
|
await query.refetch();
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
title={t("recurring_proformas.detail.error.title")}
|
title={t("recurring_proformas.detail.error.title")}
|
||||||
variant="unknown"
|
variant="unknown"
|
||||||
@ -76,25 +83,46 @@ export const RecurringProformaDetailPage = () => {
|
|||||||
if (!recurringProforma) {
|
if (!recurringProforma) {
|
||||||
return null;
|
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 schedule = recurringProforma.schedule;
|
||||||
const mainView = buildRecurringProformaDetailMainView(recurringProforma, (key, options) =>
|
const mainView = buildRecurringProformaDetailMainView(recurringProforma, (key, options) =>
|
||||||
String(t(key, options))
|
String(t(key, options))
|
||||||
);
|
);
|
||||||
const discount = formatRecurringProformaPercentage(recurringProforma.global_discount_percentage);
|
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 (
|
return (
|
||||||
<RdxPageStack className="fixed inset-0 min-h-0 overflow-hidden" spacing="none">
|
<RdxPageStack className="fixed inset-0 min-h-0 overflow-hidden" spacing="none">
|
||||||
<ProformaPageHeader
|
<ProformaPageHeader
|
||||||
|
actions={
|
||||||
|
<RecurringProformaDetailHeaderActions
|
||||||
|
actions={headerActions}
|
||||||
|
isPending={actionMutation.isPending}
|
||||||
|
onExecuteAction={(action) =>
|
||||||
|
actionMutation.mutate({ id: recurringProforma.id, action })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
backLabel={t("recurring_proformas.detail.back")}
|
backLabel={t("recurring_proformas.detail.back")}
|
||||||
onBack={() => navigate("/recurring-proformas")}
|
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">
|
<div className="space-y-3 border-b bg-background px-4 py-4 sm:px-6">
|
||||||
<Badge variant="secondary">{t(mainView.statusKey)}</Badge>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{t("recurring_proformas.detail.context")}
|
{t("recurring_proformas.detail.context")}
|
||||||
</span>
|
</span>
|
||||||
|
<p className="text-sm text-muted-foreground">{metadata}</p>
|
||||||
</div>
|
</div>
|
||||||
</ProformaPageHeader>
|
</ProformaPageHeader>
|
||||||
|
|
||||||
@ -114,14 +142,6 @@ export const RecurringProformaDetailPage = () => {
|
|||||||
label={t("recurring_proformas.detail.customer")}
|
label={t("recurring_proformas.detail.customer")}
|
||||||
value={recurringProforma.customer_id ?? t("recurring_proformas.detail.no_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>
|
</dl>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -139,13 +159,13 @@ export const RecurringProformaDetailPage = () => {
|
|||||||
/>
|
/>
|
||||||
<DetailField
|
<DetailField
|
||||||
label={t("recurring_proformas.detail.schedule.start_date")}
|
label={t("recurring_proformas.detail.schedule.start_date")}
|
||||||
value={formatRecurringProformaDateOnly(schedule.start_date, dateLocale)}
|
value={formatRecurringProformaDateOnly(schedule.start_date)}
|
||||||
/>
|
/>
|
||||||
<DetailField
|
<DetailField
|
||||||
label={t("recurring_proformas.detail.schedule.end_date")}
|
label={t("recurring_proformas.detail.schedule.end_date")}
|
||||||
value={
|
value={
|
||||||
schedule.end_date
|
schedule.end_date
|
||||||
? formatRecurringProformaDateOnly(schedule.end_date, dateLocale)
|
? formatRecurringProformaDateOnly(schedule.end_date)
|
||||||
: t("recurring_proformas.detail.schedule.no_end_date")
|
: t("recurring_proformas.detail.schedule.no_end_date")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export const useRecurringProformaActionMutation = () => {
|
|||||||
data: undefined,
|
data: undefined,
|
||||||
}),
|
}),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: recurringProformaQueryKeys.lists() });
|
await queryClient.invalidateQueries({ queryKey: recurringProformaQueryKeys.all });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user