diff --git a/package.json b/package.json index b620d3e4..c622f191 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.139", + "need4deed-sdk": "0.0.145", "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 95b0c789..e429cd67 100644 --- a/public/locales/de/translations.json +++ b/public/locales/de/translations.json @@ -830,7 +830,21 @@ "confirmMessage": "Dadurch wird \"{{opportunityName}}\" zu {{agentName}} verschoben. Die Kontaktdaten der antragstellenden Person bleiben gleich; zugewiesene Freiwillige werden mit übertragen.", "confirmButton": "Übertragen", "success": "Möglichkeit erfolgreich übertragen" - } + }, + "createAgent": { + "button": "Neue NGO hinzufügen", + "title": "Neue NGO hinzufügen", + "name": "Name der Organisation", + "addressStreet": "Straße", + "addressPostcode": "Postleitzahl", + "cancel": "Abbrechen", + "submit": "Erstellen", + "success": "NGO erfolgreich erstellt", + "validation": { + "titleRequired": "Name der Organisation ist erforderlich" + } + }, + "unclaimed": "Nicht übernommen" }, "opportunities": { "opportunities": "Ehrenamtsbedarfe", @@ -1077,7 +1091,9 @@ "new": "Neu", "active": "Aktiv", "unresponsive": "Nicht erreichbar", - "inactive": "Inaktiv" + "inactive": "Inaktiv", + "incontact": "In Kontakt", + "triedToContact": "Kontaktversuch unternommen" }, "volunteerSearch": { "notNeeded": "Nicht benötigt", @@ -1109,7 +1125,9 @@ "unresponsive": "Nicht erreichbar", "unresponsive_description": "Diese Einrichtung/dieses Projekt antwortet nicht.", "inactive": "Inaktiv", - "inactive_description": "Diese Einrichtung/dieses Projekt arbeitet nicht mehr mit der Organisation zusammen." + "inactive_description": "Diese Einrichtung/dieses Projekt arbeitet nicht mehr mit der Organisation zusammen.", + "incontact_description": "Diese Einrichtung/dieses Projekt ist in Kontakt mit der Organisation.", + "triedToContact_description": "Es wurde versucht, diese Einrichtung/dieses Projekt zu kontaktieren." }, "optionsVolSearch": { "agent-not-needed": "Keine Ehrenamtlichen mehr benötigt", diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 75219285..d0496c07 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -863,7 +863,21 @@ "confirmMessage": "This will move \"{{opportunityName}}\" to {{agentName}}. The applicant's contact details stay the same; assigned volunteers move with it.", "confirmButton": "Transfer", "success": "Opportunity transferred successfully" - } + }, + "createAgent": { + "button": "Add a new NGO", + "title": "Add a new NGO", + "name": "Organization name", + "addressStreet": "Street address", + "addressPostcode": "Postcode", + "cancel": "Cancel", + "submit": "Create", + "success": "NGO created successfully", + "validation": { + "titleRequired": "Organization name is required" + } + }, + "unclaimed": "Unclaimed" }, "opportunities": { "opportunities": "Opportunities", @@ -1065,7 +1079,9 @@ "new": "New", "active": "Active", "unresponsive": "Unresponsive", - "inactive": "Inactive" + "inactive": "Inactive", + "incontact": "In contact", + "triedToContact": "Tried to contact" }, "volunteerSearch": { "notNeeded": "Not needed", @@ -1097,7 +1113,9 @@ "unresponsive": "Unresponsive", "unresponsive_description": "This NGO does not respond.", "inactive": "Inactive", - "inactive_description": "This NGO does not work with the organization anymore." + "inactive_description": "This NGO does not work with the organization anymore.", + "incontact_description": "This NGO is in contact with the organization.", + "triedToContact_description": "We tried to contact this NGO but they did not respond." }, "optionsVolSearch": { "agent-not-needed": "No longer need volunteers", @@ -1145,7 +1163,6 @@ "confirmText": "Delete", "success": "Contact deleted successfully" }, - "validation": { "nameRequired": "Name is required", "districtRequired": "District is required", diff --git a/src/components/Dashboard/Agents/AgentCard.tsx b/src/components/Dashboard/Agents/AgentCard.tsx index a76f8b48..49d73724 100644 --- a/src/components/Dashboard/Agents/AgentCard.tsx +++ b/src/components/Dashboard/Agents/AgentCard.tsx @@ -17,6 +17,7 @@ import { useUpdateAgentStatus } from "@/hooks"; import { getNormalizedAgent } from "./helpers"; import { createVolunteerSearchMap } from "./constants"; import { StatusBadge } from "../common/StatusBadge"; +import { UnclaimedBadge } from "../common/UnclaimedBadge"; import { Card, CardDetailsInfo, CardHeader, CardHeaderInfo, DistrictContainer, DistrictDiv } from "./styles"; interface Props { @@ -28,7 +29,7 @@ interface Props { export const AgentCard = ({ agent, districtsList, onSelect }: Props) => { const { t, i18n } = useTranslation(); - const { id, title, district, volunteerSearch, services, type, trustLevel } = getNormalizedAgent(agent); + const { id, title, district, volunteerSearch, services, type, trustLevel, unclaimed } = getNormalizedAgent(agent); const districtTitle = district?.id ? (districtsList?.find((d) => d.id === district.id)?.title ?? null) : null; const lang = i18n.language as Lang; @@ -63,6 +64,7 @@ export const AgentCard = ({ agent, districtsList, onSelect }: Props) => { {title} + {unclaimed && } diff --git a/src/components/Dashboard/Agents/AgentTableRow.tsx b/src/components/Dashboard/Agents/AgentTableRow.tsx index 3d45c82a..d41a08f7 100644 --- a/src/components/Dashboard/Agents/AgentTableRow.tsx +++ b/src/components/Dashboard/Agents/AgentTableRow.tsx @@ -7,6 +7,7 @@ import { AGENT_COL_WIDTHS } from "./agentsTableColumns"; import { ClickableRow, TableCell } from "@/components/core/common/Table"; import { WrapAnywhereCell } from "../common/EntityTableList/styles"; import { CopyEmail } from "../common/CopyEmail"; +import { UnclaimedBadge } from "../common/UnclaimedBadge"; import { extractOptionTitle } from "@/components/Dashboard/Profile/sections/OpportunityDetails/formatters"; interface TableRowProps { @@ -20,7 +21,7 @@ interface TableRowProps { export function AgentTableRow({ agent, isLast, searchLabels, districtsList, onSelect }: TableRowProps) { const { i18n } = useTranslation(); - const { id, title, type, volunteerSearch, district, activeVolunteers, numOpportunities, email } = agent; + const { id, title, type, volunteerSearch, district, activeVolunteers, numOpportunities, email, unclaimed } = agent; const districtTitle = district?.id ? (districtsList?.find((d) => d.id === district.id)?.title ?? null) : null; const lang = i18n.language as Lang; @@ -44,6 +45,7 @@ export function AgentTableRow({ agent, isLast, searchLabels, districtsList, onSe {title} + {unclaimed && } {extractOptionTitle(type, lang)} diff --git a/src/components/Dashboard/Agents/Agents.tsx b/src/components/Dashboard/Agents/Agents.tsx index b4e78231..d3de3db7 100644 --- a/src/components/Dashboard/Agents/Agents.tsx +++ b/src/components/Dashboard/Agents/Agents.tsx @@ -1,9 +1,11 @@ "use client"; +import { PlusIcon } from "@phosphor-icons/react"; import { DashboardLayout } from "@/components/Layout"; import { AgentListController } from "./AgentListController"; +import { CreateAgentDialog } from "./CreateAgentDialog/CreateAgentDialog"; import { PendingMemberships } from "./PendingMemberships"; -import { AgentsContainer, ContentRow } from "./styles"; +import { AgentsContainer, ContentRow, CreateAgentButton } from "./styles"; import CardsHeader from "../common/CardsHeader/CardsHeader"; import { useTranslation } from "react-i18next"; import { useEffect, useState } from "react"; @@ -27,9 +29,11 @@ import { ConfirmationDialog } from "../Profile/sections/shared/ConfirmationDialo export const Agents = () => { const user = useCurrentUser(true); const isAgent = user?.role === UserRole.AGENT; + const canCreateAgent = user?.role === UserRole.COORDINATOR || user?.role === UserRole.ADMIN; const { t, i18n } = useTranslation(); const [selectedTabIndex, setSelectedTabIndex] = useState(0); const [isFiltersOpen, setIsFiltersOpen] = useState(false); + const [isCreateAgentOpen, setIsCreateAgentOpen] = useState(false); const [sortOrder, setSortOrder] = useState(SortOrder.NewToOld); const [numOfAgents, setNumOfAgents] = useState(0); const [cardsFilter, setCardsFilter] = useState(defaultAgentCardsFilter); @@ -131,6 +135,17 @@ export const Agents = () => { activeFilters={activeFilters} onClearAllFilters={handleClearAllFilters} onClearFilter={handleClearFilter} + headerAction={ + canCreateAgent && ( + setIsCreateAgentOpen(true)} + aria-label={t("dashboard.agents.createAgent.button")} + > + + + ) + } /> @@ -161,6 +176,7 @@ export const Agents = () => { onConfirm={handleTransferConfirm} /> )} + {canCreateAgent && setIsCreateAgentOpen(false)} />} ); }; diff --git a/src/components/Dashboard/Agents/CreateAgentDialog/CreateAgentDialog.tsx b/src/components/Dashboard/Agents/CreateAgentDialog/CreateAgentDialog.tsx new file mode 100644 index 00000000..2e3ed02b --- /dev/null +++ b/src/components/Dashboard/Agents/CreateAgentDialog/CreateAgentDialog.tsx @@ -0,0 +1,126 @@ +"use client"; + +import Button from "@/components/core/button/Button/Button"; +import { Modal } from "@/components/core/modal/Modal"; +import { EditableField } from "@/components/EditableField/EditableField"; +import { useCreateAgent } from "@/hooks/useCreateAgent"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Controller, ControllerRenderProps, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { ButtonRow, FormDetails } from "../../Profile/sections/shared/styles"; +import { CreateAgentFormData, createAgentFormSchema } from "./createAgentFormSchema"; +import { DialogTitle } from "./styles"; + +type Props = { + isOpen: boolean; + onClose: () => void; +}; + +const emptyValues: CreateAgentFormData = { title: "", addressStreet: "", addressPostcode: "" }; + +// Coordinator/admin-only "+" flow from the Agents table header (fe#911): a +// bare Agent with no linked Person/User, for an NGO the coordinator already +// has details for before it has self-registered. Deliberately minimal — just +// enough to create the placeholder record; richer fields (type, services, +// languages, about) are filled in later via the agent's own profile page, +// same as any other agent. +export const CreateAgentDialog = ({ isOpen, onClose }: Props) => { + const { t } = useTranslation(); + const { mutate: createAgent, isPending } = useCreateAgent(); + + const schema = createAgentFormSchema(t); + const { + control, + handleSubmit, + reset, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(schema), + mode: "onChange", + defaultValues: emptyValues, + }); + + const handleClose = () => { + reset(emptyValues); + onClose(); + }; + + const onSubmit = (values: CreateAgentFormData) => { + createAgent( + { + title: values.title, + addressStreet: values.addressStreet || undefined, + addressPostcode: values.addressPostcode || undefined, + }, + { onSuccess: handleClose }, + ); + }; + + if (!isOpen) return null; + + return ( + + {t("dashboard.agents.createAgent.title")} + + }) => ( + + )} + /> + }) => ( + + )} + /> + }) => ( + + )} + /> + + +