diff --git a/package.json b/package.json index c6c4e267..3eb7c962 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "date-fns": "^4.1.0", "email-validator": "^2.0.4", "i18next": "^25.3.2", - "need4deed-sdk": "0.0.146", + "need4deed-sdk": "0.0.147", "next": "15.3.8", "react": "^19.0.0", "react-day-picker": "^9.13.0", diff --git a/public/locales/de/translations.json b/public/locales/de/translations.json index 520b7d9c..7fdfdba1 100644 --- a/public/locales/de/translations.json +++ b/public/locales/de/translations.json @@ -1074,7 +1074,10 @@ "status": "Status", "receivedOn": "Erhalten am", "statusReceived": "Erhalten", + "statusPending": "Ausstehend", + "statusPost": "Geschickt", "statusDueTo": "Fällig am", + "statusMailedOn": "Geschickt am", "typeOptions": { "tshirt": "T-Shirt", "toteBag": "Stofftasche", @@ -1092,6 +1095,8 @@ "volunteerReceivedIt": "Der*die Ehrenamtliche hat es erhalten", "needToGiveIt": "Muss dem*der Ehrenamtlichen gegeben werden", "receivedOnRequired": "Erhalten am*", + "volunteerPostIt": "Wurde dem*der Ehrenamtlichen per Post geschickt", + "postOnRequired": "Geschickt am*", "today": "heute", "cancel": "Abbrechen", "save": "Speichern", diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 331bc05c..e708643c 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -1062,7 +1062,10 @@ "status": "Status", "receivedOn": "Received on", "statusReceived": "Received", + "statusPending": "Pending", + "statusPost": "Mailed", "statusDueTo": "Due on", + "statusMailedOn": "Mailed on", "typeOptions": { "tshirt": "T-shirt", "toteBag": "Tote bag", @@ -1080,6 +1083,8 @@ "volunteerReceivedIt": "The volunteer received it", "needToGiveIt": "Need to give it to the volunteer", "receivedOnRequired": "Received on*", + "volunteerPostIt": "It was mailed to the volunteer", + "postOnRequired": "Mailed on*", "today": "today", "cancel": "Cancel", "save": "Save", diff --git a/src/components/Dashboard/Profile/sections/Appreciation/Appreciation.tsx b/src/components/Dashboard/Profile/sections/Appreciation/Appreciation.tsx index a9fc54fc..db6f7f7e 100644 --- a/src/components/Dashboard/Profile/sections/Appreciation/Appreciation.tsx +++ b/src/components/Dashboard/Profile/sections/Appreciation/Appreciation.tsx @@ -1,14 +1,15 @@ import { EmptyPlaceholder } from "@/components/core/common/EmptyPlaceholder"; -import { EMPTY_PLACEHOLDER_VALUE } from "@/config/constants"; import { useAppreciationTracker } from "@/hooks/useAppreciationTracker"; import { PencilSimple, Trash } from "@phosphor-icons/react"; import { ApiVolunteerGet, - ApiAppreciationGet, ApiAppreciationPost, VolunteerStateAppreciationType, + ApiAppreciationPatch, + ApiAppreciationGet, + AppreciationStatusType, } from "need4deed-sdk"; -import { forwardRef, useImperativeHandle, useState } from "react"; +import { forwardRef, useImperativeHandle, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { AppreciationDialog } from "./AppreciationDialog"; import { ConfirmationDialog } from "../shared/ConfirmationDialog"; @@ -26,6 +27,8 @@ import { } from "@/components/core/common/Table"; import { formatDate } from "../shared/utils/formatDate"; import { getAppreciationTypeLabel } from "./utils/translations"; +import { EMPTY_PLACEHOLDER_VALUE } from "@/config/constants"; +import { createAppreciationStatusLabelMap } from "./constants"; type Props = { volunteer: ApiVolunteerGet; @@ -77,36 +80,29 @@ export const Appreciation = forwardRef(function Apprecia title: VolunteerStateAppreciationType; dateDue: Date | null; dateDelivery: Date | null; + status: AppreciationStatusType; }) => { if (data.id) { - const payload = { + const payload: ApiAppreciationPatch = { title: data.title, dateDue: data.dateDue, dateDelivery: data.dateDelivery, + status: data.status, }; updateAppreciation({ id: data.id, data: payload }, { onSuccess: () => setIsDialogOpen(false) }); } else { const payload: ApiAppreciationPost = { title: data.title, - dateDue: data.dateDue || new Date(), + dateDue: data.dateDue, dateDelivery: data.dateDelivery ?? undefined, + status: data.status, }; createAppreciation(payload, { onSuccess: () => setIsDialogOpen(false) }); } }; - const getStatus = (entry: ApiAppreciationGet): "received" | "pending" => - entry.dateDelivery ? "received" : "pending"; - - const getStatusLabel = (entry: ApiAppreciationGet): string => { - if (entry.dateDelivery) { - return t("dashboard.appreciationSection.statusReceived"); - } - if (entry.dateDue) { - return `${t("dashboard.appreciationSection.statusDueTo")} ${formatDate(entry.dateDue)}`; - } - return EMPTY_PLACEHOLDER_VALUE; - }; + const statusLabels = useMemo(() => createAppreciationStatusLabelMap(t), [t]); + const getStatusLabel = (entry: ApiAppreciationGet) => statusLabels[entry.status] ?? EMPTY_PLACEHOLDER_VALUE; return ( @@ -118,6 +114,8 @@ export const Appreciation = forwardRef(function Apprecia {t("dashboard.appreciationSection.typeOfAppreciation")} {t("dashboard.appreciationSection.status")} + {t("dashboard.appreciationSection.statusDueTo")} + {t("dashboard.appreciationSection.statusMailedOn")} {t("dashboard.appreciationSection.receivedOn")} @@ -131,8 +129,23 @@ export const Appreciation = forwardRef(function Apprecia > {getAppreciationTypeLabel(t, entry.title)} - {getStatusLabel(entry)} + {getStatusLabel(entry)} + + + {entry.status === AppreciationStatusType.PENDING && entry.dateDue ? ( + formatDate(entry.dateDue) + ) : ( + + )} + + {entry.status === AppreciationStatusType.POST && entry.dateDue ? ( + formatDate(entry.dateDue) + ) : ( + + )} + + {entry.dateDelivery ? formatDate(entry.dateDelivery) : } diff --git a/src/components/Dashboard/Profile/sections/Appreciation/AppreciationDialog.tsx b/src/components/Dashboard/Profile/sections/Appreciation/AppreciationDialog.tsx index bba9c484..5f3d4282 100644 --- a/src/components/Dashboard/Profile/sections/Appreciation/AppreciationDialog.tsx +++ b/src/components/Dashboard/Profile/sections/Appreciation/AppreciationDialog.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/core/button"; import { DatePickerWithLabel } from "@/components/core/common/DatePicker"; import { Modal } from "@/components/core/modal"; import { de, enUS, Locale } from "date-fns/locale"; -import { ApiAppreciationGet, VolunteerStateAppreciationType } from "need4deed-sdk"; +import { VolunteerStateAppreciationType, ApiAppreciationGet, AppreciationStatusType } from "need4deed-sdk"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { SelectableOption } from "../shared/SelectableOption"; @@ -25,12 +25,11 @@ type Props = { title: VolunteerStateAppreciationType; dateDue: Date | null; dateDelivery: Date | null; + status: AppreciationStatusType; }) => void; initialData?: ApiAppreciationGet; }; -type DeliveryStatus = "received" | "pending"; - const APPRECIATION_TYPES = [ { value: VolunteerStateAppreciationType.TOTE_BAG, @@ -56,8 +55,36 @@ const APPRECIATION_TYPES = [ }, ]; +const DELIVERY_STATUSES = [ + { + status: AppreciationStatusType.PENDING, + labelKey: "dashboard.appreciationSection.needToGiveIt", + dateLabelKey: "dashboard.appreciationSection.dueDateRequired", + testId: "due-date-field", + allowFuture: true, + }, + { + status: AppreciationStatusType.POST, + labelKey: "dashboard.appreciationSection.volunteerPostIt", + dateLabelKey: "dashboard.appreciationSection.postOnRequired", + testId: "post-date-field", + }, + { + status: AppreciationStatusType.RECEIVED, + labelKey: "dashboard.appreciationSection.volunteerReceivedIt", + dateLabelKey: "dashboard.appreciationSection.receivedOnRequired", + testId: "received-date-field", + }, +]; + +function withoutDisallowedFuture(date: Date | undefined, status: AppreciationStatusType | undefined): Date | undefined { + if (!date || !status) return date; + const allowsFuture = DELIVERY_STATUSES.find((option) => option.status === status)?.allowFuture ?? false; + return !allowsFuture && date > new Date() ? undefined : date; +} + type DeliveryStatusOptionProps = { - status: DeliveryStatus; + status: AppreciationStatusType; isSelected: boolean; label: string; onSelect: () => void; @@ -110,21 +137,22 @@ export function AppreciationDialog({ isOpen, onClose, onSave, initialData }: Pro const locale = i18n.language === "de" ? de : enUS; const [selectedType, setSelectedType] = useState(undefined); - const [deliveryStatus, setDeliveryStatus] = useState(undefined); + const [deliveryStatus, setDeliveryStatus] = useState(undefined); const [selectedDate, setSelectedDate] = useState(undefined); useEffect(() => { if (!isOpen) return; - if (initialData) { setSelectedType(initialData.title); - if (initialData.dateDelivery) { - setDeliveryStatus("received"); - setSelectedDate(new Date(initialData.dateDelivery)); - } else if (initialData.dateDue) { - setDeliveryStatus("pending"); - setSelectedDate(new Date(initialData.dateDue)); - } + setDeliveryStatus(initialData.status); + + const storedDate = initialData.dateDelivery + ? new Date(initialData.dateDelivery) + : initialData.dateDue + ? new Date(initialData.dateDue) + : undefined; + + setSelectedDate(withoutDisallowedFuture(storedDate, initialData.status)); } else { setSelectedType(undefined); setDeliveryStatus(undefined); @@ -140,8 +168,13 @@ export function AppreciationDialog({ isOpen, onClose, onSave, initialData }: Pro } }; - const handleDeliveryStatusSelect = (status: DeliveryStatus) => { + const handleDeliveryStatusSelect = (status: AppreciationStatusType) => { setDeliveryStatus(status); + if (selectedDate && !withoutDisallowedFuture(selectedDate, status)) { + setSelectedDate(undefined); + return; + } + if (!selectedDate) { setSelectedDate(new Date()); } @@ -154,8 +187,9 @@ export function AppreciationDialog({ isOpen, onClose, onSave, initialData }: Pro onSave({ id: initialData?.id, title: selectedType, - dateDue: deliveryStatus === "pending" ? selectedDate : null, - dateDelivery: deliveryStatus === "received" ? selectedDate : null, + dateDue: deliveryStatus === AppreciationStatusType.RECEIVED ? null : selectedDate, + dateDelivery: deliveryStatus === AppreciationStatusType.RECEIVED ? selectedDate : null, + status: deliveryStatus, }); }; @@ -189,33 +223,23 @@ export function AppreciationDialog({ isOpen, onClose, onSave, initialData }: Pro {t("dashboard.appreciationSection.didVolunteerReceive")} - handleDeliveryStatusSelect("received")} - showDatePicker={deliveryStatus === "received"} - date={selectedDate} - onDateSelect={setSelectedDate} - locale={locale} - dateLabel={t("dashboard.appreciationSection.receivedOnRequired")} - todayText={todayText} - testId="received-date-field" - /> - handleDeliveryStatusSelect("pending")} - showDatePicker={deliveryStatus === "pending"} - date={selectedDate} - onDateSelect={setSelectedDate} - locale={locale} - dateLabel={t("dashboard.appreciationSection.dueDateRequired")} - todayText={todayText} - allowFuture - testId="due-date-field" - /> + {DELIVERY_STATUSES.map((option) => ( + handleDeliveryStatusSelect(option.status)} + showDatePicker={deliveryStatus === option.status} + date={selectedDate} + onDateSelect={setSelectedDate} + locale={locale} + dateLabel={t(option.dateLabelKey)} + todayText={todayText} + allowFuture={option.allowFuture} + testId={option.testId} + /> + ))} )} diff --git a/src/components/Dashboard/Profile/sections/Appreciation/constants.ts b/src/components/Dashboard/Profile/sections/Appreciation/constants.ts new file mode 100644 index 00000000..5eca7314 --- /dev/null +++ b/src/components/Dashboard/Profile/sections/Appreciation/constants.ts @@ -0,0 +1,8 @@ +import { TFunction } from "i18next"; +import { AppreciationStatusType } from "need4deed-sdk"; + +export const createAppreciationStatusLabelMap = (t: TFunction): Record => ({ + [AppreciationStatusType.RECEIVED]: t("dashboard.appreciationSection.statusReceived"), + [AppreciationStatusType.PENDING]: t("dashboard.appreciationSection.statusPending"), + [AppreciationStatusType.POST]: t("dashboard.appreciationSection.statusPost"), +}); diff --git a/src/components/Dashboard/Profile/sections/Appreciation/styles.ts b/src/components/Dashboard/Profile/sections/Appreciation/styles.ts index 590ba13d..16d8e629 100644 --- a/src/components/Dashboard/Profile/sections/Appreciation/styles.ts +++ b/src/components/Dashboard/Profile/sections/Appreciation/styles.ts @@ -1,13 +1,20 @@ import styled from "styled-components"; import { TableContainer } from "@/components/core/common/Table"; import { FlexColumn } from "@/components/styled/FlexColumn"; +import { AppreciationStatusType } from "need4deed-sdk"; export const AppreciationTableContainer = styled(TableContainer)` margin-top: var(--spacing-24); `; -export const StatusBadge = styled.div<{ $status: "received" | "pending" }>` - background: ${(props) => (props.$status === "received" ? "var(--color-green-100)" : "var(--color-red-50)")}; +const statusColorMap: Record = { + [AppreciationStatusType.RECEIVED]: "var(--color-green-100)", + [AppreciationStatusType.POST]: "var(--color-blue-100)", + [AppreciationStatusType.PENDING]: "var(--color-red-50)", +}; + +export const StatusBadge = styled.div<{ $status: AppreciationStatusType }>` + background: ${(props) => statusColorMap[props.$status] ?? "var(--color-grey-50)"}; padding: var(--spacing-12); border-radius: var(--border-radius-xs); font-weight: var(--font-weight-semi-bold); diff --git a/src/hooks/useAppreciationTracker.ts b/src/hooks/useAppreciationTracker.ts index 3924ec4c..921d0ab7 100644 --- a/src/hooks/useAppreciationTracker.ts +++ b/src/hooks/useAppreciationTracker.ts @@ -2,7 +2,7 @@ import { apiPathAppreciation, apiPathVolunteer } from "@/config/constants"; import { useGetQuery } from "@/hooks/useGetQuery"; import { useMutationQuery } from "@/hooks/useMutationQuery"; import axios from "axios"; -import { ApiAppreciationGet, ApiAppreciationPatch, ApiAppreciationPost } from "need4deed-sdk"; +import { ApiAppreciationPatch, ApiAppreciationPost, ApiAppreciationGet } from "need4deed-sdk"; export const useAppreciationTracker = (volunteerId: number) => { const queryKey = ["volunteer", String(volunteerId), "appreciations"]; diff --git a/yarn.lock b/yarn.lock index f81ad157..1bf4e78f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2412,10 +2412,10 @@ natural-compare@^1.4.0: resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -need4deed-sdk@0.0.146: - version "0.0.146" - resolved "https://registry.yarnpkg.com/need4deed-sdk/-/need4deed-sdk-0.0.146.tgz#42f62886f87e2797f987f74e3280a6b16256cc4c" - integrity sha512-7bQV6H5z0HtrCnCa9GnK1aXQi8wVs45xBoJanqjdfUNdLKaFCLuN1Zts3pzxToRq2jmhks5/7uZljyTr0wsJpw== +need4deed-sdk@0.0.147: + version "0.0.147" + resolved "https://registry.yarnpkg.com/need4deed-sdk/-/need4deed-sdk-0.0.147.tgz#b5d54178585dc519be975f7a0be4d29b6daf5713" + integrity sha512-SDZHVyVWrmMzESvjmEb87TV4299gLL3oyEZ4VgiiuLxwhqprdnzUDe5TaZT72FbYll/kQoV0fspMKjNq6+mKmw== next@15.3.8: version "15.3.8"