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.139",
"need4deed-sdk": "0.0.145",
"next": "15.3.8",
"react": "^19.0.0",
"react-day-picker": "^9.13.0",
Expand Down
24 changes: 21 additions & 3 deletions public/locales/de/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
25 changes: 21 additions & 4 deletions public/locales/en/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1145,7 +1163,6 @@
"confirmText": "Delete",
"success": "Contact deleted successfully"
},

"validation": {
"nameRequired": "Name is required",
"districtRequired": "District is required",
Expand Down
4 changes: 3 additions & 1 deletion src/components/Dashboard/Agents/AgentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;

Expand Down Expand Up @@ -63,6 +64,7 @@ export const AgentCard = ({ agent, districtsList, onSelect }: Props) => {
<CardHeader>
<CardHeaderInfo>
<Heading4>{title}</Heading4>
{unclaimed && <UnclaimedBadge />}
</CardHeaderInfo>
</CardHeader>
<CardDetailsInfo>
Expand Down
4 changes: 3 additions & 1 deletion src/components/Dashboard/Agents/AgentTableRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;

Expand All @@ -44,6 +45,7 @@ export function AgentTableRow({ agent, isLast, searchLabels, districtsList, onSe
<ClickableRow {...rowProps} $isLast={isLast} data-testid={`agent-row-${id}`}>
<WrapAnywhereCell $width={AGENT_COL_WIDTHS.title} data-testid={`agent-title-${id}`}>
{title}
{unclaimed && <UnclaimedBadge />}
</WrapAnywhereCell>
<WrapAnywhereCell $width={AGENT_COL_WIDTHS.type} data-testid={`agent-type-${id}`}>
{extractOptionTitle(type, lang)}
Expand Down
18 changes: 17 additions & 1 deletion src/components/Dashboard/Agents/Agents.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -131,6 +135,17 @@ export const Agents = () => {
activeFilters={activeFilters}
onClearAllFilters={handleClearAllFilters}
onClearFilter={handleClearFilter}
headerAction={
canCreateAgent && (
<CreateAgentButton
type="button"
onClick={() => setIsCreateAgentOpen(true)}
aria-label={t("dashboard.agents.createAgent.button")}
>
<PlusIcon size={16} weight="bold" />
</CreateAgentButton>
)
}
/>
<PendingMemberships />
<ContentRow>
Expand Down Expand Up @@ -161,6 +176,7 @@ export const Agents = () => {
onConfirm={handleTransferConfirm}
/>
)}
{canCreateAgent && <CreateAgentDialog isOpen={isCreateAgentOpen} onClose={() => setIsCreateAgentOpen(false)} />}
</DashboardLayout>
);
};
Original file line number Diff line number Diff line change
@@ -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<CreateAgentFormData>({
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 (
<Modal isOpen={isOpen} onClose={handleClose}>
<DialogTitle>{t("dashboard.agents.createAgent.title")}</DialogTitle>
<FormDetails data-testid="create-agent-form-fields">
<Controller
name="title"
control={control}
render={({ field }: { field: ControllerRenderProps<CreateAgentFormData, "title"> }) => (
<EditableField
mode="edit"
type="text"
label={t("dashboard.agents.createAgent.name")}
value={field.value}
setValue={field.onChange}
errorMessage={errors.title?.message}
/>
)}
/>
<Controller
name="addressStreet"
control={control}
render={({ field }: { field: ControllerRenderProps<CreateAgentFormData, "addressStreet"> }) => (
<EditableField
mode="edit"
type="text"
label={t("dashboard.agents.createAgent.addressStreet")}
value={field.value ?? ""}
setValue={field.onChange}
errorMessage={errors.addressStreet?.message}
/>
)}
/>
<Controller
name="addressPostcode"
control={control}
render={({ field }: { field: ControllerRenderProps<CreateAgentFormData, "addressPostcode"> }) => (
<EditableField
mode="edit"
type="text"
label={t("dashboard.agents.createAgent.addressPostcode")}
value={field.value ?? ""}
setValue={field.onChange}
errorMessage={errors.addressPostcode?.message}
/>
)}
/>
</FormDetails>
<ButtonRow>
<Button
text={t("dashboard.agents.createAgent.cancel")}
onClick={handleClose}
width="auto"
backgroundcolor="var(--color-white)"
textColor="var(--color-aubergine)"
border="var(--volunteer-profile-section-card-header-button-border)"
/>
<Button
text={t("dashboard.agents.createAgent.submit")}
onClick={handleSubmit(onSubmit)}
width="auto"
disabled={!isValid || isPending}
/>
</ButtonRow>
</Modal>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { z } from "zod";

export const createAgentFormSchema = (t: (key: string) => string) => {
return z.object({
title: z.string().min(1, t("dashboard.agents.createAgent.validation.titleRequired")),
addressStreet: z.string().optional().or(z.literal("")),
addressPostcode: z.string().optional().or(z.literal("")),
});
};

export type CreateAgentFormData = z.infer<ReturnType<typeof createAgentFormSchema>>;
6 changes: 6 additions & 0 deletions src/components/Dashboard/Agents/CreateAgentDialog/styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import styled from "styled-components";
import { Heading3 } from "@/components/styled/text";

export const DialogTitle = styled(Heading3)`
margin-bottom: 16px;
`;
8 changes: 8 additions & 0 deletions src/components/Dashboard/Agents/styles.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BaseCard } from "@/components/styled/container";
import { circleIconButtonStyles } from "@/components/styled/mixins";
import styled from "styled-components";

export const AgentsContainer = styled.div`
Expand All @@ -7,6 +8,13 @@ export const AgentsContainer = styled.div`
gap: var(--dashboard-volunteers-container-gap);
`;

// Coordinator/admin-only "create agent" button next to the Agents table
// header (fe#911) — same circular icon-button treatment as the agent
// profile's "add contact" button.
export const CreateAgentButton = styled.button`
${circleIconButtonStyles}
`;

export const ContentRow = styled.div`
display: flex;
flex-direction: row;
Expand Down
Loading
Loading