From 69eea0cb92ca9fa99b5f547a0824d62df343a218 Mon Sep 17 00:00:00 2001 From: Nadav Nir Date: Thu, 20 Aug 2026 12:56:21 +0200 Subject: [PATCH 1/2] feat: coordinator-created NGO/agent profiles from the Agents table (fe#911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a coordinator/admin-only "+" button to the Agents table header, opening a minimal create form (name + optional address) that calls the new POST /agent (be#891). The created agent has no linked Person/User — richer fields (type, services, languages, about) are filled in later via the agent's own profile page, same as any other agent. - CardsHeader gets a generic headerAction slot next to the title. - CreateAgentDialog: react-hook-form + zod, mirrors the existing add-contact dialog's pattern (useCreateAgentContact / NewContactRow). - Deliberately does not reuse the multi-step self-registration wizard — wrong UX for a quick placeholder-record creation. --- public/locales/de/translations.json | 13 ++ public/locales/en/translations.json | 14 +- src/components/Dashboard/Agents/Agents.tsx | 18 ++- .../CreateAgentDialog/CreateAgentDialog.tsx | 126 ++++++++++++++++++ .../createAgentFormSchema.ts | 11 ++ .../Agents/CreateAgentDialog/styles.ts | 6 + src/components/Dashboard/Agents/styles.ts | 21 +++ .../common/CardsHeader/CardsHeader.tsx | 12 +- .../Dashboard/common/CardsHeader/styles.ts | 6 + src/hooks/useCreateAgent.ts | 16 +++ 10 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 src/components/Dashboard/Agents/CreateAgentDialog/CreateAgentDialog.tsx create mode 100644 src/components/Dashboard/Agents/CreateAgentDialog/createAgentFormSchema.ts create mode 100644 src/components/Dashboard/Agents/CreateAgentDialog/styles.ts create mode 100644 src/hooks/useCreateAgent.ts diff --git a/public/locales/de/translations.json b/public/locales/de/translations.json index 95b0c789..3fb289c4 100644 --- a/public/locales/de/translations.json +++ b/public/locales/de/translations.json @@ -830,6 +830,19 @@ "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" + } } }, "opportunities": { diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 75219285..fbce5ceb 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -863,6 +863,19 @@ "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" + } } }, "opportunities": { @@ -1145,7 +1158,6 @@ "confirmText": "Delete", "success": "Contact deleted successfully" }, - "validation": { "nameRequired": "Name is required", "districtRequired": "District is required", 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")} + + }) => ( + + )} + /> + }) => ( + + )} + /> + }) => ( + + )} + /> + + +