From 2308f8d7e397e62b7ef87c741fd748653ffa37cd Mon Sep 17 00:00:00 2001 From: Julian Roeland Date: Thu, 19 Sep 2024 16:37:19 +0200 Subject: [PATCH 1/4] :sparkles: - feat: implemented wachtperiode voor vernietigingsproces --- .../ProcessingStatusBadge.tsx | 15 +++++++-- frontend/src/fixtures/destructionList.ts | 1 + frontend/src/fixtures/destructionListItem.ts | 3 ++ frontend/src/lib/api/destructionLists.ts | 1 + frontend/src/lib/api/destructionListsItem.ts | 1 + frontend/src/lib/auth/permissions.ts | 15 +++++++++ frontend/src/lib/format/date.ts | 31 +++++++++++++------ .../detail/DestructionListDetail.tsx | 1 + .../DestructionListToolbar.tsx | 12 ++++--- .../hooks/useDataGridProps.tsx | 5 ++- frontend/src/pages/landing/Landing.tsx | 12 +++++-- 11 files changed, 77 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/ProcessingStatusBadge/ProcessingStatusBadge.tsx b/frontend/src/components/ProcessingStatusBadge/ProcessingStatusBadge.tsx index 1762950e..0d5dd31b 100644 --- a/frontend/src/components/ProcessingStatusBadge/ProcessingStatusBadge.tsx +++ b/frontend/src/components/ProcessingStatusBadge/ProcessingStatusBadge.tsx @@ -2,6 +2,7 @@ import { Badge, field2Title } from "@maykin-ui/admin-ui"; import React from "react"; import { ProcessingStatus } from "../../lib/api/processingStatus"; +import { timeAgo } from "../../lib/format/date"; import { PROCESSING_STATUS_ICON_MAPPING, PROCESSING_STATUS_LEVEL_MAPPING, @@ -10,17 +11,25 @@ import { type ProcessingStatusBadgeProps = { processingStatus: ProcessingStatus; + plannedDestructionDate: string | null; }; export const ProcessingStatusBadge: React.FC = ({ processingStatus, + plannedDestructionDate, }) => { + const getStatusText = () => { + if (processingStatus === "new" && plannedDestructionDate) { + return `Vernietigd ${timeAgo(plannedDestructionDate ?? "", { shortFormat: true })}`; + } + return field2Title(PROCESSING_STATUS_MAPPING[processingStatus], { + unHyphen: false, + }); + }; return ( {PROCESSING_STATUS_ICON_MAPPING[processingStatus]} - {field2Title(PROCESSING_STATUS_MAPPING[processingStatus], { - unHyphen: false, - })} + {getStatusText()} ); }; diff --git a/frontend/src/fixtures/destructionList.ts b/frontend/src/fixtures/destructionList.ts index 224314e6..6a24078c 100644 --- a/frontend/src/fixtures/destructionList.ts +++ b/frontend/src/fixtures/destructionList.ts @@ -11,6 +11,7 @@ const FIXTURE_DESTRUCTION_LIST: DestructionList = { containsSensitiveInfo: false, status: "changes_requested", processingStatus: "new", + plannedDestructionDate: null, assignees: defaultAssignees, assignee: defaultAssignees[0].user, created: "2024-07-11T16:57", diff --git a/frontend/src/fixtures/destructionListItem.ts b/frontend/src/fixtures/destructionListItem.ts index 6dc0e698..e552dd49 100644 --- a/frontend/src/fixtures/destructionListItem.ts +++ b/frontend/src/fixtures/destructionListItem.ts @@ -11,6 +11,7 @@ export const FIXTURE_DESTRUCTION_LIST_ITEM: DestructionListItem = { extraZaakData: null, zaak: zaakFactory(), processingStatus: "new", + plannedDestructionDate: null, }; export const FIXTURE_DESTRUCTION_LIST_ITEM_DELETED: DestructionListItem = { pk: 2, @@ -18,6 +19,7 @@ export const FIXTURE_DESTRUCTION_LIST_ITEM_DELETED: DestructionListItem = { extraZaakData: null, zaak: null, processingStatus: "succeeded", + plannedDestructionDate: null, }; export const FIXTURE_DESTRUCTION_LIST_ITEM_FAILED: DestructionListItem = { pk: 3, @@ -25,6 +27,7 @@ export const FIXTURE_DESTRUCTION_LIST_ITEM_FAILED: DestructionListItem = { extraZaakData: null, zaak: zaakFactory(), processingStatus: "failed", + plannedDestructionDate: null, }; export const destructionListItemFactory = createObjectFactory( diff --git a/frontend/src/lib/api/destructionLists.ts b/frontend/src/lib/api/destructionLists.ts index 2a9b61f5..70ff2810 100644 --- a/frontend/src/lib/api/destructionLists.ts +++ b/frontend/src/lib/api/destructionLists.ts @@ -12,6 +12,7 @@ export type DestructionList = { assignees: DestructionListAssignee[]; author: User; containsSensitiveInfo: boolean; + plannedDestructionDate: string | null; created: string; name: string; status: DestructionListStatus; diff --git a/frontend/src/lib/api/destructionListsItem.ts b/frontend/src/lib/api/destructionListsItem.ts index 99d8a29f..661c2217 100644 --- a/frontend/src/lib/api/destructionListsItem.ts +++ b/frontend/src/lib/api/destructionListsItem.ts @@ -9,6 +9,7 @@ export type DestructionListItem = { extraZaakData?: Record | null; zaak: Zaak | null; processingStatus: ProcessingStatus; + plannedDestructionDate: string | null; }; export interface ZaakItem extends Zaak { diff --git a/frontend/src/lib/auth/permissions.ts b/frontend/src/lib/auth/permissions.ts index d30ae850..6fa06b17 100644 --- a/frontend/src/lib/auth/permissions.ts +++ b/frontend/src/lib/auth/permissions.ts @@ -63,6 +63,14 @@ export function canUpdateDestructionList( return false; } + if ( + destructionList.status === "ready_to_delete" && + destructionList.plannedDestructionDate && + destructionList.processingStatus === "new" + ) { + return false; + } + if (!STATUSES_ELIGIBLE_FOR_EDIT.includes(destructionList.status)) { return false; } @@ -92,6 +100,13 @@ export function canTriggerDestruction( user: User, destructionList: DestructionList, ) { + if ( + destructionList.status === "ready_to_delete" && + destructionList.plannedDestructionDate && + destructionList.processingStatus === "new" + ) { + return false; + } return ( user.pk === destructionList.author.pk && destructionList.status === "ready_to_delete" && diff --git a/frontend/src/lib/format/date.ts b/frontend/src/lib/format/date.ts index a330413a..bd33a468 100644 --- a/frontend/src/lib/format/date.ts +++ b/frontend/src/lib/format/date.ts @@ -23,7 +23,8 @@ interface TimeAgoOptions { } /** - * Calculate how long ago a given date was and return a human-readable string. + * 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. * 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. @@ -39,7 +40,7 @@ export function timeAgo( // Check for invalid date input if (isNaN(date.getTime())) { - throw new Error("Invalid date input"); + throw new Error("Ongeldige datum input"); } const now = new Date(); @@ -47,17 +48,25 @@ 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: "ma", seconds: 2592000 }, + { label: "maand", plural: "maanden", shortFormat: "mnd", seconds: 2592000 }, { label: "week", plural: "weken", shortFormat: "w", seconds: 604800 }, { label: "dag", plural: "dagen", shortFormat: "d", seconds: 86400 }, - { label: "uur", plural: "uur", shortFormat: "u", seconds: 3600 }, + { label: "uur", plural: "uren", shortFormat: "u", seconds: 3600 }, { label: "minuut", plural: "minuten", shortFormat: "m", seconds: 60 }, + { label: "seconde", plural: "seconden", shortFormat: "s", seconds: 1 }, ]; 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 + } // Iterate over the intervals to determine the appropriate time unit for (const interval of intervals) { @@ -70,15 +79,19 @@ export function timeAgo( ? interval.label : interval.plural; - result += `${intervalCount}${shortFormat ? "" : " "}${label}${shortFormat ? "" : " geleden"}`; - // Update seconds to the remainder for the next interval - seconds %= interval.seconds; + if (isFuture) { + result = `over ${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" : "Nu"); + return ( + result.trim() || (shortFormat ? "0m" : isFuture ? "zo meteen" : "zojuist") + ); } /** diff --git a/frontend/src/pages/destructionlist/detail/DestructionListDetail.tsx b/frontend/src/pages/destructionlist/detail/DestructionListDetail.tsx index a7c798a6..15b13c99 100644 --- a/frontend/src/pages/destructionlist/detail/DestructionListDetail.tsx +++ b/frontend/src/pages/destructionlist/detail/DestructionListDetail.tsx @@ -170,6 +170,7 @@ export function DestructionListDetailPage() { ), "spacer", diff --git a/frontend/src/pages/destructionlist/detail/components/DestructionListToolbar/DestructionListToolbar.tsx b/frontend/src/pages/destructionlist/detail/components/DestructionListToolbar/DestructionListToolbar.tsx index 2a3a6564..be8bff15 100644 --- a/frontend/src/pages/destructionlist/detail/components/DestructionListToolbar/DestructionListToolbar.tsx +++ b/frontend/src/pages/destructionlist/detail/components/DestructionListToolbar/DestructionListToolbar.tsx @@ -11,6 +11,7 @@ import { } from "@maykin-ui/admin-ui"; import { useLoaderData } from "react-router-dom"; +import { ProcessingStatusBadge } from "../../../../../components"; import { AuditLogItem } from "../../../../../lib/api/auditLog"; import { User } from "../../../../../lib/api/auth"; import { DestructionListRead } from "../../../../../lib/api/destructionLists"; @@ -21,8 +22,6 @@ import { formatUser } from "../../../../../lib/format/user"; import { REVIEW_DECISION_LEVEL_MAPPING, REVIEW_DECISION_MAPPING, - STATUS_LEVEL_MAPPING, - STATUS_MAPPING, } from "../../../../constants"; import { DestructionListAuditLog } from "../DestructionListAuditLog"; import { DestructionListReviewer } from "../index"; @@ -66,9 +65,12 @@ export function DestructionListToolbar({ title }: DestructionListToolbarProps) { status: { label: "Status", value: ( - - {STATUS_MAPPING[destructionList.status]} - + ), }, aangemaakt: { diff --git a/frontend/src/pages/destructionlist/hooks/useDataGridProps.tsx b/frontend/src/pages/destructionlist/hooks/useDataGridProps.tsx index cf56879c..0da3fae0 100644 --- a/frontend/src/pages/destructionlist/hooks/useDataGridProps.tsx +++ b/frontend/src/pages/destructionlist/hooks/useDataGridProps.tsx @@ -190,7 +190,10 @@ export function useDataGridProps( return { ...(item.zaak ? formatZaak(item.zaak) : item.extraZaakData), processingStatus: ( - + ), }; } diff --git a/frontend/src/pages/landing/Landing.tsx b/frontend/src/pages/landing/Landing.tsx index eaba3074..945b6c7d 100644 --- a/frontend/src/pages/landing/Landing.tsx +++ b/frontend/src/pages/landing/Landing.tsx @@ -33,6 +33,7 @@ export type LandingKanbanEntry = { key: string; onClick: () => void; disabled: boolean; + plannedDestructionDate: string | null; processingStatus: ProcessingStatus; title: string; timeAgo: string; @@ -180,6 +181,7 @@ export const Landing = () => { onClick: () => navigate(href), disabled: !href, processingStatus: list.processingStatus, + plannedDestructionDate: list.plannedDestructionDate, title: list.name, timeAgo: timeAgo(list.created), assignees: otherAssignees.length ? ( @@ -248,7 +250,10 @@ export const Landing = () => { renderPreview: (object: AttributeData) => { const entry = object as LandingKanbanEntry; - if (entry.processingStatus === "new") { + if ( + entry.processingStatus === "new" && + !entry.plannedDestructionDate + ) { return ( @@ -257,7 +262,10 @@ export const Landing = () => { ); } return ( - + ); }, }} From 561ff5c2c5998d6f1be6a3eae7c8168a7080df86 Mon Sep 17 00:00:00 2001 From: Julian Roeland Date: Fri, 20 Sep 2024 15:54:46 +0200 Subject: [PATCH 2/4] :sparkles: - feat: cancel --- frontend/src/lib/api/destructionLists.ts | 16 ++ frontend/src/lib/auth/permissions.ts | 7 - .../detail/DestructionListDetail.action.ts | 17 ++ .../detail/DestructionListDetail.tsx | 170 +++++++++++++----- 4 files changed, 158 insertions(+), 52 deletions(-) diff --git a/frontend/src/lib/api/destructionLists.ts b/frontend/src/lib/api/destructionLists.ts index 70ff2810..c0c02229 100644 --- a/frontend/src/lib/api/destructionLists.ts +++ b/frontend/src/lib/api/destructionLists.ts @@ -221,3 +221,19 @@ export async function reassignDestructionList( ) { return request("POST", `/destruction-lists/${uuid}/reassign/`, {}, data); } + +/** + * Abort the destruction of a destruction list. + * @param uuid + */ +export async function abortPlannedDestruction( + uuid: string, + data: { comment: string }, +) { + return request( + "POST", + `/destruction-lists/${uuid}/abort_destruction/`, + {}, + data, + ); +} diff --git a/frontend/src/lib/auth/permissions.ts b/frontend/src/lib/auth/permissions.ts index 6fa06b17..9fea67e7 100644 --- a/frontend/src/lib/auth/permissions.ts +++ b/frontend/src/lib/auth/permissions.ts @@ -100,13 +100,6 @@ export function canTriggerDestruction( user: User, destructionList: DestructionList, ) { - if ( - destructionList.status === "ready_to_delete" && - destructionList.plannedDestructionDate && - destructionList.processingStatus === "new" - ) { - return false; - } return ( user.pk === destructionList.author.pk && destructionList.status === "ready_to_delete" && diff --git a/frontend/src/pages/destructionlist/detail/DestructionListDetail.action.ts b/frontend/src/pages/destructionlist/detail/DestructionListDetail.action.ts index 889c2206..7efe32bc 100644 --- a/frontend/src/pages/destructionlist/detail/DestructionListDetail.action.ts +++ b/frontend/src/pages/destructionlist/detail/DestructionListDetail.action.ts @@ -5,6 +5,7 @@ import { JsonValue, TypedAction } from "../../../hooks"; import { User } from "../../../lib/api/auth"; import { DestructionListItemUpdate, + abortPlannedDestruction, destroyDestructionList, markDestructionListAsFinal, markDestructionListAsReadyToReview, @@ -19,6 +20,7 @@ import { clearZaakSelection } from "../../../lib/zaakSelection/zaakSelection"; export type UpdateDestructionListAction

= TypedAction< | "DESTROY" + | "CANCEL_DESTROY" | "MAKE_FINAL" | "PROCESS_REVIEW" | "READY_TO_REVIEW" @@ -53,6 +55,8 @@ export async function destructionListUpdateAction({ return await destructionListUpdateReviewerAction({ request, params }); case "UPDATE_ZAKEN": return await destructionListUpdateZakenAction({ request, params }); + case "CANCEL_DESTROY": + return await destructionListCancelDestroyAction({ request, params }); default: throw new Error("INVALID ACTION TYPE SPECIFIED!"); } @@ -181,3 +185,16 @@ export async function destructionListUpdateZakenAction({ return redirect(`/destruction-lists/${params.uuid}/`); } + +export async function destructionListCancelDestroyAction({ + request, +}: ActionFunctionArgs) { + const data = await request.json(); + const { payload } = data as UpdateDestructionListAction<{ + uuid: string; + comment: string; + }>; + const { comment, uuid } = payload; + await abortPlannedDestruction(uuid, { comment }); + return redirect(`/destruction-lists/${uuid}/`); +} diff --git a/frontend/src/pages/destructionlist/detail/DestructionListDetail.tsx b/frontend/src/pages/destructionlist/detail/DestructionListDetail.tsx index 15b13c99..4c434fb6 100644 --- a/frontend/src/pages/destructionlist/detail/DestructionListDetail.tsx +++ b/frontend/src/pages/destructionlist/detail/DestructionListDetail.tsx @@ -3,11 +3,9 @@ import { Body, Button, CardBaseTemplate, - Column, ErrorMessage, Form, FormField, - Grid, Modal, P, SerializedFormData, @@ -74,6 +72,8 @@ export function DestructionListDetailPage() { ] = useState(false); const [destroyModalOpenState, setDestroyModalOpenState] = useState(false); + const [cancelDestroyModalOpenState, setCancelDestroyModalOpenState] = + useState(false); const isInReview = destructionList.status === "changes_requested"; // An object of {url: string} items used to indicate (additional) selected zaken. @@ -110,6 +110,14 @@ export function DestructionListDetailPage() { }, ]; + const cancelModalFormFields: FormField[] = [ + { + label: "Opmerking", + name: "comment", + required: true, + }, + ]; + /** * Returns the items to show in the secondary navigation (top bar). */ @@ -182,25 +190,40 @@ export function DestructionListDetailPage() { ) : ( <> ), - ["new", "failed"].includes(destructionList.processingStatus) ? ( - { - bold: true, - children: ( - <> + <> + {["new", "failed"].includes(destructionList.processingStatus) ? ( + + + {isPlannedForDestruction() && ( + + )} + + ) : ( + <> + )} + , ]; } }; @@ -214,6 +237,17 @@ export function DestructionListDetailPage() { }); }; + const isPlannedForDestruction = () => { + if ( + destructionList.status === "ready_to_delete" && + destructionList.plannedDestructionDate && + destructionList.processingStatus === "new" + ) { + return true; + } + return false; + }; + /** * Dispatches action to mark the destruction list as final (archivist approves). */ @@ -312,6 +346,22 @@ export function DestructionListDetailPage() { }); }; + /** + * Dispatches action to cancel the destruction of all zaken on the destruction list. + */ + const handleCancelDestroy = async ( + _: FormEvent, + data: SerializedFormData, + ) => { + submitAction({ + type: "CANCEL_DESTROY", + payload: { + uuid: destructionList.uuid, + comment: data.comment as string, + }, + }); + }; + return ( @@ -382,34 +432,64 @@ export function DestructionListDetailPage() { )} - {destructionList.status === "ready_to_delete" && ( - setDestroyModalOpenState(false)} - > - -

- U staat op het punt om {destructionListItems.count} zaken - definitief te vernietigen. -

- - -
- - - )} + {destructionList.status === "ready_to_delete" && + !destructionList.plannedDestructionDate && ( + setDestroyModalOpenState(false)} + > + +

+ U staat op het punt om {destructionListItems.count} zaken + definitief te vernietigen. +

+ + + + +
+ )} + {destructionList.status === "ready_to_delete" && + destructionList.plannedDestructionDate && + destructionList.processingStatus === "new" && ( + setCancelDestroyModalOpenState(false)} + > + +

+ U staat op het punt om de vernietiging van{" "} + {destructionListItems.count} zaken te annuleren. +

+ + + + +
+ )} ); } From 2898fadf94e38e1c27b602095d83e148a650930a Mon Sep 17 00:00:00 2001 From: Julian Roeland Date: Tue, 24 Sep 2024 13:02:11 +0200 Subject: [PATCH 3/4] :bug: - fix: fix build --- frontend/src/lib/format/date.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/format/date.ts b/frontend/src/lib/format/date.ts index bd33a468..47a90fc4 100644 --- a/frontend/src/lib/format/date.ts +++ b/frontend/src/lib/format/date.ts @@ -82,7 +82,7 @@ export function timeAgo( if (isFuture) { result = `over ${intervalCount}${shortFormat ? "" : " "}${label}`; } else { - result = `${intervalCount}${shortFormat ? "" : " "}${label} geleden`; + result = `${intervalCount}${shortFormat ? "" : " "}${label}`; } break; } From 47b5ca42e84112fcb88196132f5d64ca3738c89b Mon Sep 17 00:00:00 2001 From: Julian Roeland Date: Tue, 24 Sep 2024 13:07:05 +0200 Subject: [PATCH 4/4] :ok_hand: - fix: pr fixes --- .../ProcessingStatusBadge.tsx | 30 ++++++-- frontend/src/fixtures/destructionListItem.ts | 4 +- frontend/src/lib/format/date.test.ts | 67 ++++++++++++++-- frontend/src/lib/format/date.ts | 29 ++++--- .../detail/DestructionListDetail.stories.tsx | 63 ++++++++++++++- .../detail/DestructionListDetail.tsx | 77 ++++++++++--------- .../hooks/useDataGridProps.tsx | 5 +- 7 files changed, 210 insertions(+), 65 deletions(-) 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() {