diff --git a/frontend/src/components/ProcessingStatusBadge/ProcessingStatusBadge.tsx b/frontend/src/components/ProcessingStatusBadge/ProcessingStatusBadge.tsx index 0d5dd31b..4b2ab094 100644 --- a/frontend/src/components/ProcessingStatusBadge/ProcessingStatusBadge.tsx +++ b/frontend/src/components/ProcessingStatusBadge/ProcessingStatusBadge.tsx @@ -1,4 +1,4 @@ -import { Badge, field2Title } from "@maykin-ui/admin-ui"; +import { Badge, Outline, field2Title } from "@maykin-ui/admin-ui"; import React from "react"; import { ProcessingStatus } from "../../lib/api/processingStatus"; @@ -11,24 +11,44 @@ import { type ProcessingStatusBadgeProps = { processingStatus: ProcessingStatus; - plannedDestructionDate: string | null; + plannedDestructionDate?: string | null; }; export const ProcessingStatusBadge: React.FC = ({ processingStatus, plannedDestructionDate, }) => { + const getLevel = () => { + if (processingStatus === "new" && plannedDestructionDate) { + return "warning"; + } + return PROCESSING_STATUS_LEVEL_MAPPING[processingStatus]; + }; + + const getStatusIcon = () => { + if (processingStatus === "new" && plannedDestructionDate) { + return ; + } + return PROCESSING_STATUS_ICON_MAPPING[processingStatus]; + }; + const getStatusText = () => { if (processingStatus === "new" && plannedDestructionDate) { - return `Vernietigd ${timeAgo(plannedDestructionDate ?? "", { shortFormat: true })}`; + const isPlannedDestructionDateInPast = + new Date(plannedDestructionDate) < new Date(); + if (isPlannedDestructionDateInPast) { + return `Wordt vernietigd`; + } + return `Wordt vernietigd ${timeAgo(plannedDestructionDate, { shortFormat: true })}`; } return field2Title(PROCESSING_STATUS_MAPPING[processingStatus], { unHyphen: false, }); }; + return ( - - {PROCESSING_STATUS_ICON_MAPPING[processingStatus]} + + {getStatusIcon()} {getStatusText()} ); diff --git a/frontend/src/fixtures/destructionListItem.ts b/frontend/src/fixtures/destructionListItem.ts index e552dd49..fe08291f 100644 --- a/frontend/src/fixtures/destructionListItem.ts +++ b/frontend/src/fixtures/destructionListItem.ts @@ -19,7 +19,7 @@ export const FIXTURE_DESTRUCTION_LIST_ITEM_DELETED: DestructionListItem = { extraZaakData: null, zaak: null, processingStatus: "succeeded", - plannedDestructionDate: null, + plannedDestructionDate: "2026-01-01T00:00:00Z", }; export const FIXTURE_DESTRUCTION_LIST_ITEM_FAILED: DestructionListItem = { pk: 3, @@ -27,7 +27,7 @@ export const FIXTURE_DESTRUCTION_LIST_ITEM_FAILED: DestructionListItem = { extraZaakData: null, zaak: zaakFactory(), processingStatus: "failed", - plannedDestructionDate: null, + plannedDestructionDate: "2026-01-01T00:00:00Z", }; export const destructionListItemFactory = createObjectFactory( diff --git a/frontend/src/lib/format/date.test.ts b/frontend/src/lib/format/date.test.ts index 8e3f11ea..91db6b50 100644 --- a/frontend/src/lib/format/date.test.ts +++ b/frontend/src/lib/format/date.test.ts @@ -19,6 +19,7 @@ describe("timeAgo()", () => { beforeEach(mockDate); afterEach(unMockDate); + // Year tests (past and future) test("timeAgo() handles year ", () => { const yearAgo = new Date("2022-09-15:00:00"); expect(timeAgo(yearAgo)).toBe("1 jaar geleden"); @@ -27,8 +28,17 @@ describe("timeAgo()", () => { const yearsAgo = new Date("2021-09-15:00:00"); expect(timeAgo(yearsAgo)).toBe("2 jaren geleden"); expect(timeAgo(yearsAgo, { shortFormat: true })).toBe("2j"); + + const yearAhead = new Date("2024-09-15:00:00"); + expect(timeAgo(yearAhead)).toBe("over 1 jaar"); + expect(timeAgo(yearAhead, { shortFormat: true })).toBe("over 1j"); + + const yearsAhead = new Date("2025-09-15:00:00"); + expect(timeAgo(yearsAhead)).toBe("over 2 jaren"); + expect(timeAgo(yearsAhead, { shortFormat: true })).toBe("over 2j"); }); + // Month tests (past and future) test("timeAgo() handles month ", () => { const monthAgo = new Date("2023-08-15:00:00"); expect(timeAgo(monthAgo)).toBe("1 maand geleden"); @@ -37,8 +47,17 @@ describe("timeAgo()", () => { const monthsAgo = new Date("2023-07-15:00:00"); expect(timeAgo(monthsAgo)).toBe("2 maanden geleden"); expect(timeAgo(monthsAgo, { shortFormat: true })).toBe("2ma"); + + const monthAhead = new Date("2023-10-15:00:00"); + expect(timeAgo(monthAhead)).toBe("over 1 maand"); + expect(timeAgo(monthAhead, { shortFormat: true })).toBe("over 1ma"); + + const monthsAhead = new Date("2023-11-15:00:00"); + expect(timeAgo(monthsAhead)).toBe("over 2 maanden"); + expect(timeAgo(monthsAhead, { shortFormat: true })).toBe("over 2ma"); }); + // Day tests (past and future) test("timeAgo() handles day ", () => { const dayAgo = new Date("2023-09-14:00:00"); expect(timeAgo(dayAgo)).toBe("1 dag geleden"); @@ -47,8 +66,17 @@ describe("timeAgo()", () => { const daysAgo = new Date("2023-09-13:00:00"); expect(timeAgo(daysAgo)).toBe("2 dagen geleden"); expect(timeAgo(daysAgo, { shortFormat: true })).toBe("2d"); + + const dayAhead = new Date("2023-09-16:00:00"); + expect(timeAgo(dayAhead)).toBe("over 1 dag"); + expect(timeAgo(dayAhead, { shortFormat: true })).toBe("over 1d"); + + const daysAhead = new Date("2023-09-17:00:00"); + expect(timeAgo(daysAhead)).toBe("over 2 dagen"); + expect(timeAgo(daysAhead, { shortFormat: true })).toBe("over 2d"); }); + // Hour tests (past and future) test("timeAgo() handles hour ", () => { const hourAgo = new Date("2023-09-14:23:00"); expect(timeAgo(hourAgo)).toBe("1 uur geleden"); @@ -57,8 +85,17 @@ describe("timeAgo()", () => { const hoursAgo = new Date("2023-09-14:22:00"); expect(timeAgo(hoursAgo)).toBe("2 uur geleden"); expect(timeAgo(hoursAgo, { shortFormat: true })).toBe("2u"); + + const hourAhead = new Date("2023-09-15:01:00"); + expect(timeAgo(hourAhead)).toBe("over 1 uur"); + expect(timeAgo(hourAhead, { shortFormat: true })).toBe("over 1u"); + + const hoursAhead = new Date("2023-09-15:02:00"); + expect(timeAgo(hoursAhead)).toBe("over 2 uur"); + expect(timeAgo(hoursAhead, { shortFormat: true })).toBe("over 2u"); }); + // Minute tests (past and future) test("timeAgo() handles minute ", () => { const minuteAgo = new Date("2023-09-14:23:59"); expect(timeAgo(minuteAgo)).toBe("1 minuut geleden"); @@ -67,8 +104,17 @@ describe("timeAgo()", () => { const minutesAgo = new Date("2023-09-14:23:58"); expect(timeAgo(minutesAgo)).toBe("2 minuten geleden"); expect(timeAgo(minutesAgo, { shortFormat: true })).toBe("2m"); + + const minuteAhead = new Date("2023-09-15:00:01"); + expect(timeAgo(minuteAhead)).toBe("over 1 minuut"); + expect(timeAgo(minuteAhead, { shortFormat: true })).toBe("over 1m"); + + const minutesAhead = new Date("2023-09-15:00:02"); + expect(timeAgo(minutesAhead)).toBe("over 2 minuten"); + expect(timeAgo(minutesAhead, { shortFormat: true })).toBe("over 2m"); }); + // Less than a minute tests (past and future) test("timeAgo() handles less than a minute ", () => { const secondAgo = new Date("2023-09-14:23:59:59"); expect(timeAgo(secondAgo)).toBe("Nu"); @@ -77,14 +123,17 @@ describe("timeAgo()", () => { const secondsAgo = new Date("2023-09-14:23:59:59"); expect(timeAgo(secondsAgo)).toBe("Nu"); expect(timeAgo(secondsAgo, { shortFormat: true })).toBe("0m"); - }); - test("timeAgo() interprets future data as now ", () => { - const yearFromNow = new Date("2024-09-15:00:00"); - expect(timeAgo(yearFromNow)).toBe("Nu"); - expect(timeAgo(yearFromNow, { shortFormat: true })).toBe("0m"); + const secondAhead = new Date("2023-09-15:00:00:01"); + expect(timeAgo(secondAhead)).toBe("zo meteen"); + expect(timeAgo(secondAhead, { shortFormat: true })).toBe("0m"); + + const secondsAhead = new Date("2023-09-15:00:00:02"); + expect(timeAgo(secondsAhead)).toBe("zo meteen"); + expect(timeAgo(secondsAhead, { shortFormat: true })).toBe("0m"); }); + // Combined scenarios (past and future) test("timeAgo() handles combined scenario ", () => { const yearAgo = new Date("2022-08-14:23:59:59"); expect(timeAgo(yearAgo)).toBe("1 jaar geleden"); @@ -93,6 +142,14 @@ describe("timeAgo()", () => { const monthsAgo = new Date("2023-07-13:22:58:58"); expect(timeAgo(monthsAgo)).toBe("2 maanden geleden"); expect(timeAgo(monthsAgo, { shortFormat: true })).toBe("2ma"); + + const yearAhead = new Date("2025-08-14:23:59:59"); + expect(timeAgo(yearAhead)).toBe("over 1 jaar"); + expect(timeAgo(yearAhead, { shortFormat: true })).toBe("over 1j"); + + const monthsAhead = new Date("2023-11-13:22:58:58"); + expect(timeAgo(monthsAhead)).toBe("over 1 maand"); + expect(timeAgo(monthsAhead, { shortFormat: true })).toBe("over 1ma"); }); }); diff --git a/frontend/src/lib/format/date.ts b/frontend/src/lib/format/date.ts index 47a90fc4..ae1876d9 100644 --- a/frontend/src/lib/format/date.ts +++ b/frontend/src/lib/format/date.ts @@ -25,6 +25,7 @@ interface TimeAgoOptions { /** * Calculate how long ago or how long until a given date and return a human-readable string in Dutch. * The date can be provided as a Date object or an ISO 8601 string. + * Note that this function does currently not show dates like "1 jaar 1 maand 1 dag geleden", but would rather show "1 jaar geleden" * TODO: Consider using a specialized library. * * @param dateInput - The date to calculate the time difference from. It can be a Date object or an ISO 8601 string. @@ -48,12 +49,13 @@ export function timeAgo( let seconds = Math.floor((now.getTime() - date.getTime()) / 1000); const { shortFormat = false } = options; + // Define the intervals in seconds for various time units const intervals = [ { label: "jaar", plural: "jaren", shortFormat: "j", seconds: 31536000 }, - { label: "maand", plural: "maanden", shortFormat: "mnd", seconds: 2592000 }, + { label: "maand", plural: "maanden", shortFormat: "ma", seconds: 2592000 }, { label: "week", plural: "weken", shortFormat: "w", seconds: 604800 }, { label: "dag", plural: "dagen", shortFormat: "d", seconds: 86400 }, - { label: "uur", plural: "uren", shortFormat: "u", seconds: 3600 }, + { label: "uur", plural: "uur", shortFormat: "u", seconds: 3600 }, { label: "minuut", plural: "minuten", shortFormat: "m", seconds: 60 }, { label: "seconde", plural: "seconden", shortFormat: "s", seconds: 1 }, ]; @@ -61,11 +63,14 @@ export function timeAgo( let result = ""; let isFuture = false; - // If the time difference is positive, the date is in the past - // If the time difference is negative, the date is in the future if (seconds < 0) { isFuture = true; - seconds = Math.abs(seconds); // Work with positive time difference for calculation + seconds = Math.abs(seconds); + } + + // Special case for "Nu" or "zo meteen" + if (seconds < 60) { + return shortFormat ? "0m" : isFuture ? "zo meteen" : "Nu"; } // Iterate over the intervals to determine the appropriate time unit @@ -79,19 +84,23 @@ export function timeAgo( ? interval.label : interval.plural; + // Check if it's future or past if (isFuture) { result = `over ${intervalCount}${shortFormat ? "" : " "}${label}`; } else { - result = `${intervalCount}${shortFormat ? "" : " "}${label}`; + // Special case to not include "geleden" for the short format + if (shortFormat) { + result = `${intervalCount}${shortFormat ? "" : " "}${label}`; + } else { + result = `${intervalCount}${shortFormat ? "" : " "}${label} geleden`; + } } break; } } - // Return the result or default to "just now" or "0m" for short format - return ( - result.trim() || (shortFormat ? "0m" : isFuture ? "zo meteen" : "zojuist") - ); + // Return the formatted time difference + return result.trim(); } /** diff --git a/frontend/src/pages/destructionlist/detail/DestructionListDetail.stories.tsx b/frontend/src/pages/destructionlist/detail/DestructionListDetail.stories.tsx index 3d07688d..c14f1b7d 100644 --- a/frontend/src/pages/destructionlist/detail/DestructionListDetail.stories.tsx +++ b/frontend/src/pages/destructionlist/detail/DestructionListDetail.stories.tsx @@ -414,7 +414,10 @@ export const DeleteDestructionList: Story = { delay: 10, }, ); - await waitFor(async () => await expect(submit).not.toBeDisabled()); + await waitFor(async () => { + const isDisabled = submit.getAttribute("disabled"); + expect(isDisabled).toBe(""); + }); }, }; @@ -450,3 +453,61 @@ export const DeleteFailedDestructionList: Story = { }, }, }; + +const FIXTURE_CANCEL_PLANNED_DESTRUCTION: DestructionListDetailContext = { + storageKey: "storybook-storage-key", + + destructionList: destructionListFactory({ + status: "ready_to_delete", + processingStatus: "new", + plannedDestructionDate: "2026-01-01T00:00:00Z", + }), + destructionListItems: paginatedDestructionListItemsFactory(), + logItems: auditLogFactory(), + + zaakSelection: {}, + selectableZaken: paginatedZakenFactory(), + + archivists: usersFactory(), + reviewers: usersFactory(), + user: usersFactory()[0], + + review: null, + reviewItems: null, + + selectieLijstKlasseChoicesMap: null, +}; + +export const CancelPlannedDestruction: Story = { + parameters: { + reactRouterDecorator: { + route: { + loader: async () => FIXTURE_CANCEL_PLANNED_DESTRUCTION, + }, + }, + }, + play: async (context) => { + const canvas = within(context.canvasElement); + const vernietigingStarten = await canvas.findByText( + "Vernietigen starten", + ); + expect(vernietigingStarten).toBeDisabled(); + await clickButton({ + ...context, + parameters: { + ...context.parameters, + name: "Vernietigen annuleren", + }, + }); + await userEvent.click(document.activeElement as HTMLInputElement, { + delay: 10, + }); + userEvent.type(document.activeElement as HTMLInputElement, "Test Comment", { + delay: 10, + }); + const submit = await canvas.findByText( + "Vernietigen annuleren", + ); + expect(submit).not.toBeDisabled(); + }, +}; diff --git a/frontend/src/pages/destructionlist/detail/DestructionListDetail.tsx b/frontend/src/pages/destructionlist/detail/DestructionListDetail.tsx index 4c434fb6..81c01ab8 100644 --- a/frontend/src/pages/destructionlist/detail/DestructionListDetail.tsx +++ b/frontend/src/pages/destructionlist/detail/DestructionListDetail.tsx @@ -195,6 +195,7 @@ export function DestructionListDetailPage() {