diff --git a/package.json b/package.json index 617389d0..cf3edbb7 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.107", + "need4deed-sdk": "0.0.110", "next": "15.3.8", "react": "^19.0.0", "react-day-picker": "^9.13.0", diff --git a/src/components/Dashboard/Home/AgentOpportunityCards.tsx b/src/components/Dashboard/Home/AgentOpportunityCards.tsx new file mode 100644 index 00000000..b8fd290e --- /dev/null +++ b/src/components/Dashboard/Home/AgentOpportunityCards.tsx @@ -0,0 +1,42 @@ +"use client"; +import { apiPathAgent, apiPathOption, cacheTTL } from "@/config/constants"; +import { useGetCurrentAgent } from "@/hooks/useGetCurrentAgent"; +import { useGetQuery } from "@/hooks"; +import { ApiOptionLists, ApiOpportunityGetList, ApiVolunteerOpportunityGetList } from "need4deed-sdk"; +import { OpportunityCard } from "../Opportunities/OpportunityCard"; +import { useTranslation } from "react-i18next"; +import { Heading4 } from "@/components/styled/text"; +import { DashboardCardContainer } from "./styles"; + +export function AgentOpportunityCards() { + const { t } = useTranslation(); + const { agentId } = useGetCurrentAgent(); + + const { data: apiFilterOptions } = useGetQuery({ queryKey: ["options"], apiPath: apiPathOption }); + const { data: opportunities, isLoading } = useGetQuery({ + queryKey: ["agent-opportunities", String(agentId)], + apiPath: `${apiPathAgent}/${agentId}/opportunity-linked`, + staleTime: cacheTTL, + enabled: !!agentId, + addLang: false, + }); + + const activitiesList = apiFilterOptions?.activity ?? undefined; + const districtsList = apiFilterOptions?.district ?? undefined; + + if (isLoading) return {t("dashboard.home.content.loading")}; + + return ( + + {opportunities?.map((opp) => ( + + ))} + + ); +} diff --git a/src/components/Dashboard/Home/HomeContent.tsx b/src/components/Dashboard/Home/HomeContent.tsx index 1ab95ae6..83e5492f 100644 --- a/src/components/Dashboard/Home/HomeContent.tsx +++ b/src/components/Dashboard/Home/HomeContent.tsx @@ -1,7 +1,10 @@ "use client"; import { Heading2, Heading3 } from "@/components/styled/text"; +import { useCurrentUser } from "@/hooks/useCurrentUser"; +import { UserRole } from "need4deed-sdk"; import React from "react"; import { useTranslation } from "react-i18next"; +import { AgentOpportunityCards } from "./AgentOpportunityCards"; import { CreateOpportunityButton } from "./CreateOpportunityButton"; import { NewestOpportunities } from "./NewestOpportunities"; import { NewestVolunteers } from "./NewestVolunteers"; @@ -10,6 +13,19 @@ import { NewestTaggedComments } from "./NewestTaggedComments"; export default function DashboardHomeContent() { const { t } = useTranslation(); + const user = useCurrentUser(true); + const isAgent = user?.role === UserRole.AGENT; + + if (isAgent) { + return ( + + {t("dashboard.home.content.header")} + + + + ); + } + return ( {t("dashboard.home.content.header")} diff --git a/src/components/Dashboard/NewOpportunity/NewOpportunity.tsx b/src/components/Dashboard/NewOpportunity/NewOpportunity.tsx index 812c5e5d..8607dae1 100644 --- a/src/components/Dashboard/NewOpportunity/NewOpportunity.tsx +++ b/src/components/Dashboard/NewOpportunity/NewOpportunity.tsx @@ -1,182 +1,689 @@ "use client"; -import { Button } from "@/components/core/button"; -import { FormInput } from "@/components/core/common"; -import { apiPathOpportunity, DashboardRoutes } from "@/config/constants"; +import { EditableField } from "@/components/EditableField/EditableField"; +import { SectionCard } from "@/components/Dashboard/Profile/common/SectionCard"; +import { apiToFormAvailability } 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 { + createOpportunityDetailsSchema, + OpportunityDetailsFormData, +} from "@/components/Dashboard/Profile/sections/OpportunityDetails/opportunityDetailsSchema"; +import { + AccompanyingDetailsFormData, + accompanyingDetailsSchema, +} from "@/components/Dashboard/Profile/sections/AccompanyingDetails/accompanyingDetailsSchema"; +import { AccompanyingDetailsEdit } from "@/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsEdit"; +import { FormDetails } from "@/components/Dashboard/Profile/sections/shared/styles"; +import { BackButton, PageContainer } from "@/components/Dashboard/Profile/styles"; +import { IconName } from "@/components/Dashboard/Profile/types"; +import { + Card, + IconContainer, + ProfileContent, + ProfileInfo, + StatusSection, + TitleSection, +} from "@/components/Dashboard/Profile/sections/ProfileHeader/common/profileHeaderStyles"; +import { createVolunteerTypeLabelMap } from "@/components/Dashboard/Profile/sections/ProfileHeader/common/labelMaps"; +import Button from "@/components/core/button/Button/Button"; +import { DatePickerWithLabel } from "@/components/core/common/DatePicker"; +import { ErrorMessage } from "@/components/core/common"; +import { AvailabilityGrid } from "@/components/forms/AvailabilityGrid/AvailabilityGrid"; +import { LanguageFields } from "@/components/forms/LanguageFields"; +import { apiPathOpportunity, DashboardRoutes, MAX_DESCRIPTION_LENGTH } from "@/config/constants"; import { useMutationQuery } from "@/hooks"; -import { useGetCurrentAgent } from "@/hooks/useGetCurrentAgent"; -import { ApiOpportunityGet } from "need4deed-sdk"; -import i18next from "i18next"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Heading2, Heading4 } from "@/components/styled/text"; +import { ShootingStarIcon, ArrowLeftIcon } from "@phosphor-icons/react"; +import { de, enUS } from "date-fns/locale"; +import { TFunction } from "i18next"; +import { + Lang, + OptionItem, + OpportunityFormDataWithAgentSubmitter, + TranslatedIntoType, + VolunteerStateTypeType, +} from "need4deed-sdk"; +import { useMemo } from "react"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { - AgentInfo, - AgentLabel, - AgentRow, - AgentValue, - Card, - ErrorBanner, - FieldLabel, - FieldWrapper, - PageSubtitle, - PageTitle, - SectionTitle, - Wrapper, -} from "./styled"; - -type CreateOpportunityBody = { - title: string; - contact: { - name: string; - phone: string; - email: string; +import styled from "styled-components"; +import { z } from "zod"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +const SELECTABLE_VOLUNTEER_TYPES = [ + VolunteerStateTypeType.REGULAR, + VolunteerStateTypeType.ACCOMPANYING, + VolunteerStateTypeType.EVENTS, +] as const; + +const createHeaderSchema = (t: (key: string) => string) => + z.object({ + title: z.string().min(1, t("form.error.required")), + volunteerType: z.enum([ + VolunteerStateTypeType.REGULAR, + VolunteerStateTypeType.ACCOMPANYING, + VolunteerStateTypeType.EVENTS, + ]), + }); + +type HeaderFormData = z.infer>; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function toLangOptionItems( + formLangs: { language: string }[], + apiLanguages: ApiLanguageOption[], + t: TFunction, +): OptionItem[] { + return formLangs.flatMap(({ language }) => { + if (!language) return []; + const numId = Number(language); + if (!isNaN(numId) && numId > 0) { + const found = apiLanguages.find((a) => a.id === numId); + return found ? [{ id: found.id, title: found.title }] : []; + } + const found = apiLanguages.find((a) => { + if (a.title === language || a.title.toLowerCase() === language.toLowerCase()) return true; + 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 }] : []; + }); +} + +function availabilityToTimeslots(availability: OpportunityDetailsFormData["availability"]): [number, string][] { + return (availability ?? []).flatMap(({ weekday, timeSlots }) => + timeSlots + .filter((ts) => ts.selected) + .map((ts) => { + const slotId = weekday === 0 ? ts.id.charAt(0).toUpperCase() + ts.id.slice(1) : ts.id; + return [weekday, slotId] as [number, string]; + }), + ); +} + +function buildCreatePayload( + headerData: HeaderFormData, + detailsData: OpportunityDetailsFormData, + accompData: AccompanyingDetailsFormData | null, + apiLanguages: ApiLanguageOption[], + apiActivities: ApiLanguageOption[], + apiSkills: ApiLanguageOption[], + lang: string, + t: TFunction, +): OpportunityFormDataWithAgentSubmitter { + const isEvent = headerData.volunteerType === VolunteerStateTypeType.EVENTS; + const isAccompanying = headerData.volunteerType === VolunteerStateTypeType.ACCOMPANYING; + + const mainLangIds = toLangOptionItems(detailsData.mainCommunication, apiLanguages, t).map((i) => String(i.id)); + const residentsLangIds = toLangOptionItems(detailsData.residentsSpeak, apiLanguages, t).map((i) => String(i.id)); + const refugeeLangIds = (accompData?.refugeeLanguage ?? []).map(String).filter(Boolean); + const languages = [...new Set([...mainLangIds, ...residentsLangIds, ...refugeeLangIds])]; + + const activities = toOptionItems(detailsData.activities, apiActivities).map((i) => String(i.id)); + const skills = toOptionItems(detailsData.skills, apiSkills).map((i) => String(i.id)); + const timeslots = isEvent ? null : availabilityToTimeslots(detailsData.availability); + + const onetime_date_time = + isEvent && detailsData.eventDate + ? `${detailsData.eventDate.toISOString().split("T")[0]}T${detailsData.eventTime || "00:00"}:00` + : null; + + const accomp_datetime = + isAccompanying && accompData?.appointmentDate + ? `${accompData.appointmentDate.toISOString().split("T")[0]}T${accompData.appointmentTime || "00:00"}:00` + : null; + + return { + title: headerData.title, + opportunity_type: isAccompanying ? "accompanying" : "volunteering", + vo_information: detailsData.description || null, + volunteers_number: Number(detailsData.numberOfVolunteers) || 1, + languages, + activities, + skills, + timeslots, + onetime_date_time, + accomp_address: isAccompanying ? (accompData?.appointmentAddress ?? null) : null, + accomp_postcode: isAccompanying ? (accompData?.appointmentPostcode ?? null) : null, + accomp_datetime, + accomp_name: isAccompanying ? (accompData?.refugeeName ?? null) : null, + accomp_phone: isAccompanying ? (accompData?.refugeeNumber ?? null) : null, + accomp_information: null, + accomp_translation: isAccompanying ? (accompData?.appointmentLanguage ?? null) : null, + berlin_locations: null, + category: "", + category_id: "", + language: lang as `${Lang}`, + agent_id: null, + submitted_by_id: null, + last_edited_time_notion: null, }; - agentId?: number; -}; +} + +// ─── Opportunity Details fields ─────────────────────────────────────────────── + +function OpportunityDetailsFields({ + isEvent, + apiLanguages, + apiActivities, + apiSkills, +}: { + isEvent: boolean; + apiLanguages: ApiLanguageOption[]; + apiActivities: ApiLanguageOption[]; + apiSkills: ApiLanguageOption[]; +}) { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + const locale = lang === "de" ? de : enUS; + const prefix = "dashboard.opportunityProfile.opportunityDetails"; + + const { + control, + formState: { errors }, + } = useFormContext(); + + const activityMapping = createMapping(apiActivities); + const skillMapping = createMapping(apiSkills); + const languagesForForm = apiLanguages.map((l) => ({ + id: l.id, + title: { [lang as Lang]: l.title } as Record, + })); + + return ( + + ( + + )} + /> + + ( + + +
+ + {fieldState.error?.message && } +
+
+ )} + /> + + ( + + +
+ + {fieldState.error?.message && } +
+
+ )} + /> + + {isEvent ? ( + <> + ( + + + + field.onChange(d ?? null)} + locale={locale} + allowFuture + /> + + + )} + /> + ( + + + + + + + )} + /> + + ) : ( + ( + + +
+ + {fieldState.error?.message && } +
+
+ )} + /> + )} + + ( + + )} + /> + + ( + activityMapping.idToTitle[Number(id)] || String(id))} + setValue={(value) => { + const labels = Array.isArray(value) ? value : [value]; + field.onChange(labels.map((label) => String(activityMapping.titleToId[label]))); + }} + options={apiActivities.map((a) => a.title)} + errorMessage={errors.activities?.message} + /> + )} + /> + + ( + skillMapping.idToTitle[Number(id)] || String(id))} + setValue={(value) => { + const labels = Array.isArray(value) ? value : [value]; + field.onChange(labels.map((label) => String(skillMapping.titleToId[label]))); + }} + options={apiSkills.map((s) => s.title)} + errorMessage={errors.skills?.message} + /> + )} + /> +
+ ); +} + +// ─── Main component ────────────────────────────────────────────────────────── export function NewOpportunity() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const lang = i18n.language; + const locale = lang === "de" ? de : enUS; const router = useRouter(); - const { agent, isLoading: agentLoading } = useGetCurrentAgent(); - - const [title, setTitle] = useState(""); - const [contactName, setContactName] = useState(""); - const [contactPhone, setContactPhone] = useState(""); - const [contactEmail, setContactEmail] = useState(""); - const [errors, setErrors] = useState>({}); - - const [prefilled, setPrefilled] = useState(false); - useEffect(() => { - if (!agent?.representative || prefilled) return; - setContactName(agent.representative.firstName ?? ""); - setContactPhone(agent.representative.phone ?? ""); - setContactEmail(agent.representative.email ?? ""); - setPrefilled(true); - }, [agent, prefilled]); + const volunteerTypeLabelMap = createVolunteerTypeLabelMap(t); + const { data: apiLanguages = [] } = useApiLanguages(); + const { data: apiActivities = [] } = useApiActivities(); + const { data: apiSkills = [] } = useApiSkills(); + + // Header form: title + volunteerType + const headerMethods = useForm({ + resolver: zodResolver(createHeaderSchema(t)), + mode: "onChange", + defaultValues: { title: "", volunteerType: VolunteerStateTypeType.REGULAR }, + }); const { - mutate: createOpportunity, - isPending, - error, - } = useMutationQuery({ + control: headerControl, + handleSubmit: handleHeaderSubmit, + watch: watchHeader, + formState: { errors: headerErrors }, + } = headerMethods; + const selectedType = watchHeader("volunteerType"); + const isAccompanying = selectedType === VolunteerStateTypeType.ACCOMPANYING; + const isEvent = selectedType === VolunteerStateTypeType.EVENTS; + + // Opportunity details form + const detailsMethods = useForm({ + resolver: zodResolver(createOpportunityDetailsSchema(t)), + mode: "onChange", + defaultValues: { + description: "", + numberOfVolunteers: "1", + mainCommunication: [{ id: 1, language: "", level: "" }], + residentsSpeak: [{ id: 1, language: "", level: "" }], + availability: undefined, + eventDate: null, + eventTime: "", + activities: [], + skills: [], + }, + }); + + // Accompanying details form (always initialised; only included in payload when type is ACCOMPANYING) + const accompanyingMethods = useForm({ + resolver: zodResolver(accompanyingDetailsSchema), + mode: "onChange", + defaultValues: { + appointmentAddress: "", + appointmentPostcode: "", + appointmentDate: null, + appointmentTime: "", + refugeeNumber: "", + refugeeName: "", + refugeeLanguage: [], + appointmentLanguage: undefined, + }, + }); + + // Accompanying section helpers + const keyToLabel: Record = {}; + const labelToKey: Record = {}; + apiLanguages.forEach((l) => { + keyToLabel[String(l.id)] = l.title; + labelToKey[l.title] = String(l.id); + }); + const appointmentLanguageKeys = Object.values(TranslatedIntoType); + const appointmentLanguageKeyToLabel: Record = {}; + const appointmentLanguageLabelToKey: Record = {}; + appointmentLanguageKeys.forEach((key) => { + const label = t(`dashboard.opportunityProfile.accompanyingDetails.appointmentLanguageOptions.${key}`); + appointmentLanguageKeyToLabel[key] = label; + appointmentLanguageLabelToKey[label] = key; + }); + const minAppointmentDate = useMemo(() => new Date(), []); + + const { mutate: createOpportunity, isPending } = useMutationQuery({ apiPath: `${apiPathOpportunity}/`, method: "post", - onSuccessCallback: (response) => { - const id = response?.data?.id; - if (id) { - router.push(`/${i18next.language}${DashboardRoutes.Opportunities}/${id}`); - } + onSuccessCallback: () => { + router.push(`/${lang}${DashboardRoutes.Home}`); }, }); - const validate = () => { - const next: Record = {}; - const required = t("form.error.required"); - if (!title.trim()) next.title = required; - if (!contactName.trim()) next.contactName = required; - if (!contactEmail.trim()) next.contactEmail = required; - else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contactEmail)) next.contactEmail = t("form.error.email"); - if (!contactPhone.trim()) next.contactPhone = required; - setErrors(next); - return Object.keys(next).length === 0; - }; - - const handleSubmit = () => { - if (!validate()) return; - createOpportunity({ - title: title.trim(), - contact: { - name: contactName.trim(), - phone: contactPhone.trim(), - email: contactEmail.trim(), - }, - agentId: agent?.id, - }); + const onSubmit = (headerData: HeaderFormData) => { + const payload = buildCreatePayload( + headerData, + detailsMethods.getValues(), + isAccompanying ? accompanyingMethods.getValues() : null, + apiLanguages, + apiActivities, + apiSkills, + lang, + t, + ); + createOpportunity(payload); }; return ( - + + router.back()}> + + {t("dashboard.volunteerProfile.backToDashboard")} + + + {t("dashboard.newOpportunity.title")} + + {/* Header card — title input + volunteer type selector */} - {t("dashboard.newOpportunity.title")} - {t("dashboard.newOpportunity.subtitle")} - - {error && {t("message.errorGeneric")}} - - - {t("dashboard.newOpportunity.fields.title")} - - + + + + + + + ( + + )} + /> + - {t("dashboard.newOpportunity.contactSection")} + + + {t("dashboard.volunteerProfile.volunteerHeader.volunteerType_title")} + + {SELECTABLE_VOLUNTEER_TYPES.map((type) => ( + headerMethods.setValue("volunteerType", type, { shouldValidate: true })} + > + {volunteerTypeLabelMap[type]} + + ))} + + + + + + - - {t("dashboard.opportunityProfile.contactDetails.name")} - - - - - {t("dashboard.opportunityProfile.contactDetails.email")} - - - - - {t("dashboard.opportunityProfile.contactDetails.phone")} - - - - {!agentLoading && agent && ( - <> - {t("dashboard.newOpportunity.agentSection")} - - - {t("dashboard.agentProfile.organisationDetails.title")} - {agent.title} - - {agent.agentDetails?.address && ( - - {t("agentRegistration.fields.addressStreet")} - {agent.agentDetails.address} - - )} - {agent.district && ( - - {t("agentRegistration.fields.district")} - {agent.district.title?.toString()} - - )} - - - )} + {/* Opportunity Details section */} + + + + } + /> + + {/* Accompanying Details section — only for ACCOMPANYING type */} + {isAccompanying && ( + + l.title)} + keyToLabel={keyToLabel} + labelToKey={labelToKey} + appointmentLanguageOptions={appointmentLanguageKeys.map((k) => appointmentLanguageKeyToLabel[k])} + appointmentLanguageKeyToLabel={appointmentLanguageKeyToLabel} + appointmentLanguageLabelToKey={appointmentLanguageLabelToKey} + onCancel={() => {}} + onSubmit={() => {}} + isPending={false} + minAppointmentDate={minAppointmentDate} + hideButtons + /> + + } + /> + )} +