Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"date-fns": "^4.1.0",
"email-validator": "^2.0.4",
"i18next": "^25.3.2",
"need4deed-sdk": "^0.0.77",
"need4deed-sdk": "^0.0.78",
"next": "15.3.8",
"react": "^19.0.0",
"react-day-picker": "^9.13.0",
Expand Down
14 changes: 14 additions & 0 deletions public/locales/de/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,20 @@
"unmatched": "Nicht gematcht"
},
"statusUpdateSuccess": "Status der Möglichkeit erfolgreich aktualisiert",
"change_status": "Status ändern",
"statusModal": {
"title": "Opportunitätsstatus ändern",
"save": "Speichern",
"cancel": "Abbrechen",
"options": {
"new": "Neu / Validierung erforderlich",
"new_description": "Die Möglichkeit wurde noch nicht validiert.",
"searching": "Validiert",
"searching_description": "Die Möglichkeit wurde validiert, es kann nach ihr gesucht werden.",
"inactive": "Inaktiv",
"inactive_description": "Die Möglichkeit ist nicht mehr aktiv."
}
},
"contactDetailsTitle": "Kontaktdaten",
"editButtonName": "Bearbeiten",
"contactDetails": {
Expand Down
14 changes: 14 additions & 0 deletions public/locales/en/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,20 @@
"unmatched": "Unmatched"
},
"statusUpdateSuccess": "Opportunity status updated successfully",
"change_status": "Change status",
"statusModal": {
"title": "Change opportunity status",
"save": "Save",
"cancel": "Cancel",
"options": {
"new": "New / Needs validation",
"new_description": "The opportunity was not validated yet.",
"searching": "Validated",
"searching_description": "The opportunity was validated, it can be searched for.",
"inactive": "Inactive",
"inactive_description": "The opportunity is no longer active."
}
},
"contactDetailsTitle": "Contact details",
"editButtonName": "Edit",
"contactDetails": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const statusColorMap: Record<OpportunityStatusType, string> = {
[OpportunityStatusType.NEW]: "var(--color-red-500)",
[OpportunityStatusType.SEARCHING]: "var(--color-orange-500, var(--color-red-500))",
[OpportunityStatusType.ACTIVE]: "var(--color-green-700)",
[OpportunityStatusType.INACTIVE]: "var(--color-grey-700)",
[OpportunityStatusType.PAST]: "var(--color-grey-700)",
};

Expand All @@ -30,6 +31,9 @@ export const statusIconMap: Record<OpportunityStatusType, JSX.Element> = {
<ShootingStarIcon size={18} color={statusColorMap[OpportunityStatusType.SEARCHING]} />
),
[OpportunityStatusType.ACTIVE]: <ShootingStarIcon size={18} color={statusColorMap[OpportunityStatusType.ACTIVE]} />,
[OpportunityStatusType.INACTIVE]: (
<ShootingStarIcon size={18} color={statusColorMap[OpportunityStatusType.INACTIVE]} />
),
[OpportunityStatusType.PAST]: <ShootingStarIcon size={18} color={statusColorMap[OpportunityStatusType.PAST]} />,
};

Expand Down
169 changes: 98 additions & 71 deletions src/components/Dashboard/Profile/common/statusMaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
VolunteerStateTypeType,
} from "need4deed-sdk";
import type React from "react";
import { OpportunityManualStatusType } from "../sections/ProfileHeader/opportunity/constants";
import { AgentEngagementStatus, AgentTrustLevel, AgentVolunteerSearch } from "../types";

export type StatusValue =
Expand All @@ -35,80 +36,106 @@ export type StatusValue =
| OpportunityMatchStatus
| AgentEngagementStatus
| AgentVolunteerSearch
| AgentTrustLevel;
| AgentTrustLevel
| OpportunityManualStatusType;

type SdkStatusValue = Exclude<StatusValue, OpportunityManualStatusType>;

type IconComponent = React.ComponentType<{ size?: number; color?: string }>;

// Several SDK enums share the same underlying string values (e.g. "new", "active",
// "pending-match"). Object literals with computed duplicate keys are a TS error, so
// these maps are built with Object.fromEntries — arrays have no such restriction and
// the SDK entries are built with Object.fromEntries — arrays have no such restriction and
// the last entry for a given key wins, matching the original intended behaviour.
export const statusColorMap: Record<StatusValue, string> = Object.fromEntries([
[VolunteerStateEngagementType.ACTIVE, "var(--color-green-100)"],
[VolunteerStateEngagementType.AVAILABLE, "var(--color-violet-100)"],
[VolunteerStateEngagementType.TEMP_UNAVAILABLE, "var( --color-red-50)"],
[VolunteerStateEngagementType.UNRESPONSIVE, "var(--color-grey-50)"],
[VolunteerStateEngagementType.INACTIVE, "var(--color-grey-50)"],
[VolunteerStateEngagementType.NEW, "var(--color-green-100)"],
[OpportunityMatchStatus.UNMATCHED, "var(--color-grey-50)"],
[OpportunityMatchStatus.PENDING_MATCH, "var(--color-violet-100)"],
[OpportunityMatchStatus.MATCHED, "var(--color-green-100)"],
[OpportunityMatchStatus.NEEDS_REMATCH, "var(--color-red-50)"],
[VolunteerStateMatchType.NO_MATCHES, "var(--color-grey-50)"],
[VolunteerStateMatchType.PENDING_MATCH, "var(--color-violet-100)"],
[VolunteerStateMatchType.MATCHED, "var(--color-green-100)"],
[VolunteerStateMatchType.NEEDS_REMATCH, "var(--color-red-50)"],
[VolunteerStateTypeType.ACCOMPANYING, "var(--color-blue-500)"],
[VolunteerStateTypeType.EVENTS, "var(--color-blue-500)"],
[VolunteerStateTypeType.REGULAR, "var(--color-blue-500)"],
[VolunteerStateTypeType.REGULAR_ACCOMPANYING, "var(--color-blue-500)"],
[OpportunityStatusType.SEARCHING, "var(--color-violet-100)"],
[OpportunityStatusType.NEW, "var(--color-violet-100)"],
[OpportunityStatusType.ACTIVE, "var(--color-green-50)"],
[OpportunityStatusType.PAST, "var(--color-grey-50)"],
[AgentVolunteerSearch.NOT_NEEDED, "var(--color-grey-50)"],
[AgentVolunteerSearch.VOLUNTEERS_FOUND, "var(--color-green-100)"],
[AgentVolunteerSearch.SEARCHING, "var(--color-red-50)"],
[AgentTrustLevel.UNKNOWN, "var(--color-grey-50)"],
[AgentTrustLevel.LOW, "var(--color-red-50)"],
[AgentTrustLevel.HIGH, "var(--color-green-100)"],
[AgentEngagementStatusType.NEW, "var(--color-violet-100)"],
[AgentEngagementStatusType.ACTIVE, "var(--color-green-100)"],
[AgentEngagementStatusType.INACTIVE, "var(--color-grey-50)"],
[AgentEngagementStatusType.UNRESPONSIVE, "var(--color-grey-50)"],
]) as Record<StatusValue, string>;
// OpportunityManualStatusType values are unique ("opp-*") and use a typed Record
// to preserve exhaustiveness checking.

type IconComponent = React.ComponentType<{ size?: number; color?: string }>;
const manualStatusColorMap: Record<OpportunityManualStatusType, string> = {
[OpportunityManualStatusType.NEW]: "var(--color-violet-100)",
[OpportunityManualStatusType.SEARCHING]: "var(--color-violet-100)",
[OpportunityManualStatusType.INACTIVE]: "var(--color-grey-50)",
};

export const statusColorMap: Record<StatusValue, string> = {
...(Object.fromEntries([
[VolunteerStateEngagementType.ACTIVE, "var(--color-green-100)"],
[VolunteerStateEngagementType.AVAILABLE, "var(--color-violet-100)"],
[VolunteerStateEngagementType.TEMP_UNAVAILABLE, "var( --color-red-50)"],
[VolunteerStateEngagementType.UNRESPONSIVE, "var(--color-grey-50)"],
[VolunteerStateEngagementType.INACTIVE, "var(--color-grey-50)"],
[VolunteerStateEngagementType.NEW, "var(--color-green-100)"],
[OpportunityMatchStatus.UNMATCHED, "var(--color-grey-50)"],
[OpportunityMatchStatus.PENDING_MATCH, "var(--color-violet-100)"],
[OpportunityMatchStatus.MATCHED, "var(--color-green-100)"],
[OpportunityMatchStatus.NEEDS_REMATCH, "var(--color-red-50)"],
[VolunteerStateMatchType.NO_MATCHES, "var(--color-grey-50)"],
[VolunteerStateMatchType.PENDING_MATCH, "var(--color-violet-100)"],
[VolunteerStateMatchType.MATCHED, "var(--color-green-100)"],
[VolunteerStateMatchType.NEEDS_REMATCH, "var(--color-red-50)"],
[VolunteerStateTypeType.ACCOMPANYING, "var(--color-blue-500)"],
[VolunteerStateTypeType.EVENTS, "var(--color-blue-500)"],
[VolunteerStateTypeType.REGULAR, "var(--color-blue-500)"],
[VolunteerStateTypeType.REGULAR_ACCOMPANYING, "var(--color-blue-500)"],
[OpportunityStatusType.SEARCHING, "var(--color-violet-100)"],
[OpportunityStatusType.NEW, "var(--color-violet-100)"],
[OpportunityStatusType.ACTIVE, "var(--color-green-50)"],
[OpportunityStatusType.INACTIVE, "var(--color-grey-50)"],
[OpportunityStatusType.PAST, "var(--color-grey-50)"],
[AgentVolunteerSearch.NOT_NEEDED, "var(--color-grey-50)"],
[AgentVolunteerSearch.VOLUNTEERS_FOUND, "var(--color-green-100)"],
[AgentVolunteerSearch.SEARCHING, "var(--color-red-50)"],
[AgentTrustLevel.UNKNOWN, "var(--color-grey-50)"],
[AgentTrustLevel.LOW, "var(--color-red-50)"],
[AgentTrustLevel.HIGH, "var(--color-green-100)"],
[AgentEngagementStatusType.NEW, "var(--color-violet-100)"],
[AgentEngagementStatusType.ACTIVE, "var(--color-green-100)"],
[AgentEngagementStatusType.INACTIVE, "var(--color-grey-50)"],
[AgentEngagementStatusType.UNRESPONSIVE, "var(--color-grey-50)"],
]) as Record<SdkStatusValue, string>),
...manualStatusColorMap,
};

const manualStatusIconMap: Record<OpportunityManualStatusType, IconComponent> = {
[OpportunityManualStatusType.NEW]: SparkleIcon,
[OpportunityManualStatusType.SEARCHING]: HourglassIcon,
[OpportunityManualStatusType.INACTIVE]: StopCircleIcon,
};

export const statusIconMap: Record<StatusValue, IconComponent> = Object.fromEntries([
[VolunteerStateEngagementType.ACTIVE, ChartLineIcon],
[VolunteerStateEngagementType.AVAILABLE, CalendarBlankIcon],
[VolunteerStateEngagementType.TEMP_UNAVAILABLE, CalendarXIcon],
[VolunteerStateEngagementType.UNRESPONSIVE, PhoneXIcon],
[VolunteerStateEngagementType.INACTIVE, StopCircleIcon],
[VolunteerStateEngagementType.NEW, SparkleIcon],
[OpportunityMatchStatus.UNMATCHED, ProhibitInsetIcon],
[OpportunityMatchStatus.PENDING_MATCH, HourglassIcon],
[OpportunityMatchStatus.MATCHED, CheckCircleIcon],
[OpportunityMatchStatus.NEEDS_REMATCH, ArrowsClockwiseIcon],
[VolunteerStateMatchType.NO_MATCHES, ProhibitInsetIcon],
[VolunteerStateMatchType.PENDING_MATCH, HourglassIcon],
[VolunteerStateMatchType.MATCHED, CheckCircleIcon],
[VolunteerStateMatchType.NEEDS_REMATCH, ArrowsClockwiseIcon],
[VolunteerStateTypeType.ACCOMPANYING, UsersIcon],
[VolunteerStateTypeType.EVENTS, UsersIcon],
[VolunteerStateTypeType.REGULAR, UsersIcon],
[VolunteerStateTypeType.REGULAR_ACCOMPANYING, UsersIcon],
[OpportunityStatusType.NEW, SparkleIcon],
[OpportunityStatusType.ACTIVE, ChartLineIcon],
[OpportunityStatusType.SEARCHING, HourglassIcon],
[OpportunityStatusType.PAST, StopCircleIcon],
[AgentVolunteerSearch.NOT_NEEDED, HandPalmIcon],
[AgentVolunteerSearch.VOLUNTEERS_FOUND, CheckCircleIcon],
[AgentVolunteerSearch.SEARCHING, BinocularsIcon],
[AgentTrustLevel.UNKNOWN, QuestionIcon],
[AgentTrustLevel.LOW, SmileySadIcon],
[AgentTrustLevel.HIGH, SmileyIcon],
[AgentEngagementStatusType.NEW, SparkleIcon],
[AgentEngagementStatusType.ACTIVE, ChartLineIcon],
[AgentEngagementStatusType.INACTIVE, StopCircleIcon],
[AgentEngagementStatusType.UNRESPONSIVE, PhoneXIcon],
]) as unknown as Record<StatusValue, IconComponent>;
export const statusIconMap: Record<StatusValue, IconComponent> = {
...(Object.fromEntries([
[VolunteerStateEngagementType.ACTIVE, ChartLineIcon],
[VolunteerStateEngagementType.AVAILABLE, CalendarBlankIcon],
[VolunteerStateEngagementType.TEMP_UNAVAILABLE, CalendarXIcon],
[VolunteerStateEngagementType.UNRESPONSIVE, PhoneXIcon],
[VolunteerStateEngagementType.INACTIVE, StopCircleIcon],
[VolunteerStateEngagementType.NEW, SparkleIcon],
[OpportunityMatchStatus.UNMATCHED, ProhibitInsetIcon],
[OpportunityMatchStatus.PENDING_MATCH, HourglassIcon],
[OpportunityMatchStatus.MATCHED, CheckCircleIcon],
[OpportunityMatchStatus.NEEDS_REMATCH, ArrowsClockwiseIcon],
[VolunteerStateMatchType.NO_MATCHES, ProhibitInsetIcon],
[VolunteerStateMatchType.PENDING_MATCH, HourglassIcon],
[VolunteerStateMatchType.MATCHED, CheckCircleIcon],
[VolunteerStateMatchType.NEEDS_REMATCH, ArrowsClockwiseIcon],
[VolunteerStateTypeType.ACCOMPANYING, UsersIcon],
[VolunteerStateTypeType.EVENTS, UsersIcon],
[VolunteerStateTypeType.REGULAR, UsersIcon],
[VolunteerStateTypeType.REGULAR_ACCOMPANYING, UsersIcon],
[OpportunityStatusType.NEW, SparkleIcon],
[OpportunityStatusType.ACTIVE, ChartLineIcon],
[OpportunityStatusType.SEARCHING, HourglassIcon],
[OpportunityStatusType.INACTIVE, StopCircleIcon],
[OpportunityStatusType.PAST, StopCircleIcon],
[AgentVolunteerSearch.NOT_NEEDED, HandPalmIcon],
[AgentVolunteerSearch.VOLUNTEERS_FOUND, CheckCircleIcon],
[AgentVolunteerSearch.SEARCHING, BinocularsIcon],
[AgentTrustLevel.UNKNOWN, QuestionIcon],
[AgentTrustLevel.LOW, SmileySadIcon],
[AgentTrustLevel.HIGH, SmileyIcon],
[AgentEngagementStatusType.NEW, SparkleIcon],
[AgentEngagementStatusType.ACTIVE, ChartLineIcon],
[AgentEngagementStatusType.INACTIVE, StopCircleIcon],
[AgentEngagementStatusType.UNRESPONSIVE, PhoneXIcon],
]) as unknown as Record<SdkStatusValue, IconComponent>),
...manualStatusIconMap,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"use client";
import { useTranslation } from "react-i18next";
import { ChangeStatusDialog } from "../common";
import { createOpportunityStatusLabelMap, OpportunityManualStatusType, STATUS_DESCRIPTION_KEYS } from "./constants";
import { UseOpportunityStatusDialogReturn } from "./useOpportunityStatusDialog";

type Props = {
dialog: UseOpportunityStatusDialogReturn;
};

export const ChangeOpportunityStatusDialog = ({
dialog: { isOpen, closeDialog, selected, setSelected, saveDialog, isSaveDisabled },
}: Props) => {
const { t } = useTranslation();
const statusLabelMap = createOpportunityStatusLabelMap(t);

const options = Object.values(OpportunityManualStatusType).map((status) => ({
value: status,
label: statusLabelMap[status],
description: t(`dashboard.opportunityProfile.statusModal.options.${STATUS_DESCRIPTION_KEYS[status]}`),
}));

return (
<ChangeStatusDialog
testId="change-opportunity-status-dialog"
isOpen={isOpen}
title={t("dashboard.opportunityProfile.statusModal.title")}
options={options}
selected={selected}
onSelect={setSelected}
onSave={saveDialog}
onCancel={closeDialog}
isSaveDisabled={isSaveDisabled}
radioName="opportunity-status"
saveLabel={t("dashboard.opportunityProfile.statusModal.save")}
cancelLabel={t("dashboard.opportunityProfile.statusModal.cancel")}
/>
);
};
Original file line number Diff line number Diff line change
@@ -1,37 +1,26 @@
"use client";
import { EMPTY_PLACEHOLDER_VALUE } from "@/config/constants";
import { useUpdateOpportunityStatus } from "@/hooks/useUpdateOpportunityStatus";
import { formatDateTime } from "@/utils";
import { ShootingStarIcon } from "@phosphor-icons/react";
import { ApiOpportunityGet, OpportunityStatusType } from "need4deed-sdk";
import { ApiOpportunityGet } from "need4deed-sdk";
import { useTranslation } from "react-i18next";
import { createVolunteerTypeLabelMap, EditButton, HeaderCard, IconContainer, StatusRowField } from "../common";
import { ChangeOpportunityStatusDialog } from "./ChangeOpportunityStatusDialog";
import { createOpportunityStatusLabelMap } from "./constants";
import { useOpportunityStatusDialog } from "./useOpportunityStatusDialog";

type Props = {
opportunity: ApiOpportunityGet;
};

const createStatusLabelMap = (t: (key: string) => string): Record<OpportunityStatusType, string> => ({
[OpportunityStatusType.NEW]: t("dashboard.opportunityProfile.status.new"),
[OpportunityStatusType.ACTIVE]: t("dashboard.opportunityProfile.status.active"),
[OpportunityStatusType.PAST]: t("dashboard.opportunityProfile.status.past"),
[OpportunityStatusType.SEARCHING]: t("dashboard.opportunityProfile.status.searching"),
});

export const OpportunityHeader = ({ opportunity }: Props) => {
const { t } = useTranslation();
const { mutate: updateStatus } = useUpdateOpportunityStatus(opportunity.id);

const statusLabels = createStatusLabelMap(t);
const dialog = useOpportunityStatusDialog(opportunity);
const statusLabelMap = createOpportunityStatusLabelMap(t);
const volunteerTypeLabelMap = createVolunteerTypeLabelMap(t);

const postedDate = opportunity.createdAt ? formatDateTime(opportunity.createdAt) : EMPTY_PLACEHOLDER_VALUE;
const subtitle = `${t("dashboard.opportunityProfile.postedOn")} ${postedDate}`;
const isButtonDisabled = opportunity.statusOpportunity !== OpportunityStatusType.NEW;

const handleStatusChange = () => {
updateStatus({ statusOpportunity: OpportunityStatusType.SEARCHING });
};

return (
<HeaderCard
Expand All @@ -43,16 +32,13 @@ export const OpportunityHeader = ({ opportunity }: Props) => {
}
title={opportunity.title}
subtitle={subtitle}
after={<ChangeOpportunityStatusDialog dialog={dialog} />}
>
<StatusRowField
title={t("dashboard.opportunityProfile.currentStatus")}
status={opportunity.statusOpportunity}
label={statusLabels[opportunity.statusOpportunity]}
action={
<EditButton onClick={handleStatusChange} disabled={isButtonDisabled}>
{t("dashboard.volunteerProfile.volunteerHeader.change_status")}
</EditButton>
}
status={dialog.selected}
label={statusLabelMap[dialog.selected]}
action={<EditButton onClick={dialog.openDialog}>{t("dashboard.opportunityProfile.change_status")}</EditButton>}
/>

<StatusRowField
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { TFunction } from "i18next";
import { OpportunityStatusType } from "need4deed-sdk";

export enum OpportunityManualStatusType {
NEW = "opp-new",
SEARCHING = "opp-searching",
INACTIVE = "opp-inactive",
}

export const SDK_TO_MANUAL: Partial<Record<OpportunityStatusType, OpportunityManualStatusType>> = {
[OpportunityStatusType.NEW]: OpportunityManualStatusType.NEW,
[OpportunityStatusType.SEARCHING]: OpportunityManualStatusType.SEARCHING,
[OpportunityStatusType.ACTIVE]: OpportunityManualStatusType.SEARCHING,
[OpportunityStatusType.INACTIVE]: OpportunityManualStatusType.INACTIVE,
[OpportunityStatusType.PAST]: OpportunityManualStatusType.INACTIVE,
};

export const MANUAL_TO_SDK: Partial<Record<OpportunityManualStatusType, OpportunityStatusType>> = {
[OpportunityManualStatusType.NEW]: OpportunityStatusType.NEW,
[OpportunityManualStatusType.SEARCHING]: OpportunityStatusType.SEARCHING,
[OpportunityManualStatusType.INACTIVE]: OpportunityStatusType.INACTIVE,
};

export const STATUS_DESCRIPTION_KEYS: Record<OpportunityManualStatusType, string> = {
[OpportunityManualStatusType.NEW]: "new_description",
[OpportunityManualStatusType.SEARCHING]: "searching_description",
[OpportunityManualStatusType.INACTIVE]: "inactive_description",
};

export const createOpportunityStatusLabelMap = (t: TFunction): Record<OpportunityManualStatusType, string> => ({
[OpportunityManualStatusType.NEW]: t("dashboard.opportunityProfile.statusModal.options.new"),
[OpportunityManualStatusType.SEARCHING]: t("dashboard.opportunityProfile.statusModal.options.searching"),
[OpportunityManualStatusType.INACTIVE]: t("dashboard.opportunityProfile.statusModal.options.inactive"),
});
Loading
Loading