From 4ce31d32674488a49beedc44a6a8d061da3e6a99 Mon Sep 17 00:00:00 2001 From: Cy-fox Date: Tue, 21 Apr 2026 13:54:57 +0100 Subject: [PATCH 01/26] =?UTF-8?q?=E2=9C=A8=20add=20name=20field=20to=20opp?= =?UTF-8?q?ortunity=20details=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #340 --- public/locales/de/translations.json | 3 +++ public/locales/en/translations.json | 3 +++ .../agent/AgentContactDetails.tsx | 4 ++-- .../OpportunityDetailsDisplay.tsx | 8 +++++++ .../OpportunityDetailsEdit.tsx | 24 +++++++++++++++++-- .../opportunityDetailsSchema.ts | 1 + src/hooks/useUpdateOpportunityTitle.ts | 16 +++++++++++++ 7 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 src/hooks/useUpdateOpportunityTitle.ts diff --git a/public/locales/de/translations.json b/public/locales/de/translations.json index a3a58ba0..6a242348 100644 --- a/public/locales/de/translations.json +++ b/public/locales/de/translations.json @@ -1063,6 +1063,7 @@ }, "opportunityDetails": { "title": "Details der Möglichkeit", + "opportunityName": "Name", "description": "Beschreibung", "mainCommunication": "Hauptkommunikation", "residentsSpeak": "Bewohner sprechen", @@ -1075,7 +1076,9 @@ "descriptionHint": "Max. {{max}} Zeichen", "cancel": "Abbrechen", "saveChanges": "Änderungen speichern", + "saveSuccess": "Möglichkeit erfolgreich aktualisiert", "validation": { + "opportunityNameRequired": "Name ist erforderlich", "descriptionRequired": "Beschreibung ist erforderlich", "descriptionTooLong": "Beschreibung ist zu lang", "numberOfVolunteersRequired": "Anzahl der Freiwilligen muss mindestens 1 sein", diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 01a4133d..2c43fd5c 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -1062,6 +1062,7 @@ }, "opportunityDetails": { "title": "Opportunity details", + "opportunityName": "Name", "description": "Description", "mainCommunication": "Main communication", "residentsSpeak": "Residents speak", @@ -1074,7 +1075,9 @@ "descriptionHint": "Max. {{max}} characters", "cancel": "Cancel", "saveChanges": "Save changes", + "saveSuccess": "Opportunity updated successfully", "validation": { + "opportunityNameRequired": "Name is required", "descriptionRequired": "Description is required", "descriptionTooLong": "Description is too long", "numberOfVolunteersRequired": "Number of volunteers must be at least 1", diff --git a/src/components/Dashboard/Profile/sections/ContactDetails/agent/AgentContactDetails.tsx b/src/components/Dashboard/Profile/sections/ContactDetails/agent/AgentContactDetails.tsx index 00a58ada..56fb36a3 100644 --- a/src/components/Dashboard/Profile/sections/ContactDetails/agent/AgentContactDetails.tsx +++ b/src/components/Dashboard/Profile/sections/ContactDetails/agent/AgentContactDetails.tsx @@ -30,7 +30,7 @@ export const AgentContactDetails = forwardRef(function ref, ) { const { t } = useTranslation(); - const { mutate: updateAgent, isPending } = useUpdateAgentContact(String(agent?.representative?.id)); + const { mutate: updateAgent, isPending } = useUpdateAgentContact(String(agent?.representatives?.[0]?.id)); const [isEditing, setIsEditing] = useState(false); useEditingChangeNotifier(isEditing, onEditingChange); @@ -42,7 +42,7 @@ export const AgentContactDetails = forwardRef(function const schema = createAgentContactDetailsSchema(t); - const initialFormValues = agent?.representative; + const initialFormValues = agent?.representatives?.[0]; const methods = useForm({ resolver: zodResolver(schema), diff --git a/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsDisplay.tsx b/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsDisplay.tsx index ddc77ad9..6354a4b8 100644 --- a/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsDisplay.tsx +++ b/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsDisplay.tsx @@ -30,6 +30,14 @@ export function OpportunityDetailsDisplay({ opportunity }: Props) { return ( + {}} + /> + { - // Mutations will be added later + const onSubmit = (data: OpportunityDetailsFormData) => { + if (data.title !== opp.title) { + updateTitle({ title: data.title }); + } onCancel(); }; return ( <> + ( + + )} + /> + string) => export const createOpportunityDetailsSchema = (t: (key: string) => string) => z.object({ + title: z.string().min(1, t(`${i18nPrefix}.opportunityNameRequired`)), description: z .string() .min(1, t(`${i18nPrefix}.descriptionRequired`)) diff --git a/src/hooks/useUpdateOpportunityTitle.ts b/src/hooks/useUpdateOpportunityTitle.ts new file mode 100644 index 00000000..127cd77b --- /dev/null +++ b/src/hooks/useUpdateOpportunityTitle.ts @@ -0,0 +1,16 @@ +import { apiPathOpportunity } from "@/config/constants"; +import { useMutationQuery } from "@/hooks"; +import { ApiOpportunityGet } from "need4deed-sdk"; + +export type OpportunityTitleUpdateData = { + title: string; +}; + +export const useUpdateOpportunityTitle = (opportunityId: ApiOpportunityGet["id"]) => { + return useMutationQuery({ + apiPath: `${apiPathOpportunity}/${opportunityId}`, + method: "patch", + successMessage: "dashboard.opportunityProfile.opportunityDetails.saveSuccess", + queryKeyToInvalidate: ["opportunity", String(opportunityId)], + }); +}; From c57a8939c3f2da33136f1c8c4506f58a467b4ad1 Mon Sep 17 00:00:00 2001 From: Cy-fox Date: Tue, 21 Apr 2026 14:30:29 +0100 Subject: [PATCH 02/26] fix: revert representative to singular property name --- .../sections/ContactDetails/agent/AgentContactDetails.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Dashboard/Profile/sections/ContactDetails/agent/AgentContactDetails.tsx b/src/components/Dashboard/Profile/sections/ContactDetails/agent/AgentContactDetails.tsx index 56fb36a3..00a58ada 100644 --- a/src/components/Dashboard/Profile/sections/ContactDetails/agent/AgentContactDetails.tsx +++ b/src/components/Dashboard/Profile/sections/ContactDetails/agent/AgentContactDetails.tsx @@ -30,7 +30,7 @@ export const AgentContactDetails = forwardRef(function ref, ) { const { t } = useTranslation(); - const { mutate: updateAgent, isPending } = useUpdateAgentContact(String(agent?.representatives?.[0]?.id)); + const { mutate: updateAgent, isPending } = useUpdateAgentContact(String(agent?.representative?.id)); const [isEditing, setIsEditing] = useState(false); useEditingChangeNotifier(isEditing, onEditingChange); @@ -42,7 +42,7 @@ export const AgentContactDetails = forwardRef(function const schema = createAgentContactDetailsSchema(t); - const initialFormValues = agent?.representatives?.[0]; + const initialFormValues = agent?.representative; const methods = useForm({ resolver: zodResolver(schema), From 1e9f3f0a73770eb8851e73f01556443b892cd5f7 Mon Sep 17 00:00:00 2001 From: Cy-fox Date: Wed, 22 Apr 2026 10:35:46 +0100 Subject: [PATCH 03/26] fix: replace district free-text with dropdown in RAC section District field in the Refugee Accommodation Centre edit form was a plain text input, making it impossible to consistently pick a valid district. Replaced with a radio-list dropdown fed from the districts API. Submission now sends the district ID instead of a raw string. Closes #357 --- .../RefugeeAccommodationCentre.tsx | 8 ++++++++ .../RefugeeAccommodationCentreEdit.tsx | 6 ++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/Dashboard/Profile/sections/RefugeeAccommodationCentre/RefugeeAccommodationCentre.tsx b/src/components/Dashboard/Profile/sections/RefugeeAccommodationCentre/RefugeeAccommodationCentre.tsx index a9c5f901..91d299ed 100644 --- a/src/components/Dashboard/Profile/sections/RefugeeAccommodationCentre/RefugeeAccommodationCentre.tsx +++ b/src/components/Dashboard/Profile/sections/RefugeeAccommodationCentre/RefugeeAccommodationCentre.tsx @@ -8,6 +8,8 @@ import { useTranslation } from "react-i18next"; import { FormContainer } from "../shared/styles"; import { EditableSectionProps, EditableSectionRef } from "../shared/types"; import { useEditingChangeNotifier } from "../shared/useEditingChangeNotifier"; +import { useApiDistricts } from "../VolunteerProfile/hooks"; +import { createMapping } from "../VolunteerProfile/mappingUtils"; import { RefugeeAccommodationCentreDisplay } from "./RefugeeAccommodationCentreDisplay"; import { RefugeeAccommodationCentreEdit } from "./RefugeeAccommodationCentreEdit"; import { @@ -29,6 +31,9 @@ export const RefugeeAccommodationCentre = forwardRef( useEditingChangeNotifier(isEditing, onEditingChange); + const { data: apiDistricts = [] } = useApiDistricts(); + const districtMapping = useMemo(() => createMapping(apiDistricts), [apiDistricts]); + const schema = createRefugeeAccommodationCentreSchema(t); const initialFormValues = useMemo((): RefugeeAccommodationCentreFormData => { @@ -61,11 +66,13 @@ export const RefugeeAccommodationCentre = forwardRef( }; const onSubmit = (values: RefugeeAccommodationCentreFormData) => { + const districtId = districtMapping.titleToId[values.district]; updateAgent( { agent: { name: values.name, address: values.address, + ...(districtId && { district: districtId }), }, }, { onSuccess: () => setIsEditing(false) }, @@ -85,6 +92,7 @@ export const RefugeeAccommodationCentre = forwardRef( onCancel={handleCancel} onSubmit={handleSubmit(onSubmit)} isPending={isPending} + districts={apiDistricts.map((d) => d.title)} /> ) : ( diff --git a/src/components/Dashboard/Profile/sections/RefugeeAccommodationCentre/RefugeeAccommodationCentreEdit.tsx b/src/components/Dashboard/Profile/sections/RefugeeAccommodationCentre/RefugeeAccommodationCentreEdit.tsx index 00a79fc0..a556ad17 100644 --- a/src/components/Dashboard/Profile/sections/RefugeeAccommodationCentre/RefugeeAccommodationCentreEdit.tsx +++ b/src/components/Dashboard/Profile/sections/RefugeeAccommodationCentre/RefugeeAccommodationCentreEdit.tsx @@ -9,9 +9,10 @@ type Props = { onCancel: () => void; onSubmit: () => void; isPending: boolean; + districts: string[]; }; -export const RefugeeAccommodationCentreEdit = ({ onCancel, onSubmit, isPending }: Props) => { +export const RefugeeAccommodationCentreEdit = ({ onCancel, onSubmit, isPending, districts }: Props) => { const { t } = useTranslation(); const { control, @@ -57,10 +58,11 @@ export const RefugeeAccommodationCentreEdit = ({ onCancel, onSubmit, isPending } render={({ field }) => ( )} From e770a7e3e2991c6056793059df03a80a6e057404 Mon Sep 17 00:00:00 2001 From: Cy-fox Date: Fri, 24 Apr 2026 09:22:24 +0100 Subject: [PATCH 04/26] fix: use strict undefined check for districtId before spreading --- .../RefugeeAccommodationCentre/RefugeeAccommodationCentre.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Dashboard/Profile/sections/RefugeeAccommodationCentre/RefugeeAccommodationCentre.tsx b/src/components/Dashboard/Profile/sections/RefugeeAccommodationCentre/RefugeeAccommodationCentre.tsx index 91d299ed..11a82c5a 100644 --- a/src/components/Dashboard/Profile/sections/RefugeeAccommodationCentre/RefugeeAccommodationCentre.tsx +++ b/src/components/Dashboard/Profile/sections/RefugeeAccommodationCentre/RefugeeAccommodationCentre.tsx @@ -72,7 +72,7 @@ export const RefugeeAccommodationCentre = forwardRef( agent: { name: values.name, address: values.address, - ...(districtId && { district: districtId }), + ...(districtId !== undefined && { district: districtId }), }, }, { onSuccess: () => setIsEditing(false) }, From ac5007f4904efbdc82f021bb0972c8924bcb9774 Mon Sep 17 00:00:00 2001 From: Rodrigo Louro Date: Mon, 20 Apr 2026 12:06:27 +0200 Subject: [PATCH 05/26] Fix: No ZIP/PLZ-code for Accompanying details #344 Fix: No ZIP/PLZ-code for Accompanying details #344 --- public/locales/de/translations.json | 1 + public/locales/en/translations.json | 1 + .../AccompanyingDetailsDisplay.tsx | 8 ++++++++ .../AccompanyingDetailsEdit.tsx | 19 ++++++++++++++++--- .../accompanyingDetailsSchema.ts | 1 + .../sections/AccompanyingDetails/helpers.ts | 1 + 6 files changed, 28 insertions(+), 3 deletions(-) diff --git a/public/locales/de/translations.json b/public/locales/de/translations.json index 6a242348..56790f71 100644 --- a/public/locales/de/translations.json +++ b/public/locales/de/translations.json @@ -1024,6 +1024,7 @@ "accompanyingDetailsTitle": "Begleitdetails", "accompanyingDetails": { "appointmentAddress": "Terminadresse", + "appointmentPostcode": "Terminpostleitzahl", "appointmentDate": "Termindatum", "appointmentTime": "Terminzeit", "refugeeNumber": "Geflüchtetennummer", diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 2c43fd5c..2b05aa04 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -1023,6 +1023,7 @@ "accompanyingDetailsTitle": "Accompanying details", "accompanyingDetails": { "appointmentAddress": "Appointment address", + "appointmentPostcode": "Appointment postcode", "appointmentDate": "Appointment date", "appointmentTime": "Appointment time", "refugeeNumber": "Refugee number", diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx index 64e9d9fd..1a22ae05 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx @@ -23,6 +23,14 @@ export const AccompanyingDetailsDisplay = ({ values, languageLabel }: Props) => setValue={() => {}} /> + {}} + /> + {values.appointmentDate ? format(values.appointmentDate, "dd.MM.yyyy") : EMPTY_PLACEHOLDER_VALUE} diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsEdit.tsx b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsEdit.tsx index bcff065b..dce6d0b9 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsEdit.tsx +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsEdit.tsx @@ -60,6 +60,21 @@ export const AccompanyingDetailsEdit = ({ )} /> + }) => ( + + )} + /> + {errors.appointmentTime && ( - {t( - `dashboard.opportunityProfile.accompanyingDetails.validation.${errors.appointmentTime.message}`, - )} + {t(`dashboard.opportunityProfile.accompanyingDetails.validation.${errors.appointmentTime.message}`)} )} diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/accompanyingDetailsSchema.ts b/src/components/Dashboard/Profile/sections/AccompanyingDetails/accompanyingDetailsSchema.ts index f36c634c..558db99c 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/accompanyingDetailsSchema.ts +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/accompanyingDetailsSchema.ts @@ -4,6 +4,7 @@ const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/; export const accompanyingDetailsSchema = z.object({ appointmentAddress: z.string().optional(), + appointmentPostcode: z.string().optional(), appointmentDate: z.date().nullable().optional(), appointmentTime: z .string() diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts index 26c76c85..0de55107 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts @@ -38,6 +38,7 @@ export const getInitialFormValues = ( details: ApiOpportunityAccompanyingDetails | undefined, ): AccompanyingDetailsFormData => ({ appointmentAddress: details?.appointmentAddress || "", + appointmentPostcode: "", appointmentDate: parseDate(details?.appointmentDate), appointmentTime: parseTime(details?.appointmentTime), refugeeNumber: details?.refugeeNumber || "", From 47807494e02f84682e1588090bd3db8a7f5a07a6 Mon Sep 17 00:00:00 2001 From: Rodrigo Louro Date: Tue, 21 Apr 2026 15:18:12 +0200 Subject: [PATCH 06/26] Fix: helpers.ts Fix: helpers.ts --- .../Dashboard/Profile/sections/AccompanyingDetails/helpers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts index 0de55107..e1140a02 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts @@ -38,7 +38,8 @@ export const getInitialFormValues = ( details: ApiOpportunityAccompanyingDetails | undefined, ): AccompanyingDetailsFormData => ({ appointmentAddress: details?.appointmentAddress || "", - appointmentPostcode: "", + appointmentPostcode: + (details as ApiOpportunityAccompanyingDetails & { appointmentPostcode?: string })?.appointmentPostcode || "", appointmentDate: parseDate(details?.appointmentDate), appointmentTime: parseTime(details?.appointmentTime), refugeeNumber: details?.refugeeNumber || "", From 83fc6632c0150270f94528b613ecf892b02e18c9 Mon Sep 17 00:00:00 2001 From: nadavosa Date: Fri, 24 Apr 2026 13:54:07 +0200 Subject: [PATCH 07/26] fix: preserve local time for accomp_datetime instead of converting to UTC datetime-local input values are local time strings. Wrapping them in new Date().toISOString() converts to UTC, causing appointment times to display 2 hours early for UTC+2 users (CEST). Passing the value directly matches how onetime_date_time is handled in the same file. Closes #393 Co-Authored-By: Claude Sonnet 4.6 --- src/components/forms/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/forms/utils.ts b/src/components/forms/utils.ts index 3ddefce8..7a08e1e3 100644 --- a/src/components/forms/utils.ts +++ b/src/components/forms/utils.ts @@ -156,7 +156,7 @@ export function parseFormStateDTOOpportunity(form: OpportunityData): Opportunity // Accompanying-specific fields const accomp_address = isAccompanying ? form.aaAddress || null : null; const accomp_postcode = isAccompanying ? form.aaPostcode || null : null; - const accomp_datetime = isAccompanying ? (form.dateTime ? new Date(form.dateTime).toISOString() : null) : null; + const accomp_datetime = isAccompanying ? (form.dateTime || null) : null; const accomp_name = isAccompanying ? form.refugeeName || null : null; const accomp_phone = isAccompanying ? form.refugeeNumber || null : null; const accomp_information = isAccompanying ? form.aaInformation || null : null; From 5608b31dade8ed726dba45cefef42b72d1f638e0 Mon Sep 17 00:00:00 2001 From: DarrellRoberts Date: Thu, 23 Apr 2026 12:54:34 +0200 Subject: [PATCH 08/26] adds helper func to get activity titles from ids --- .../Opportunities/OpportunityCard.tsx | 19 +++++++++++-------- .../Opportunities/OpportunityCardList.tsx | 6 ++++-- .../OpportunityListController.tsx | 1 + .../Dashboard/Opportunities/helpers.ts | 16 +++++++++++++++- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/components/Dashboard/Opportunities/OpportunityCard.tsx b/src/components/Dashboard/Opportunities/OpportunityCard.tsx index 8a42731c..91d00875 100644 --- a/src/components/Dashboard/Opportunities/OpportunityCard.tsx +++ b/src/components/Dashboard/Opportunities/OpportunityCard.tsx @@ -1,4 +1,4 @@ -import { ApiVolunteerOpportunityGetList, LangPurpose, ProfileVolunteeringType } from "need4deed-sdk"; +import { ApiVolunteerOpportunityGetList, LangPurpose, OptionItem, ProfileVolunteeringType } from "need4deed-sdk"; import { useRouter } from "next/navigation"; import { useTranslation } from "react-i18next"; @@ -7,7 +7,7 @@ import { Paragraph } from "@/components/styled/text"; import CardDetail from "../Volunteers/CardDetail"; import { CardParagraph } from "../Volunteers/VolunteerCard"; import { IconName } from "../Volunteers/icon"; -import { getLanguagesByPurpose, getOptionTitles } from "./helpers"; +import { getActivityTitles, getLanguagesByPurpose, getOptionTitles } from "./helpers"; import { formatAccompanyingDate, formatAvailability, @@ -20,9 +20,10 @@ import { Card, LanguageRow, StatusDiv, StatusTagsDiv, TagDiv, TitleParagraph } f type Props = { opportunity: ApiVolunteerOpportunityGetList; volunteerId?: string; + activitiesList?: OptionItem[]; }; -export function OpportunityCard({ opportunity, volunteerId }: Props) { +export function OpportunityCard({ opportunity, volunteerId, activitiesList }: Props) { const { t, i18n } = useTranslation(); const router = useRouter(); @@ -40,8 +41,8 @@ export function OpportunityCard({ opportunity, volunteerId }: Props) { const mainCommunication = getLanguagesByPurpose(languages, LangPurpose.GENERAL); const recipientLanguage = getLanguagesByPurpose(languages, LangPurpose.RECIPIENT); - const activityTitles = getOptionTitles(activities); const locationTitles = getOptionTitles(location); + const activityTitles = getActivityTitles(activities, activitiesList); const isAccompanying = volunteerType === ProfileVolunteeringType.ACCOMPANYING; const scheduleText = isAccompanying @@ -55,7 +56,7 @@ export function OpportunityCard({ opportunity, volunteerId }: Props) { const params = volunteerId ? `?volunteer=${volunteerId}` : ""; router.push(`/${i18n.language}/dashboard/opportunities/${id}${params}`); }; - + console.log(opportunity); return ( @@ -103,9 +104,11 @@ export function OpportunityCard({ opportunity, volunteerId }: Props) { )} - - - + {activityTitles.length > 0 && ( + + + + )} {scheduleText && } diff --git a/src/components/Dashboard/Opportunities/OpportunityCardList.tsx b/src/components/Dashboard/Opportunities/OpportunityCardList.tsx index 2bda28ae..84997d58 100644 --- a/src/components/Dashboard/Opportunities/OpportunityCardList.tsx +++ b/src/components/Dashboard/Opportunities/OpportunityCardList.tsx @@ -1,9 +1,10 @@ -import { ApiVolunteerOpportunityGetList } from "need4deed-sdk"; +import { ApiVolunteerOpportunityGetList, OptionItem } from "need4deed-sdk"; import { PaginatedGrid } from "@/components/core/paginatedGrid"; import { OpportunityCard } from "./OpportunityCard"; import { OpportunityCardListContainer } from "./styles"; type Props = { + activitiesList?: OptionItem[]; opportunities: ApiVolunteerOpportunityGetList[]; count: number; columns: number; @@ -21,9 +22,10 @@ export function OpportunityCardList({ currentPage, setCurrentPage, volunteerId, + activitiesList, }: Props) { const items = opportunities.map((opp) => ( - + )); return ( diff --git a/src/components/Dashboard/Opportunities/OpportunityListController.tsx b/src/components/Dashboard/Opportunities/OpportunityListController.tsx index 80c8e1b1..4f52d085 100644 --- a/src/components/Dashboard/Opportunities/OpportunityListController.tsx +++ b/src/components/Dashboard/Opportunities/OpportunityListController.tsx @@ -58,6 +58,7 @@ export function OpportunityListController({ return ( (typeof item.title === "string" ? item.title : "")).filter(Boolean); } + +export function getActivityTitles(activities: OptionById[], activityList: OptionItem[] | undefined): string[] { + if (!activities?.length || !activityList?.length) return []; + const activityMap = new Map(activityList.map((item) => [item.id, item.title])); + return activities.map((act) => activityMap.get(Number(act.id))).filter((title): title is string => Boolean(title)); +} From 326003339f1a76f235d462f1a85fe095d1e5cd24 Mon Sep 17 00:00:00 2001 From: DarrellRoberts Date: Thu, 23 Apr 2026 13:05:22 +0200 Subject: [PATCH 09/26] removes console log --- .../Dashboard/Opportunities/OpportunityCard.tsx | 2 +- src/components/Dashboard/Opportunities/helpers.ts | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/components/Dashboard/Opportunities/OpportunityCard.tsx b/src/components/Dashboard/Opportunities/OpportunityCard.tsx index 91d00875..6ba62439 100644 --- a/src/components/Dashboard/Opportunities/OpportunityCard.tsx +++ b/src/components/Dashboard/Opportunities/OpportunityCard.tsx @@ -56,7 +56,7 @@ export function OpportunityCard({ opportunity, volunteerId, activitiesList }: Pr const params = volunteerId ? `?volunteer=${volunteerId}` : ""; router.push(`/${i18n.language}/dashboard/opportunities/${id}${params}`); }; - console.log(opportunity); + return ( diff --git a/src/components/Dashboard/Opportunities/helpers.ts b/src/components/Dashboard/Opportunities/helpers.ts index 3eb0b2f5..dd42d31e 100644 --- a/src/components/Dashboard/Opportunities/helpers.ts +++ b/src/components/Dashboard/Opportunities/helpers.ts @@ -1,12 +1,4 @@ -import { - ActivityAPI, - ApiLanguage, - ApiOptionLists, - LangPurpose, - OptionById, - OptionItem, - QueryParamsKeys, -} from "need4deed-sdk"; +import { ApiLanguage, ApiOptionLists, LangPurpose, OptionById, OptionItem, QueryParamsKeys } from "need4deed-sdk"; import { ReadonlyURLSearchParams } from "next/navigation"; import { OpportunityCardsFilter } from "./Filters/types"; From 8b49fcadc799dc0f7fdaf5eb0762a7e16d175a4c Mon Sep 17 00:00:00 2001 From: DarrellRoberts Date: Thu, 23 Apr 2026 16:58:41 +0200 Subject: [PATCH 10/26] adds activity filter to opp-list --- public/locales/de/translations.json | 1 + public/locales/en/translations.json | 1 + .../Opportunities/Filters/FiltersContent.tsx | 9 +++---- .../Opportunities/Filters/constants.ts | 3 ++- .../Opportunities/Filters/helpers.ts | 20 ++++++++++------ .../Dashboard/Opportunities/Filters/types.ts | 3 ++- .../Dashboard/Opportunities/Opportunities.tsx | 3 ++- .../Dashboard/Opportunities/helpers.ts | 24 ++++++++++++++++++- 8 files changed, 47 insertions(+), 17 deletions(-) diff --git a/public/locales/de/translations.json b/public/locales/de/translations.json index 56790f71..07ac7b9c 100644 --- a/public/locales/de/translations.json +++ b/public/locales/de/translations.json @@ -348,6 +348,7 @@ "accompanyingDesc": "Nur Freiwillige anzeigen, die bereit sind, Bewohner zu einzelnen Terminen zu begleiten", "district": "Bezirk", "languages": "Sprachen", + "activities": "Aktivitäten", "engagement": { "header": "Engagement", "active": "Aktiv", diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 2b05aa04..90d879ac 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -348,6 +348,7 @@ "accompanyingDesc": "Only show volunteers that are open to bring residents to individual appointments", "district": "District", "languages": "Languages", + "activities": "Activities", "engagement": { "header": "Engagement", "active": "Active", diff --git a/src/components/Dashboard/Opportunities/Filters/FiltersContent.tsx b/src/components/Dashboard/Opportunities/Filters/FiltersContent.tsx index a6483a90..798c3ae4 100644 --- a/src/components/Dashboard/Opportunities/Filters/FiltersContent.tsx +++ b/src/components/Dashboard/Opportunities/Filters/FiltersContent.tsx @@ -13,18 +13,15 @@ type Props = { export default function FiltersContent({ setFilter, filter }: Props) { const { t } = useTranslation(); - const { districtFilters, languageFilters, statusFilters, typeFilters } = createOpportunityFilterItems( - filter, - setFilter, - t, - ); - + const { districtFilters, languageFilters, statusFilters, typeFilters, activityFilters } = + createOpportunityFilterItems(filter, setFilter, t); return ( + ); } diff --git a/src/components/Dashboard/Opportunities/Filters/constants.ts b/src/components/Dashboard/Opportunities/Filters/constants.ts index ef49499a..4dae3c67 100644 --- a/src/components/Dashboard/Opportunities/Filters/constants.ts +++ b/src/components/Dashboard/Opportunities/Filters/constants.ts @@ -1,4 +1,4 @@ -import { OpportunityStatusType, OpportunityType, QueryParamsKeys } from "need4deed-sdk"; +import { EntityTableName, OpportunityStatusType, OpportunityType, QueryParamsKeys } from "need4deed-sdk"; import { OpportunityCardsFilter } from "./types"; export const defaultOpportunityCardsFilter: OpportunityCardsFilter = { @@ -16,4 +16,5 @@ export const defaultOpportunityCardsFilter: OpportunityCardsFilter = { [OpportunityType.EVENTS]: false, [OpportunityType.REGULAR]: false, }, + [EntityTableName.ACTIVITY]: {}, }; diff --git a/src/components/Dashboard/Opportunities/Filters/helpers.ts b/src/components/Dashboard/Opportunities/Filters/helpers.ts index 9009cd13..48536219 100644 --- a/src/components/Dashboard/Opportunities/Filters/helpers.ts +++ b/src/components/Dashboard/Opportunities/Filters/helpers.ts @@ -1,7 +1,7 @@ import { TFunction } from "i18next"; import { generateNestedFilterControlItems } from "../../common/CardsFilter/helpers"; import { SetFilter } from "../../common/CardsFilter/types"; -import { QueryParamsKeys } from "need4deed-sdk"; +import { EntityTableName, QueryParamsKeys } from "need4deed-sdk"; import { OpportunityCardsFilter } from "./types"; export const createOpportunityFilterItems = ( @@ -31,7 +31,14 @@ export const createOpportunityFilterItems = ( t(`dashboard.opportunities.filters.type.${key}`), ); - return { districtFilters, languageFilters, statusFilters, typeFilters }; + const activityFilters = generateNestedFilterControlItems( + filter[EntityTableName.ACTIVITY], + setFilter, + EntityTableName.ACTIVITY, + (key) => key, + ); + + return { districtFilters, languageFilters, statusFilters, typeFilters, activityFilters }; }; export const createSelectedOpportunityFiltersAsFlatArray = ( @@ -39,10 +46,9 @@ export const createSelectedOpportunityFiltersAsFlatArray = ( setFilter: SetFilter, t: TFunction, ) => { - const { districtFilters, languageFilters, statusFilters, typeFilters } = createOpportunityFilterItems( - filter, - setFilter, - t, + const { districtFilters, languageFilters, statusFilters, typeFilters, activityFilters } = + createOpportunityFilterItems(filter, setFilter, t); + return [...districtFilters, ...languageFilters, ...statusFilters, ...typeFilters, ...activityFilters].filter( + (f) => f.checked, ); - return [...districtFilters, ...languageFilters, ...statusFilters, ...typeFilters].filter((f) => f.checked); }; diff --git a/src/components/Dashboard/Opportunities/Filters/types.ts b/src/components/Dashboard/Opportunities/Filters/types.ts index f8229393..0e9b9bd6 100644 --- a/src/components/Dashboard/Opportunities/Filters/types.ts +++ b/src/components/Dashboard/Opportunities/Filters/types.ts @@ -1,4 +1,4 @@ -import { QueryParamsKeys } from "need4deed-sdk"; +import { EntityTableName, QueryParamsKeys } from "need4deed-sdk"; import { SelectionMap } from "../../common/CardsFilter/types"; export interface OpportunityCardsFilter { @@ -7,6 +7,7 @@ export interface OpportunityCardsFilter { [QueryParamsKeys.LANGUAGE]: SelectionMap; status: SelectionMap; type: SelectionMap; + [EntityTableName.ACTIVITY]: SelectionMap; } export type OpportunityCardFilterKeys = keyof OpportunityCardsFilter; diff --git a/src/components/Dashboard/Opportunities/Opportunities.tsx b/src/components/Dashboard/Opportunities/Opportunities.tsx index e593e3d2..85f7fe57 100644 --- a/src/components/Dashboard/Opportunities/Opportunities.tsx +++ b/src/components/Dashboard/Opportunities/Opportunities.tsx @@ -72,7 +72,8 @@ export function Opportunities() { setCardsFilter((prev) => { const district = createFilterFromOption(apiFilterOptions, EntityTableName.DISTRICT); const language = createFilterFromOption(apiFilterOptions, EntityTableName.LANGUAGE); - return { ...prev, district, language }; + const activity = createFilterFromOption(apiFilterOptions, EntityTableName.ACTIVITY); + return { ...prev, district, language, activity }; }); }, [apiFilterOptions]); diff --git a/src/components/Dashboard/Opportunities/helpers.ts b/src/components/Dashboard/Opportunities/helpers.ts index dd42d31e..7b55dff9 100644 --- a/src/components/Dashboard/Opportunities/helpers.ts +++ b/src/components/Dashboard/Opportunities/helpers.ts @@ -1,4 +1,12 @@ -import { ApiLanguage, ApiOptionLists, LangPurpose, OptionById, OptionItem, QueryParamsKeys } from "need4deed-sdk"; +import { + ApiLanguage, + ApiOptionLists, + EntityTableName, + LangPurpose, + OptionById, + OptionItem, + QueryParamsKeys, +} from "need4deed-sdk"; import { ReadonlyURLSearchParams } from "next/navigation"; import { OpportunityCardsFilter } from "./Filters/types"; @@ -50,6 +58,15 @@ export function serializeOpportunityFilters( } }); + params.delete(EntityTableName.ACTIVITY); + Object.entries(filter.activity).forEach(([key, value]) => { + if (value === true) { + const paramValue = + (options?.serializeToIDs && options.apiFilterOptions?.activity?.find((d) => d.title === key)?.id) || key; + params.append(EntityTableName.ACTIVITY, String(paramValue)); + } + }); + return asString ? params.toString() : params; } @@ -82,6 +99,11 @@ export function deserializeOpportunityFilters( if (newFilter.type[s] !== undefined) newFilter.type[s] = true; }); + const queryActivities = searchParams.getAll(EntityTableName.ACTIVITY); + queryActivities.forEach((l) => { + if (newFilter.activity[l] !== undefined) newFilter.activity[l] = true; + }); + return newFilter; } From cbf8aac43571bbf109ec9a1732cef0501f7fc743 Mon Sep 17 00:00:00 2001 From: DarrellRoberts Date: Thu, 23 Apr 2026 22:37:12 +0200 Subject: [PATCH 11/26] adds String to ids & removes undefined from deserialFunc --- src/components/Dashboard/Opportunities/helpers.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/Dashboard/Opportunities/helpers.ts b/src/components/Dashboard/Opportunities/helpers.ts index 7b55dff9..bf24446c 100644 --- a/src/components/Dashboard/Opportunities/helpers.ts +++ b/src/components/Dashboard/Opportunities/helpers.ts @@ -81,27 +81,27 @@ export function deserializeOpportunityFilters( const queryDistricts = searchParams.getAll(QueryParamsKeys.DISTRICT); queryDistricts.forEach((d) => { - if (newFilter.district[d] !== undefined) newFilter.district[d] = true; + newFilter.district[d] = true; }); const queryLanguages = searchParams.getAll(QueryParamsKeys.LANGUAGE); queryLanguages.forEach((l) => { - if (newFilter.language[l] !== undefined) newFilter.language[l] = true; + newFilter.language[l] = true; }); const queryStatus = searchParams.getAll("status"); queryStatus.forEach((s) => { - if (newFilter.status[s] !== undefined) newFilter.status[s] = true; + newFilter.status[s] = true; }); const queryType = searchParams.getAll("type"); queryType.forEach((s) => { - if (newFilter.type[s] !== undefined) newFilter.type[s] = true; + newFilter.type[s] = true; }); const queryActivities = searchParams.getAll(EntityTableName.ACTIVITY); queryActivities.forEach((l) => { - if (newFilter.activity[l] !== undefined) newFilter.activity[l] = true; + newFilter.activity[l] = true; }); return newFilter; @@ -122,6 +122,6 @@ export function getOptionTitles(items: OptionById[] | undefined): string[] { export function getActivityTitles(activities: OptionById[], activityList: OptionItem[] | undefined): string[] { if (!activities?.length || !activityList?.length) return []; - const activityMap = new Map(activityList.map((item) => [item.id, item.title])); - return activities.map((act) => activityMap.get(Number(act.id))).filter((title): title is string => Boolean(title)); + const activityMap = new Map(activityList.map((item) => [String(item.id), item.title])); + return activities.map((act) => activityMap.get(String(act.id))).filter((title): title is string => Boolean(title)); } From b3026604c870c7655dcc7fee2e1e9654d4adc48f Mon Sep 17 00:00:00 2001 From: Cy-fox Date: Wed, 22 Apr 2026 09:50:50 +0100 Subject: [PATCH 12/26] =?UTF-8?q?=F0=9F=90=9B=20preserve=20language=20purp?= =?UTF-8?q?ose=20when=20saving=20volunteer=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Language purpose (GENERAL/TRANSLATION) was stripped from the form data during submission, causing saved languages to lose their purpose field. New languages default to GENERAL purpose. Closes #367 --- .../Dashboard/Profile/sections/VolunteerProfile/formatters.ts | 1 + .../Profile/sections/VolunteerProfile/transformers.ts | 3 ++- .../sections/VolunteerProfile/volunteerProfileSchema.ts | 2 ++ src/types.ts | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/Dashboard/Profile/sections/VolunteerProfile/formatters.ts b/src/components/Dashboard/Profile/sections/VolunteerProfile/formatters.ts index c2816678..7057771a 100644 --- a/src/components/Dashboard/Profile/sections/VolunteerProfile/formatters.ts +++ b/src/components/Dashboard/Profile/sections/VolunteerProfile/formatters.ts @@ -31,6 +31,7 @@ export function formatLanguages( id: lang.id, language: String(dbId), level: proficiencyToLevel[lang.proficiency?.toLowerCase() || "native"] || LanguageLevel.NATIVE, + purpose: lang.purpose, }; }); } diff --git a/src/components/Dashboard/Profile/sections/VolunteerProfile/transformers.ts b/src/components/Dashboard/Profile/sections/VolunteerProfile/transformers.ts index 99064b5f..acb35076 100644 --- a/src/components/Dashboard/Profile/sections/VolunteerProfile/transformers.ts +++ b/src/components/Dashboard/Profile/sections/VolunteerProfile/transformers.ts @@ -1,5 +1,5 @@ import { TFunction } from "i18next"; -import { ApiLanguage, ApiVolunteerGet, VolunteerStateTypeType } from "need4deed-sdk"; +import { ApiLanguage, ApiVolunteerGet, LangPurpose, VolunteerStateTypeType } from "need4deed-sdk"; import { apiToFormAvailability } from "./availabilityUtils"; import { LEVEL_TO_PROFICIENCY } from "./constants"; import { formatActivities, formatDistricts, formatLanguages, formatSkills, getVolunteerTypeLabel } from "./formatters"; @@ -52,6 +52,7 @@ export function transformLanguagesToApi(languages: VolunteerProfileFormData["lan id: parseInt(lang.language, 10), title: languageMapping.idToTitle[parseInt(lang.language, 10)] || "", proficiency: LEVEL_TO_PROFICIENCY[lang.level as unknown as number], + purpose: lang.purpose ?? LangPurpose.GENERAL, }) as ApiLanguage, ); } diff --git a/src/components/Dashboard/Profile/sections/VolunteerProfile/volunteerProfileSchema.ts b/src/components/Dashboard/Profile/sections/VolunteerProfile/volunteerProfileSchema.ts index b8c965ae..ea056bd2 100644 --- a/src/components/Dashboard/Profile/sections/VolunteerProfile/volunteerProfileSchema.ts +++ b/src/components/Dashboard/Profile/sections/VolunteerProfile/volunteerProfileSchema.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { Availability } from "@/components/forms/types"; import { LanguageLevel, LanguageObject } from "@/types"; +import { LangPurpose } from "need4deed-sdk"; export const createVolunteerProfileSchema = (t: (key: string) => string) => { return z.object({ @@ -10,6 +11,7 @@ export const createVolunteerProfileSchema = (t: (key: string) => string) => { id: z.number(), language: z.string(), level: z.union([z.enum(LanguageLevel), z.literal("")]), + purpose: z.nativeEnum(LangPurpose).optional(), }) satisfies z.ZodType, ) .min(1, t("dashboard.volunteerProfile.profileSection.validation.languageRequired")) diff --git a/src/types.ts b/src/types.ts index 52e98db1..ed1588f7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,6 +50,7 @@ export type LanguageObject = { id: number; language: string; level: LanguageLevel | ""; + purpose?: import("need4deed-sdk").LangPurpose; }; export type FooterLink = [string, string]; From 1f07b6d3b40be7b287ca8570ddd31be7434c5ec8 Mon Sep 17 00:00:00 2001 From: Cy-fox Date: Wed, 22 Apr 2026 13:31:12 +0100 Subject: [PATCH 13/26] fix: fetch and display connected opportunities on agent profile --- .../AgentOpportunities/AgentOpportunities.tsx | 53 ++++++++++++------- src/hooks/useAgentProfileSections.tsx | 2 +- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/components/Dashboard/Profile/sections/AgentOpportunities/AgentOpportunities.tsx b/src/components/Dashboard/Profile/sections/AgentOpportunities/AgentOpportunities.tsx index dda42fd7..7bfa43e2 100644 --- a/src/components/Dashboard/Profile/sections/AgentOpportunities/AgentOpportunities.tsx +++ b/src/components/Dashboard/Profile/sections/AgentOpportunities/AgentOpportunities.tsx @@ -1,32 +1,49 @@ -import { OpportunityVolunteerStatusType } from "need4deed-sdk"; +import { Heading4 } from "@/components/styled/text"; +import { apiPathAgent, cacheTTL } from "@/config/constants"; +import { useGetQuery } from "@/hooks/useGetQuery"; +import { ApiOpportunityGetList, Id } from "need4deed-sdk"; +import { useRouter } from "next/navigation"; import { useTranslation } from "react-i18next"; +import { Accordion } from "../shared/Accordion"; import { SectionEmptyState } from "../shared/styles"; -import { Tabs } from "../shared/Tabs"; -import { useTabTransitions } from "../shared/useTabTransitions"; import { AgentOpportunitiesContainer } from "./styles"; -const tabsKeys = ["lookingForVolunteers", "active", "past"] as const; +type Props = { agentId: Id }; -const agentTabStatusOrder: OpportunityVolunteerStatusType[] = [ - OpportunityVolunteerStatusType.PENDING, - OpportunityVolunteerStatusType.ACTIVE, - OpportunityVolunteerStatusType.PAST, -]; +export const AgentOpportunities = ({ agentId }: Props) => { + const { t, i18n } = useTranslation(); + const router = useRouter(); -export const AgentOpportunities = () => { - const { t } = useTranslation(); + const { data, isLoading } = useGetQuery({ + queryKey: ["agent-opportunities", String(agentId)], + apiPath: `${apiPathAgent}/${agentId}/opportunity-linked`, + staleTime: cacheTTL, + enabled: !!agentId, + addLang: false, + }); - const { selectedTabIndex, setSelectedTabIndex, tabCounts } = useTabTransitions([], agentTabStatusOrder); + const opportunities = data ?? []; - const tabs = tabsKeys.map((key, index) => ({ - label: t(`dashboard.agentProfile.opportunitiesSec.tabs.${key}`), - count: tabCounts[index], - })); + if (isLoading) return ; return ( - - {t("dashboard.volunteerProfile.opportunitiesSec.emptyState")} + {opportunities.length === 0 ? ( + {t("dashboard.volunteerProfile.opportunitiesSec.emptyState")} + ) : ( + opportunities.map((opp) => ( + + {opp.title} + + } + subtitle={t(`dashboard.opportunities.status.${opp.statusOpportunity}`)} + onGoToProfile={() => router.push(`/${i18n.language}/dashboard/opportunities/${opp.id}`)} + /> + )) + )} ); }; diff --git a/src/hooks/useAgentProfileSections.tsx b/src/hooks/useAgentProfileSections.tsx index c89d4a55..94ce73ab 100644 --- a/src/hooks/useAgentProfileSections.tsx +++ b/src/hooks/useAgentProfileSections.tsx @@ -61,7 +61,7 @@ export const useAgentProfileSections = (agent: ApiAgentProfileGet | undefined) = iconName: IconName.ShootingStar, title: t("dashboard.volunteerProfile.opportunities"), headerButtonName: t("dashboard.agentProfile.opportunitiesSec.postOpportunity"), - subComponent: , + subComponent: , }, { iconName: IconName.ChatsTeardrop, From 0721aab2382256b0c01e240eff74b0887a646b28 Mon Sep 17 00:00:00 2001 From: Cy-fox Date: Fri, 24 Apr 2026 09:23:11 +0100 Subject: [PATCH 14/26] fix: add fallback for missing statusOpportunity in subtitle --- .../Profile/sections/AgentOpportunities/AgentOpportunities.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Dashboard/Profile/sections/AgentOpportunities/AgentOpportunities.tsx b/src/components/Dashboard/Profile/sections/AgentOpportunities/AgentOpportunities.tsx index 7bfa43e2..ff69ee40 100644 --- a/src/components/Dashboard/Profile/sections/AgentOpportunities/AgentOpportunities.tsx +++ b/src/components/Dashboard/Profile/sections/AgentOpportunities/AgentOpportunities.tsx @@ -39,7 +39,7 @@ export const AgentOpportunities = ({ agentId }: Props) => { {opp.title} } - subtitle={t(`dashboard.opportunities.status.${opp.statusOpportunity}`)} + subtitle={opp.statusOpportunity ? t(`dashboard.opportunities.status.${opp.statusOpportunity}`) : "-"} onGoToProfile={() => router.push(`/${i18n.language}/dashboard/opportunities/${opp.id}`)} /> )) From 49f1cdcf5d06e7faeea5f00961a27b4e2527fa1d Mon Sep 17 00:00:00 2001 From: Cy-fox Date: Fri, 24 Apr 2026 09:41:07 +0100 Subject: [PATCH 15/26] fix: rename language field to refugee language and enable multi-select --- public/locales/de/translations.json | 1 + public/locales/en/translations.json | 1 + .../AccompanyingDetails.tsx | 4 ++-- .../AccompanyingDetailsDisplay.tsx | 5 ++--- .../AccompanyingDetailsEdit.tsx | 20 +++++++++++-------- .../accompanyingDetailsSchema.ts | 2 +- .../sections/AccompanyingDetails/helpers.ts | 2 +- 7 files changed, 20 insertions(+), 15 deletions(-) diff --git a/public/locales/de/translations.json b/public/locales/de/translations.json index 07ac7b9c..2d6f41c4 100644 --- a/public/locales/de/translations.json +++ b/public/locales/de/translations.json @@ -1031,6 +1031,7 @@ "refugeeNumber": "Geflüchtetennummer", "refugeeName": "Name des Geflüchteten", "languageToTranslate": "Zu übersetzende Sprache", + "refugeeLanguage": "Flüchtlingssprache", "cancel": "Abbrechen", "saveChanges": "Änderungen speichern", "saveSuccess": "Begleitdetails erfolgreich aktualisiert", diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 90d879ac..4a98c078 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -1030,6 +1030,7 @@ "refugeeNumber": "Refugee number", "refugeeName": "Refugee name", "languageToTranslate": "Language to translate", + "refugeeLanguage": "Refugee language", "cancel": "Cancel", "saveChanges": "Save changes", "saveSuccess": "Accompanying details updated successfully", diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetails.tsx b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetails.tsx index a565978b..31f08bb0 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetails.tsx +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetails.tsx @@ -77,7 +77,7 @@ export const AccompanyingDetails = forwardRef(functio appointmentTime: values.appointmentTime || undefined, refugeeNumber: values.refugeeNumber, refugeeName: values.refugeeName, - languagesToTranslate: values.languageToTranslate ? [values.languageToTranslate] : [], + languagesToTranslate: values.languagesToTranslate ?? [], }, }, { @@ -104,7 +104,7 @@ export const AccompanyingDetails = forwardRef(functio ); } - const languageLabel = keyToLabel[formValues.languageToTranslate || ""] || formValues.languageToTranslate || ""; + const languageLabel = (formValues.languagesToTranslate ?? []).map((id) => keyToLabel[id] || id).join(", "); return ( diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx index 1a22ae05..637d78b1 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx @@ -59,11 +59,10 @@ export const AccompanyingDetailsDisplay = ({ values, languageLabel }: Props) => {}} - options={[]} /> ); diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsEdit.tsx b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsEdit.tsx index dce6d0b9..71a64c2f 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsEdit.tsx +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsEdit.tsx @@ -152,20 +152,24 @@ export const AccompanyingDetailsEdit = ({ /> }) => ( + render={({ + field, + }: { + field: ControllerRenderProps; + }) => ( keyToLabel[id] || id)} setValue={(value) => { - const label = Array.isArray(value) ? value[0] : value; - field.onChange(labelToKey[label] || label); + const labels = Array.isArray(value) ? value : [value]; + field.onChange(labels.map((label) => labelToKey[label] || label)); }} options={languageOptions} - errorMessage={errors.languageToTranslate?.message} + errorMessage={errors.languagesToTranslate?.message} /> )} /> diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/accompanyingDetailsSchema.ts b/src/components/Dashboard/Profile/sections/AccompanyingDetails/accompanyingDetailsSchema.ts index 558db99c..0dcdd3cf 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/accompanyingDetailsSchema.ts +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/accompanyingDetailsSchema.ts @@ -14,7 +14,7 @@ export const accompanyingDetailsSchema = z.object({ }), refugeeNumber: z.string().optional(), refugeeName: z.string().optional(), - languageToTranslate: z.string().optional(), + languagesToTranslate: z.array(z.string()).optional(), }); export type AccompanyingDetailsFormData = z.infer; diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts index e1140a02..4976e813 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts @@ -44,5 +44,5 @@ export const getInitialFormValues = ( appointmentTime: parseTime(details?.appointmentTime), refugeeNumber: details?.refugeeNumber || "", refugeeName: details?.refugeeName || "", - languageToTranslate: details?.languageToTranslate?.toString() ?? "", + languagesToTranslate: details?.languageToTranslate !== undefined ? [details.languageToTranslate.toString()] : [], }); From 004ce6a94f0046a41f542d1793338fd5e281e701 Mon Sep 17 00:00:00 2001 From: DarrellRoberts Date: Fri, 24 Apr 2026 16:31:44 +0200 Subject: [PATCH 16/26] adds deserializeFilterFunc to Opp, Vol and Agents lists --- src/components/Dashboard/Agents/Agents.tsx | 12 +++++++---- .../Dashboard/Opportunities/Opportunities.tsx | 17 ++++++++------- .../Dashboard/Opportunities/helpers.ts | 1 - .../Dashboard/Volunteers/Volunteers.tsx | 21 ++++++++++++------- .../Dashboard/Volunteers/helpers.ts | 2 +- 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/src/components/Dashboard/Agents/Agents.tsx b/src/components/Dashboard/Agents/Agents.tsx index 11161543..31f827ec 100644 --- a/src/components/Dashboard/Agents/Agents.tsx +++ b/src/components/Dashboard/Agents/Agents.tsx @@ -14,7 +14,7 @@ import { AgentCardsFilter } from "./Filters/types"; import { createSelectedAgentFiltersAsFlatArray } from "./Filters/helpers"; import { defaultAgentCardsFilter } from "./Filters/constants"; import { createFilterFromOption, getClearFilter } from "../common/CardsFilter/helpers"; -import { serializeAgentFilters } from "./helpers"; +import { deserializeAgentFilters, serializeAgentFilters } from "./helpers"; import Filters from "../common/CardsFilter/Filters"; import FiltersContent from "./Filters/FiltersContent"; @@ -55,10 +55,14 @@ export const Agents = () => { if (!apiFilterOptions) return; setCardsFilter((prev) => { - const district = createFilterFromOption(apiFilterOptions, EntityTableName.DISTRICT); - return { ...prev, district }; + const baseFilters = { + ...prev, + district: createFilterFromOption(apiFilterOptions, EntityTableName.DISTRICT), + }; + + return deserializeAgentFilters(baseFilters, searchParams); }); - }, [apiFilterOptions]); + }, [apiFilterOptions, searchParams]); const activeFilters = createSelectedAgentFiltersAsFlatArray(cardsFilter, setCardsFilter, t); return ( diff --git a/src/components/Dashboard/Opportunities/Opportunities.tsx b/src/components/Dashboard/Opportunities/Opportunities.tsx index 85f7fe57..c3ee78b0 100644 --- a/src/components/Dashboard/Opportunities/Opportunities.tsx +++ b/src/components/Dashboard/Opportunities/Opportunities.tsx @@ -14,7 +14,7 @@ import { defaultOpportunityCardsFilter } from "./Filters/constants"; import FiltersContent from "./Filters/FiltersContent"; import { OpportunityCardsFilter } from "./Filters/types"; import { createSelectedOpportunityFiltersAsFlatArray } from "./Filters/helpers"; -import { serializeOpportunityFilters } from "./helpers"; +import { deserializeOpportunityFilters, serializeOpportunityFilters } from "./helpers"; import { OpportunityListController } from "./OpportunityListController"; import { OpportunitiesContainer } from "./styles"; @@ -70,15 +70,18 @@ export function Opportunities() { if (!apiFilterOptions) return; setCardsFilter((prev) => { - const district = createFilterFromOption(apiFilterOptions, EntityTableName.DISTRICT); - const language = createFilterFromOption(apiFilterOptions, EntityTableName.LANGUAGE); - const activity = createFilterFromOption(apiFilterOptions, EntityTableName.ACTIVITY); - return { ...prev, district, language, activity }; + const baseFilters = { + ...prev, + district: createFilterFromOption(apiFilterOptions, EntityTableName.DISTRICT), + language: createFilterFromOption(apiFilterOptions, EntityTableName.LANGUAGE), + activity: createFilterFromOption(apiFilterOptions, EntityTableName.ACTIVITY), + }; + + return deserializeOpportunityFilters(baseFilters, searchParams); }); - }, [apiFilterOptions]); + }, [apiFilterOptions, searchParams]); const activeFilters = createSelectedOpportunityFiltersAsFlatArray(cardsFilter, setCardsFilter, t); - return ( diff --git a/src/components/Dashboard/Opportunities/helpers.ts b/src/components/Dashboard/Opportunities/helpers.ts index bf24446c..1b59f809 100644 --- a/src/components/Dashboard/Opportunities/helpers.ts +++ b/src/components/Dashboard/Opportunities/helpers.ts @@ -83,7 +83,6 @@ export function deserializeOpportunityFilters( queryDistricts.forEach((d) => { newFilter.district[d] = true; }); - const queryLanguages = searchParams.getAll(QueryParamsKeys.LANGUAGE); queryLanguages.forEach((l) => { newFilter.language[l] = true; diff --git a/src/components/Dashboard/Volunteers/Volunteers.tsx b/src/components/Dashboard/Volunteers/Volunteers.tsx index 7c3f078d..abee5b1d 100644 --- a/src/components/Dashboard/Volunteers/Volunteers.tsx +++ b/src/components/Dashboard/Volunteers/Volunteers.tsx @@ -1,8 +1,8 @@ "use client"; + import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; - import { DashboardLayout } from "@/components/Layout"; import { apiPathOption, questionMark } from "@/config/constants"; import { useGetOpportunity, useGetQuery } from "@/hooks"; @@ -14,7 +14,12 @@ import { getClearFilter } from "../common/CardsFilter/helpers"; import { defaultVolunteerCardsFilter } from "./Filters/constants"; import FiltersContent from "./Filters/FiltersContent"; import { CardsFilter } from "./Filters/types"; -import { createFilterFromOption, createSelectedFilterItemsAsFlatArray, serializeFilters } from "./helpers"; +import { + createFilterFromOption, + createSelectedFilterItemsAsFlatArray, + deserializeVolunteerFilters, + serializeFilters, +} from "./helpers"; import { VolunteerListController } from "./VolunteerListController"; export function Volunteers() { @@ -63,14 +68,16 @@ export function Volunteers() { useEffect(() => { if (!apiFilterOptions) return; - // Merge and set 'district' - 'languages' of query params and API option setCardsFilter((prev) => { - const district = createFilterFromOption(apiFilterOptions, EntityTableName.DISTRICT); - const language = createFilterFromOption(apiFilterOptions, EntityTableName.LANGUAGE); + const baseFilters = { + ...prev, + district: createFilterFromOption(apiFilterOptions, EntityTableName.DISTRICT), + language: createFilterFromOption(apiFilterOptions, EntityTableName.LANGUAGE), + }; - return { ...prev, district, language }; + return deserializeVolunteerFilters(baseFilters, searchParams); }); - }, [apiFilterOptions]); + }, [apiFilterOptions, searchParams]); const activeFilters = createSelectedFilterItemsAsFlatArray(cardsFilter, setCardsFilter, t); diff --git a/src/components/Dashboard/Volunteers/helpers.ts b/src/components/Dashboard/Volunteers/helpers.ts index 6a686eb8..2ab9f7f5 100644 --- a/src/components/Dashboard/Volunteers/helpers.ts +++ b/src/components/Dashboard/Volunteers/helpers.ts @@ -129,7 +129,7 @@ export function serializeFilters( return asString ? params.toString() : params; } -export function deserializeFilters(filter: CardsFilter, searchParams: ReadonlyURLSearchParams) { +export function deserializeVolunteerFilters(filter: CardsFilter, searchParams: ReadonlyURLSearchParams) { const newFilter: CardsFilter = structuredClone(filter); const search = searchParams.get(QueryParamsKeys.SEARCH); From e7e8184ef0dd6df2d92e9ed52f6bf74de4631331 Mon Sep 17 00:00:00 2001 From: Cy-fox Date: Tue, 21 Apr 2026 14:42:10 +0100 Subject: [PATCH 17/26] =?UTF-8?q?=F0=9F=90=9B=20split=20volunteer=20langua?= =?UTF-8?q?ges=20into=20main=20communication=20and=20translate=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #352 --- public/locales/de/translations.json | 3 ++- public/locales/en/translations.json | 3 ++- .../sections/VolunteerProfile/DisplayFields.tsx | 15 +++++++++++---- .../VolunteerProfile/VolunteerProfile.tsx | 15 +++++++++++++-- .../sections/VolunteerProfile/formatters.ts | 7 +++++-- 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/public/locales/de/translations.json b/public/locales/de/translations.json index 2d6f41c4..eb813ce1 100644 --- a/public/locales/de/translations.json +++ b/public/locales/de/translations.json @@ -593,7 +593,8 @@ "profileSection": { "title": "Freiwilligenprofil", "edit": "Bearbeiten", - "languages": "Sprachen", + "mainCommunication": "Hauptkommunikation", + "languagesToTranslate": "Sprache zum Übersetzen", "availability": "Verfügbarkeit", "districts": "Bezirke", "volunteerType": "Freiwilligen-Typ", diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 4a98c078..d65ca9c1 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -592,7 +592,8 @@ "profileSection": { "title": "Volunteer profile", "edit": "Edit", - "languages": "Languages", + "mainCommunication": "Main communication", + "languagesToTranslate": "Language to translate", "availability": "Availability", "districts": "Districts", "volunteerType": "Volunteer type", diff --git a/src/components/Dashboard/Profile/sections/VolunteerProfile/DisplayFields.tsx b/src/components/Dashboard/Profile/sections/VolunteerProfile/DisplayFields.tsx index 0a8f9781..72602cbe 100644 --- a/src/components/Dashboard/Profile/sections/VolunteerProfile/DisplayFields.tsx +++ b/src/components/Dashboard/Profile/sections/VolunteerProfile/DisplayFields.tsx @@ -40,7 +40,8 @@ const TagsWrapper = styled.div` `; type Props = { - languages: string; + mainCommunication: string; + languagesToTranslate: string; availability: string; districts: string; volunteerType: string; @@ -51,7 +52,8 @@ type Props = { }; export function DisplayFields({ - languages, + mainCommunication, + languagesToTranslate, availability, districts, volunteerType, @@ -63,8 +65,13 @@ export function DisplayFields({ return ( <> - {t("dashboard.volunteerProfile.profileSection.languages")} - {languages} + {t("dashboard.volunteerProfile.profileSection.mainCommunication")} + {mainCommunication} + + + + {t("dashboard.volunteerProfile.profileSection.languagesToTranslate")} + {languagesToTranslate} diff --git a/src/components/Dashboard/Profile/sections/VolunteerProfile/VolunteerProfile.tsx b/src/components/Dashboard/Profile/sections/VolunteerProfile/VolunteerProfile.tsx index db6d6769..c98fb2cf 100644 --- a/src/components/Dashboard/Profile/sections/VolunteerProfile/VolunteerProfile.tsx +++ b/src/components/Dashboard/Profile/sections/VolunteerProfile/VolunteerProfile.tsx @@ -2,7 +2,7 @@ import Button from "@/components/core/button/Button/Button"; import { useUpdateVolunteerProfile } from "@/hooks/useUpdateVolunteerProfile"; import { zodResolver } from "@hookform/resolvers/zod"; -import { ApiVolunteerGet, Lang, VolunteerStateTypeType } from "need4deed-sdk"; +import { ApiVolunteerGet, Lang, LangPurpose, VolunteerStateTypeType } from "need4deed-sdk"; import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; @@ -151,7 +151,18 @@ export const VolunteerProfile = forwardRef(function /> ) : ( , t: TFunction, + purpose?: LangPurpose, ): string { if (!langs || langs.length === 0) return EMPTY_PLACEHOLDER_VALUE; - return langs + const filtered = purpose ? langs.filter((lang) => lang.purpose === purpose) : langs; + if (filtered.length === 0) return EMPTY_PLACEHOLDER_VALUE; + return filtered .map((lang) => { const localizedTitle = languageIdToTitle[lang.id] || lang.title; const proficiencyKey = lang.proficiency?.toLowerCase(); From 7f9d3face2ccc566fefee725f12cebc683605341 Mon Sep 17 00:00:00 2001 From: Cy-fox Date: Tue, 21 Apr 2026 15:06:59 +0100 Subject: [PATCH 18/26] ci: retrigger lint check From d25b9efc6a0663887e82c53829de587e2001b083 Mon Sep 17 00:00:00 2001 From: Cy-fox Date: Wed, 22 Apr 2026 14:47:21 +0100 Subject: [PATCH 19/26] fix: restore languages translation key alongside new split keys --- public/locales/de/translations.json | 1 + public/locales/en/translations.json | 1 + 2 files changed, 2 insertions(+) diff --git a/public/locales/de/translations.json b/public/locales/de/translations.json index eb813ce1..62571281 100644 --- a/public/locales/de/translations.json +++ b/public/locales/de/translations.json @@ -593,6 +593,7 @@ "profileSection": { "title": "Freiwilligenprofil", "edit": "Bearbeiten", + "languages": "Sprachen", "mainCommunication": "Hauptkommunikation", "languagesToTranslate": "Sprache zum Übersetzen", "availability": "Verfügbarkeit", diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index d65ca9c1..13cf4a44 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -592,6 +592,7 @@ "profileSection": { "title": "Volunteer profile", "edit": "Edit", + "languages": "Languages", "mainCommunication": "Main communication", "languagesToTranslate": "Language to translate", "availability": "Availability", From 10621aee58531118d2a6ce5cafbb7b9396ca1914 Mon Sep 17 00:00:00 2001 From: Rodrigo Louro Date: Fri, 24 Apr 2026 10:57:28 +0200 Subject: [PATCH 20/26] Fix: Accompanying details: add new row #386 Fix: Accompanying details: add new row #386 --- public/locales/de/translations.json | 7 ++++++ public/locales/en/translations.json | 7 ++++++ .../AccompanyingDetails.tsx | 15 +++++++++++ .../AccompanyingDetailsDisplay.tsx | 15 +++++++++++ .../AccompanyingDetailsEdit.tsx | 25 +++++++++++++++++++ .../accompanyingDetailsSchema.ts | 1 + .../sections/AccompanyingDetails/helpers.ts | 2 ++ ...useUpdateOpportunityAccompanyingDetails.ts | 1 + 8 files changed, 73 insertions(+) diff --git a/public/locales/de/translations.json b/public/locales/de/translations.json index 62571281..22136abf 100644 --- a/public/locales/de/translations.json +++ b/public/locales/de/translations.json @@ -1034,6 +1034,13 @@ "refugeeName": "Name des Geflüchteten", "languageToTranslate": "Zu übersetzende Sprache", "refugeeLanguage": "Flüchtlingssprache", + "appointmentLanguage": "Terminsprache", + "appointmentLanguageOptions": { + "german": "Deutsch", + "english": "Englisch", + "englishGerman": "Englisch/Deutsch", + "noTranslation": "Keine Übersetzung erforderlich" + }, "cancel": "Abbrechen", "saveChanges": "Änderungen speichern", "saveSuccess": "Begleitdetails erfolgreich aktualisiert", diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 13cf4a44..fed0d78f 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -1033,6 +1033,13 @@ "refugeeName": "Refugee name", "languageToTranslate": "Language to translate", "refugeeLanguage": "Refugee language", + "appointmentLanguage": "Appointment language", + "appointmentLanguageOptions": { + "german": "German", + "english": "English", + "englishGerman": "English/German", + "noTranslation": "No translation needed" + }, "cancel": "Cancel", "saveChanges": "Save changes", "saveSuccess": "Accompanying details updated successfully", diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetails.tsx b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetails.tsx index 31f08bb0..c605ad03 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetails.tsx +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetails.tsx @@ -11,6 +11,7 @@ import { EditableSectionProps, EditableSectionRef } from "../shared/types"; import { useEditingChangeNotifier } from "../shared/useEditingChangeNotifier"; import { AccompanyingDetailsDisplay } from "./AccompanyingDetailsDisplay"; import { AccompanyingDetailsEdit } from "./AccompanyingDetailsEdit"; +import { AppointmentLanguages } from "@/config/constants"; import { getInitialFormValues, getMinAppointmentDate, isAccompanyingType } from "./helpers"; import { AccompanyingDetailsFormData, accompanyingDetailsSchema } from "./accompanyingDetailsSchema"; import { Container, NotAccompanyingMessage } from "./styles"; @@ -42,6 +43,16 @@ export const AccompanyingDetails = forwardRef(functio labelToKey[lang.title] = String(lang.id); }); + const appointmentLanguageKeys = Object.values(AppointmentLanguages); + const appointmentLanguageKeyToLabel: Record = {}; + const appointmentLanguageLabelToKey: Record = {}; + appointmentLanguageKeys.forEach((key) => { + const label = t(`dashboard.opportunityProfile.accompanyingDetails.appointmentLanguageOptions.${key}`); + appointmentLanguageKeyToLabel[key] = label; + appointmentLanguageLabelToKey[label] = key; + }); + const appointmentLanguageOptions = appointmentLanguageKeys.map((key) => appointmentLanguageKeyToLabel[key]); + const initialFormValues = getInitialFormValues(opportunity.accompanyingDetails); const methods = useForm({ @@ -78,6 +89,7 @@ export const AccompanyingDetails = forwardRef(functio refugeeNumber: values.refugeeNumber, refugeeName: values.refugeeName, languagesToTranslate: values.languagesToTranslate ?? [], + appointmentLanguage: values.appointmentLanguage || undefined, }, }, { @@ -115,6 +127,9 @@ export const AccompanyingDetails = forwardRef(functio languageOptions={languageOptions} keyToLabel={keyToLabel} labelToKey={labelToKey} + appointmentLanguageOptions={appointmentLanguageOptions} + appointmentLanguageKeyToLabel={appointmentLanguageKeyToLabel} + appointmentLanguageLabelToKey={appointmentLanguageLabelToKey} onCancel={handleCancel} onSubmit={handleSubmit(onSubmit)} isPending={isPending} diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx index 637d78b1..b11e8b86 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx @@ -64,6 +64,21 @@ export const AccompanyingDetailsDisplay = ({ values, languageLabel }: Props) => value={languageLabel} setValue={() => {}} /> + + {}} + options={[]} + /> ); }; diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsEdit.tsx b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsEdit.tsx index 71a64c2f..c2c0299a 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsEdit.tsx +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsEdit.tsx @@ -20,6 +20,9 @@ type Props = { languageOptions: string[]; keyToLabel: Record; labelToKey: Record; + appointmentLanguageOptions: string[]; + appointmentLanguageKeyToLabel: Record; + appointmentLanguageLabelToKey: Record; onCancel: () => void; onSubmit: () => void; isPending: boolean; @@ -31,6 +34,9 @@ export const AccompanyingDetailsEdit = ({ languageOptions, keyToLabel, labelToKey, + appointmentLanguageOptions, + appointmentLanguageKeyToLabel, + appointmentLanguageLabelToKey, onCancel, onSubmit, isPending, @@ -173,6 +179,25 @@ export const AccompanyingDetailsEdit = ({ /> )} /> + + }) => ( + { + const label = Array.isArray(value) ? value[0] : value; + field.onChange(appointmentLanguageLabelToKey[label] || label); + }} + options={appointmentLanguageOptions} + errorMessage={errors.appointmentLanguage?.message} + /> + )} + /> diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/accompanyingDetailsSchema.ts b/src/components/Dashboard/Profile/sections/AccompanyingDetails/accompanyingDetailsSchema.ts index 0dcdd3cf..c03b6102 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/accompanyingDetailsSchema.ts +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/accompanyingDetailsSchema.ts @@ -15,6 +15,7 @@ export const accompanyingDetailsSchema = z.object({ refugeeNumber: z.string().optional(), refugeeName: z.string().optional(), languagesToTranslate: z.array(z.string()).optional(), + appointmentLanguage: z.string().optional(), }); export type AccompanyingDetailsFormData = z.infer; diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts index 4976e813..c45ad58c 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts @@ -45,4 +45,6 @@ export const getInitialFormValues = ( refugeeNumber: details?.refugeeNumber || "", refugeeName: details?.refugeeName || "", languagesToTranslate: details?.languageToTranslate !== undefined ? [details.languageToTranslate.toString()] : [], + appointmentLanguage: + (details as ApiOpportunityAccompanyingDetails & { appointmentLanguage?: string })?.appointmentLanguage || "", }); diff --git a/src/hooks/useUpdateOpportunityAccompanyingDetails.ts b/src/hooks/useUpdateOpportunityAccompanyingDetails.ts index 7f048ac4..1ce402a7 100644 --- a/src/hooks/useUpdateOpportunityAccompanyingDetails.ts +++ b/src/hooks/useUpdateOpportunityAccompanyingDetails.ts @@ -10,6 +10,7 @@ export type OpportunityAccompanyingDetailsUpdateData = { refugeeNumber?: string; refugeeName?: string; languagesToTranslate?: string[]; + appointmentLanguage?: string; }; }; From d359620c0388da6d6d3b3a5c9bb1b5949986d8bb Mon Sep 17 00:00:00 2001 From: Rodrigo Louro Date: Fri, 24 Apr 2026 16:18:14 +0200 Subject: [PATCH 21/26] Fix: bugs Fix: bugs --- src/config/constants.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/config/constants.ts b/src/config/constants.ts index 3718d519..a8c6e71c 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -74,3 +74,10 @@ export const MAX_DESCRIPTION_LENGTH = 500; export const PHONE_NUMBER_REGEX = /^\+[0-9\s]+$/; export const LOGGED_IN_COOKIE = "is_logged_in=true; path=/; max-age=6000; SameSite=Lax; Secure"; + +export enum AppointmentLanguages { + GERMAN = "german", + ENGLISH = "english", + ENGLISH_GERMAN = "englishGerman", + NO_TRANSLATION = "noTranslation", +} From 2d435ee62bcc04c6f316f2f66f132ee3278bd2fe Mon Sep 17 00:00:00 2001 From: Rodrigo Louro Date: Mon, 27 Apr 2026 11:07:30 +0200 Subject: [PATCH 22/26] Update OpportunityCard.tsx --- src/components/Dashboard/Opportunities/OpportunityCard.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/Dashboard/Opportunities/OpportunityCard.tsx b/src/components/Dashboard/Opportunities/OpportunityCard.tsx index 6ba62439..01ee5277 100644 --- a/src/components/Dashboard/Opportunities/OpportunityCard.tsx +++ b/src/components/Dashboard/Opportunities/OpportunityCard.tsx @@ -37,7 +37,9 @@ export function OpportunityCard({ opportunity, volunteerId, activitiesList }: Pr location, availability, accompanyingDetails, - } = opportunity; + } = opportunity as ApiVolunteerOpportunityGetList & { + accompanyingDetails?: { appointmentDate?: string; appointmentTime?: string }; + }; const mainCommunication = getLanguagesByPurpose(languages, LangPurpose.GENERAL); const recipientLanguage = getLanguagesByPurpose(languages, LangPurpose.RECIPIENT); From 8c889b383dc8ed9a3ec9e2797bc4ce5b41f0fc97 Mon Sep 17 00:00:00 2001 From: Cy-fox Date: Mon, 27 Apr 2026 15:20:44 +0100 Subject: [PATCH 23/26] Fix opportunity details form blocked when no schedule is set Closes #400 --- .../OpportunityDetails/opportunityDetailsSchema.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/components/Dashboard/Profile/sections/OpportunityDetails/opportunityDetailsSchema.ts b/src/components/Dashboard/Profile/sections/OpportunityDetails/opportunityDetailsSchema.ts index ea38704b..f79231ed 100644 --- a/src/components/Dashboard/Profile/sections/OpportunityDetails/opportunityDetailsSchema.ts +++ b/src/components/Dashboard/Profile/sections/OpportunityDetails/opportunityDetailsSchema.ts @@ -34,17 +34,7 @@ export const createOpportunityDetailsSchema = (t: (key: string) => string) => }), mainCommunication: languagesValidator(t), residentsSpeak: languagesValidator(t), - availability: z - .custom( - (data) => { - if (data === null || data === undefined) return true; - if (!Array.isArray(data)) return false; - return data.some((day) => day.timeSlots.some((slot: { selected: boolean }) => slot.selected)); - }, - t(`${i18nPrefix}.availabilityRequired`), - ) - .nullable() - .optional(), + availability: z.custom().nullable().optional(), eventDate: z.date().nullable().optional(), eventTime: z.string().optional(), activities: z.array(z.string()).min(1, t(`${i18nPrefix}.activitiesRequired`)), From 7cab478b12c5016486a778911e0b2387571c5af3 Mon Sep 17 00:00:00 2001 From: nadavosa Date: Mon, 27 Apr 2026 18:26:36 +0200 Subject: [PATCH 24/26] fix: wire up opportunity details save mutation and fix UTC time display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds useUpdateOpportunityDetails hook and wires it into OpportunityDetailsEdit so saving the form actually PATCHes the API (fixes #401) - Fixes toLangOptionItems to reverse the languagesToFormValues translation lookup, so languages initialised from the API in any locale still match on submit - Adds formatTimeForDisplay (UTC→local) separate from parseTime (form state), so AccompanyingDetailsDisplay shows local time without corrupting the value on each save (parseTime kept raw for form state) - Fixes formatAccompanyingDate UTC conversion to use explicit getHours/getMinutes instead of toTimeString() which is implementation-defined - Removes unused formatAccompanyingDate dead code added in the same diff Co-Authored-By: Claude Sonnet 4.6 --- .../Opportunities/OpportunityCard.helpers.tsx | 19 ++++- .../AccompanyingDetailsDisplay.tsx | 3 +- .../sections/AccompanyingDetails/helpers.ts | 17 +++-- .../OpportunityDetailsEdit.tsx | 70 ++++++++++++------- src/hooks/useUpdateOpportunityDetails.ts | 12 ++++ 5 files changed, 89 insertions(+), 32 deletions(-) create mode 100644 src/hooks/useUpdateOpportunityDetails.ts diff --git a/src/components/Dashboard/Opportunities/OpportunityCard.helpers.tsx b/src/components/Dashboard/Opportunities/OpportunityCard.helpers.tsx index 4434ca49..d4c63be4 100644 --- a/src/components/Dashboard/Opportunities/OpportunityCard.helpers.tsx +++ b/src/components/Dashboard/Opportunities/OpportunityCard.helpers.tsx @@ -1,4 +1,5 @@ import { ConfettiIcon, PersonSimpleWalkIcon, ShootingStarIcon, TranslateIcon } from "@phosphor-icons/react"; +import { format } from "date-fns"; import { ApiVolunteerOpportunityGetList, OpportunityStatusType, ProfileVolunteeringType } from "need4deed-sdk"; import { JSX } from "react"; @@ -14,7 +15,23 @@ export function formatAccompanyingDate(details?: { appointmentTime?: string; }): string | null { if (!details?.appointmentDate) return null; - return [details.appointmentDate, details.appointmentTime].filter(Boolean).join(" "); + + const date = new Date(details.appointmentDate); + const formattedDate = isNaN(date.getTime()) ? details.appointmentDate : format(date, "dd.MM.yyyy"); + + let formattedTime: string | null = null; + if (details.appointmentTime) { + const [h, m] = details.appointmentTime.split(":").map(Number); + if (!isNaN(h) && !isNaN(m)) { + const d = new Date(); + d.setUTCHours(h, m, 0, 0); + formattedTime = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; + } else { + formattedTime = details.appointmentTime; + } + } + + return [formattedDate, formattedTime].filter(Boolean).join(" "); } export const statusColorMap: Record = { diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx index b11e8b86..8862c548 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx @@ -3,6 +3,7 @@ import { EMPTY_PLACEHOLDER_VALUE } from "@/config/constants"; import { format } from "date-fns"; import { useTranslation } from "react-i18next"; import { AccompanyingDetailsFormData } from "./accompanyingDetailsSchema"; +import { formatTimeForDisplay } from "./helpers"; import { DateFieldRow, Details } from "./styles"; type Props = { @@ -38,7 +39,7 @@ export const AccompanyingDetailsDisplay = ({ values, languageLabel }: Props) => - {values.appointmentTime || EMPTY_PLACEHOLDER_VALUE} + {formatTimeForDisplay(values.appointmentTime) || EMPTY_PLACEHOLDER_VALUE} { return time.toTimeString().slice(0, 5); }; +// Converts a UTC HH:mm string from the API to the browser's local time for display only. +// Do NOT use this for form state — it would cause the time to shift on every save. +export const formatTimeForDisplay = (time: string | undefined): string => { + if (!time) return ""; + const [h, m] = time.split(":").map(Number); + if (isNaN(h) || isNaN(m)) return time; + const d = new Date(); + d.setUTCHours(h, m, 0, 0); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +}; + export const getInitialFormValues = ( details: ApiOpportunityAccompanyingDetails | undefined, ): AccompanyingDetailsFormData => ({ appointmentAddress: details?.appointmentAddress || "", - appointmentPostcode: - (details as ApiOpportunityAccompanyingDetails & { appointmentPostcode?: string })?.appointmentPostcode || "", appointmentDate: parseDate(details?.appointmentDate), appointmentTime: parseTime(details?.appointmentTime), refugeeNumber: details?.refugeeNumber || "", refugeeName: details?.refugeeName || "", - languagesToTranslate: details?.languageToTranslate !== undefined ? [details.languageToTranslate.toString()] : [], - appointmentLanguage: - (details as ApiOpportunityAccompanyingDetails & { appointmentLanguage?: string })?.appointmentLanguage || "", + languageToTranslate: details?.languageToTranslate?.toString() ?? "", }); diff --git a/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsEdit.tsx b/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsEdit.tsx index 649da025..fd35df20 100644 --- a/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsEdit.tsx +++ b/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsEdit.tsx @@ -4,18 +4,23 @@ import { DatePickerWithLabel } from "@/components/core/common/DatePicker"; import { EditableField } from "@/components/EditableField/EditableField"; import { AvailabilityGrid } from "@/components/forms/AvailabilityGrid/AvailabilityGrid"; import { LanguageFields } from "@/components/forms/LanguageFields"; -import { apiToFormAvailability } from "@/components/Dashboard/Profile/sections/VolunteerProfile/availabilityUtils"; import { + apiToFormAvailability, + formToApiAvailability, +} from "@/components/Dashboard/Profile/sections/VolunteerProfile/availabilityUtils"; +import { + ApiLanguageOption, useApiActivities, useApiLanguages, useApiSkills, } from "@/components/Dashboard/Profile/sections/VolunteerProfile/hooks"; import { createMapping } from "@/components/Dashboard/Profile/sections/VolunteerProfile/mappingUtils"; -import { useUpdateOpportunityTitle } from "@/hooks/useUpdateOpportunityTitle"; +import { useUpdateOpportunityDetails } from "@/hooks/useUpdateOpportunityDetails"; import { zodResolver } from "@hookform/resolvers/zod"; import { MAX_DESCRIPTION_LENGTH } from "@/config/constants"; import { de, enUS } from "date-fns/locale"; -import { ApiOpportunityGet, Lang, LangPurpose, VolunteerStateTypeType } from "need4deed-sdk"; +import { TFunction } from "i18next"; +import { ApiOpportunityGet, Lang, LangPurpose, OptionItem, VolunteerStateTypeType } from "need4deed-sdk"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { FormButtonRow, FormDetails } from "../shared/styles"; @@ -24,6 +29,29 @@ import { createOpportunityDetailsSchema, OpportunityDetailsFormData } from "./op import { DateFieldRow, DatePickerContainer, ErrorText, FieldGroup, TimeInput, TimeInputWrapper } from "./styles"; import { OpportunityWithDetails } from "./types"; +function toLangOptionItems(formLangs: { language: string }[], apiLanguages: ApiLanguageOption[], t: TFunction): OptionItem[] { + return formLangs.flatMap(({ language }) => { + if (!language) return []; + const found = apiLanguages.find((a) => { + if (a.title === language || a.title.toLowerCase() === language.toLowerCase()) return true; + // languagesToFormValues stores translated names; reverse the lookup to match them + const key = `languageNames.${a.title.toLowerCase()}`; + const translated = t(key); + return translated !== key && translated === language; + }); + return found ? [{ id: found.id, title: found.title }] : []; + }); +} + +function toOptionItems(ids: string[], apiItems: ApiLanguageOption[]): OptionItem[] { + const map = new Map(apiItems.map((i) => [i.id, i.title])); + return ids.flatMap((id) => { + const numId = Number(id); + const title = map.get(numId); + return title ? [{ id: numId, title }] : []; + }); +} + type Props = { opportunity: ApiOpportunityGet; onCancel: () => void; @@ -37,8 +65,8 @@ export function OpportunityDetailsEdit({ opportunity, onCancel }: Props) { const prefix = "dashboard.opportunityProfile.opportunityDetails"; const isEventType = opp.volunteerType === VolunteerStateTypeType.EVENTS; - const { mutate: updateTitle } = useUpdateOpportunityTitle(opp.id); + const { mutate: updateOpportunityDetails } = useUpdateOpportunityDetails(opp.id); const { data: apiLanguages = [] } = useApiLanguages(); const { data: apiActivities = [] } = useApiActivities(); const { data: apiSkills = [] } = useApiSkills(); @@ -63,7 +91,6 @@ export function OpportunityDetailsEdit({ opportunity, onCancel }: Props) { resolver: zodResolver(schema), mode: "onChange", defaultValues: { - title: opp.title ?? "", description: opp.description ?? "", numberOfVolunteers: String(opp.numberOfVolunteers ?? ""), mainCommunication: languagesToFormValues(generalLangs, t), @@ -81,31 +108,24 @@ export function OpportunityDetailsEdit({ opportunity, onCancel }: Props) { onCancel(); }; - const onSubmit = (data: OpportunityDetailsFormData) => { - if (data.title !== opp.title) { - updateTitle({ title: data.title }); - } - onCancel(); + const onSubmit = (values: OpportunityDetailsFormData) => { + updateOpportunityDetails( + { + description: values.description, + numberVolunteers: Number(values.numberOfVolunteers), + languagesMain: toLangOptionItems(values.mainCommunication, apiLanguages, t), + languagesResidents: toLangOptionItems(values.residentsSpeak, apiLanguages, t), + activities: toOptionItems(values.activities, apiActivities), + skills: toOptionItems(values.skills, apiSkills), + schedule: values.availability ? formToApiAvailability(values.availability) : undefined, + }, + { onSuccess: onCancel }, + ); }; return ( <> - ( - - )} - /> - { + return useMutationQuery({ + apiPath: `${apiPathOpportunity}/${opportunityId}`, + method: "patch", + successMessage: "dashboard.opportunityProfile.opportunityDetails.saveSuccess", + queryKeyToInvalidate: ["opportunity", String(opportunityId)], + }); +}; From d286a9c4d933a59a17d83791538297c6e81d0f49 Mon Sep 17 00:00:00 2001 From: nadavosa Date: Tue, 28 Apr 2026 11:20:22 +0200 Subject: [PATCH 25/26] fix: correct languagesToTranslate field name and restore missing form fields in getInitialFormValues Co-Authored-By: Claude Sonnet 4.6 --- .../Profile/sections/AccompanyingDetails/helpers.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts index 53ab341c..7297e4bf 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts @@ -49,9 +49,13 @@ export const getInitialFormValues = ( details: ApiOpportunityAccompanyingDetails | undefined, ): AccompanyingDetailsFormData => ({ appointmentAddress: details?.appointmentAddress || "", + appointmentPostcode: + (details as ApiOpportunityAccompanyingDetails & { appointmentPostcode?: string })?.appointmentPostcode || "", appointmentDate: parseDate(details?.appointmentDate), appointmentTime: parseTime(details?.appointmentTime), refugeeNumber: details?.refugeeNumber || "", refugeeName: details?.refugeeName || "", - languageToTranslate: details?.languageToTranslate?.toString() ?? "", + languagesToTranslate: details?.languageToTranslate !== undefined ? [details.languageToTranslate.toString()] : [], + appointmentLanguage: + (details as ApiOpportunityAccompanyingDetails & { appointmentLanguage?: string })?.appointmentLanguage || "", }); From 7612b7f1dd29a43183709f38d10c8db064bdd41f Mon Sep 17 00:00:00 2001 From: nadavosa Date: Thu, 30 Apr 2026 11:34:03 +0200 Subject: [PATCH 26/26] refactor: move UTC time conversion to shared utcHhmmToLocal utility in utils Replaces the local formatTimeForDisplay implementation with a thin wrapper around the shared utcHhmmToLocal function exported from utils/index.ts. Co-Authored-By: Claude Sonnet 4.6 --- .../Profile/sections/AccompanyingDetails/helpers.ts | 11 +++-------- src/utils/index.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts index 7297e4bf..3651b2b0 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts @@ -1,3 +1,4 @@ +import { utcHhmmToLocal } from "@/utils"; import { ApiOpportunityAccompanyingDetails, VolunteerStateTypeType } from "need4deed-sdk"; import { AccompanyingDetailsFormData } from "./accompanyingDetailsSchema"; @@ -36,14 +37,8 @@ export const parseTime = (time: Date | string | undefined): string => { // Converts a UTC HH:mm string from the API to the browser's local time for display only. // Do NOT use this for form state — it would cause the time to shift on every save. -export const formatTimeForDisplay = (time: string | undefined): string => { - if (!time) return ""; - const [h, m] = time.split(":").map(Number); - if (isNaN(h) || isNaN(m)) return time; - const d = new Date(); - d.setUTCHours(h, m, 0, 0); - return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; -}; +export const formatTimeForDisplay = (time: string | undefined): string => + time ? utcHhmmToLocal(time) : ""; export const getInitialFormValues = ( details: ApiOpportunityAccompanyingDetails | undefined, diff --git a/src/utils/index.ts b/src/utils/index.ts index 82b7de1b..2c775a5e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,6 +3,14 @@ export * from "./helpers"; import { cloudfrontURL } from "@/config/constants"; import { DocumentStatusType } from "need4deed-sdk"; +export function utcHhmmToLocal(hhmm: string): string { + const [h, m] = hhmm.split(":").map(Number); + if (isNaN(h) || isNaN(m)) return hhmm; + const d = new Date(); + d.setUTCHours(h, m, 0, 0); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} + export function getDateLocalTooUTC(dateStr: string | undefined) { if (!dateStr) return undefined;