From 6d1af7a0113b6e3de44ab9bf2645591f4d6d68b4 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Mon, 11 May 2026 23:40:07 +0200 Subject: [PATCH] feat(customers): "Manage galleries" dialog on customer detail page --- .../components/admin/AssignedEventsDialog.tsx | 307 ++++++++++++++++++ frontend/src/i18n/locales/de.json | 20 +- frontend/src/i18n/locales/en.json | 20 +- frontend/src/i18n/locales/fr.json | 20 +- frontend/src/i18n/locales/nl.json | 20 +- frontend/src/i18n/locales/pt.json | 20 +- frontend/src/i18n/locales/ru.json | 20 +- .../src/pages/admin/CustomerDetailPage.tsx | 46 ++- .../src/services/customerAdmin.service.ts | 20 ++ 9 files changed, 482 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/admin/AssignedEventsDialog.tsx diff --git a/frontend/src/components/admin/AssignedEventsDialog.tsx b/frontend/src/components/admin/AssignedEventsDialog.tsx new file mode 100644 index 00000000..82668c4c --- /dev/null +++ b/frontend/src/components/admin/AssignedEventsDialog.tsx @@ -0,0 +1,307 @@ +/** + * AssignedEventsDialog (#354 follow-up). + * + * Modal dialog that lets an admin replace the full set of events a + * single customer is assigned to. Mounted from the "Assigned events" + * card on CustomerDetailPage via the "Manage galleries" button. + * + * UX shape — multi-select autocomplete (mirrors CustomerAccountPicker): + * - Search box at the top filters available events (admin-side + * event list, debounced 200ms). + * - Currently-selected events render as chips above the search. + * - Click a chip to remove. Click a search result to add. + * - Save replaces the customer's full assignment list via + * PUT /admin/customers/:id/events. + * + * Access revocation: removing a chip + saving deletes the + * event_customer_assignments row. Gallery middleware re-checks that + * row on every customer-minted JWT, so the customer's next request + * to a removed gallery 403s with CUSTOMER_ASSIGNMENT_REVOKED — no + * token-blacklist step needed on the frontend. + */ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Search, X, Calendar as CalendarIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Button } from '../common'; +import { customerAdminService } from '../../services/customerAdmin.service'; +import { eventsService } from '../../services/events.service'; +import type { Event as AdminEvent } from '../../services/events.service'; + +interface SelectedEvent { + id: number; + eventName: string; + eventDate: string | null; +} + +interface Props { + customerId: number; + isOpen: boolean; + initial: SelectedEvent[]; + onClose: () => void; + /** Called after a successful save so the parent can refetch. */ + onSaved: () => void; +} + +export const AssignedEventsDialog: React.FC = ({ customerId, isOpen, initial, onClose, onSaved }) => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const [selected, setSelected] = useState(initial); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const searchInputRef = useRef(null); + + // Re-seed selection whenever the dialog is opened so we always start + // from the server-current assignment list (not whatever the parent + // last refetched before the previous close). + useEffect(() => { + if (isOpen) { + setSelected(initial); + setQuery(''); + setResults([]); + // Autofocus the search input after the open animation settles. + setTimeout(() => searchInputRef.current?.focus(), 50); + } + }, [isOpen, initial]); + + // Debounced event search. Aborts in-flight responses so a fast typer + // doesn't see a stale result win the race. + useEffect(() => { + if (!isOpen) return undefined; + const term = query.trim(); + if (!term) { + setResults([]); + setIsSearching(false); + return undefined; + } + setIsSearching(true); + let cancelled = false; + const handle = window.setTimeout(async () => { + try { + const resp = await eventsService.getEvents(1, 25, undefined, term); + const events = Array.isArray((resp as any)?.events) + ? (resp as any).events as AdminEvent[] + : ([] as AdminEvent[]); + if (!cancelled) { + // Filter out already-selected ids client-side. Cheaper than + // round-tripping the selection state through the search API + // and keeps the matching logic in one place. + const selectedIds = new Set(selected.map((s) => s.id)); + setResults(events.filter((e) => !selectedIds.has(e.id))); + } + } catch { + if (!cancelled) setResults([]); + } finally { + if (!cancelled) setIsSearching(false); + } + }, 200); + return () => { cancelled = true; window.clearTimeout(handle); }; + }, [query, selected, isOpen]); + + const add = (ev: AdminEvent) => { + setSelected((prev) => [ + ...prev, + { id: ev.id, eventName: ev.event_name, eventDate: ev.event_date || null }, + ]); + setQuery(''); + setResults([]); + searchInputRef.current?.focus(); + }; + + const remove = (id: number) => { + setSelected((prev) => prev.filter((s) => s.id !== id)); + }; + + const initialIds = useMemo(() => new Set(initial.map((s) => s.id)), [initial]); + const selectedIds = useMemo(() => new Set(selected.map((s) => s.id)), [selected]); + const isDirty = useMemo(() => { + if (selectedIds.size !== initialIds.size) return true; + for (const id of selectedIds) { + if (!initialIds.has(id)) return true; + } + return false; + }, [selectedIds, initialIds]); + + const saveMutation = useMutation({ + mutationFn: () => customerAdminService.setEvents(customerId, [...selectedIds]), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] }); + queryClient.invalidateQueries({ queryKey: ['admin-customers'] }); + // Surface the diff so it's obvious revocations took effect. + const parts: string[] = []; + if (result.added) parts.push(t('customers.assignedEvents.addedN', '{{count}} added', { count: result.added })); + if (result.removed) parts.push(t('customers.assignedEvents.removedN', '{{count}} removed', { count: result.removed })); + toast.success(parts.length + ? t('customers.assignedEvents.savedDiff', 'Assignments updated: {{parts}}', { parts: parts.join(', ') }) + : t('customers.assignedEvents.saved', 'Assignments updated')); + onSaved(); + onClose(); + }, + onError: () => { + toast.error(t('customers.assignedEvents.error', 'Could not update assignments')); + }, + }); + + if (!isOpen) return null; + + return ( +
{ + // Click-outside to close — only when the click was actually on + // the backdrop, not on a child element that bubbled up. + if (e.target === e.currentTarget && !saveMutation.isPending) onClose(); + }} + > +
+ {/* Header */} +
+
+

+ {t('customers.assignedEvents.title', 'Manage assigned galleries')} +

+

+ {t( + 'customers.assignedEvents.subtitle', + 'Pick every gallery this customer should be able to access from their dashboard. Removing a gallery here revokes access immediately on the customer\'s next request.', + )} +

+
+ +
+ + {/* Body */} +
+ {/* Selected chips */} +
+ + {selected.length === 0 ? ( +

+ {t('customers.assignedEvents.empty', 'No galleries assigned yet. Search below to add one.')} +

+ ) : ( +
    + {selected.map((s) => ( +
  • + + {s.eventName} + +
  • + ))} +
+ )} +
+ + {/* Search */} +
+ +
+ + setQuery(e.target.value)} + placeholder={t('customers.assignedEvents.searchPlaceholder', 'Search by event name')} + disabled={saveMutation.isPending} + className="w-full pl-9 pr-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" + /> +
+ + {/* Results dropdown — inline (not a popover) since this is + already inside a modal, no nested-popover headaches. */} +
+ {!query.trim() ? ( +

+ {t('customers.assignedEvents.searchHint', 'Start typing to find galleries.')} +

+ ) : isSearching ? ( +

+ {t('common.searching', 'Searching…')} +

+ ) : results.length === 0 ? ( +

+ {t('customers.assignedEvents.noResults', 'No matching galleries.')} +

+ ) : ( +
    + {results.map((ev) => ( +
  • + +
  • + ))} +
+ )} +
+
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 10342032..74ff2c1d 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -2938,7 +2938,8 @@ "success": "Passwort-Reset-E-Mail gesendet", "error": "Passwort-Reset konnte nicht gesendet werden", "inactive": "Aktiviere den Kunden, bevor du einen Reset sendest." - } + }, + "manageEvents": "Galerien verwalten" }, "reactivate": { "button": "Reaktivieren", @@ -2953,6 +2954,23 @@ "confirmInFlight": "Lösche…", "success": "Kunde gelöscht", "error": "Kunde konnte nicht gelöscht werden" + }, + "assignedEvents": { + "title": "Zugewiesene Galerien verwalten", + "subtitle": "Wähle jede Galerie aus, auf die dieser Kunde über sein Dashboard zugreifen können soll. Eine hier entfernte Galerie wird beim nächsten Aufruf des Kunden sofort gesperrt.", + "currentLabel": "Zugewiesene Galerien", + "empty": "Noch keine Galerien zugewiesen. Suche unten, um eine hinzuzufügen.", + "searchLabel": "Galerie hinzufügen", + "searchPlaceholder": "Nach Eventname suchen", + "searchHint": "Beginne zu tippen, um Galerien zu finden.", + "noResults": "Keine passenden Galerien.", + "removeAria": "{{name}} entfernen", + "save": "Zuweisungen speichern", + "saved": "Zuweisungen aktualisiert", + "savedDiff": "Zuweisungen aktualisiert: {{parts}}", + "addedN": "{{count}} hinzugefügt", + "removedN": "{{count}} entfernt", + "error": "Zuweisungen konnten nicht aktualisiert werden" } }, "clients": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 075a555d..232151c6 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2938,7 +2938,8 @@ "success": "Password reset email sent", "error": "Could not send password reset", "inactive": "Reactivate the customer before sending a reset." - } + }, + "manageEvents": "Manage galleries" }, "reactivate": { "button": "Reactivate", @@ -2953,6 +2954,23 @@ "confirmInFlight": "Erasing…", "success": "Customer erased", "error": "Could not erase customer" + }, + "assignedEvents": { + "title": "Manage assigned galleries", + "subtitle": "Pick every gallery this customer should be able to access from their dashboard. Removing a gallery here revokes access immediately on the customer's next request.", + "currentLabel": "Assigned galleries", + "empty": "No galleries assigned yet. Search below to add one.", + "searchLabel": "Add a gallery", + "searchPlaceholder": "Search by event name", + "searchHint": "Start typing to find galleries.", + "noResults": "No matching galleries.", + "removeAria": "Remove {{name}}", + "save": "Save assignments", + "saved": "Assignments updated", + "savedDiff": "Assignments updated: {{parts}}", + "addedN": "{{count}} added", + "removedN": "{{count}} removed", + "error": "Could not update assignments" } }, "clients": { diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index ad71d41a..e9578d9d 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -2892,7 +2892,8 @@ "success": "E-mail de réinitialisation envoyé", "error": "Impossible d'envoyer la réinitialisation", "inactive": "Réactivez le client avant d'envoyer une réinitialisation." - } + }, + "manageEvents": "Gérer les galeries" }, "reactivate": { "button": "Réactiver", @@ -2907,6 +2908,23 @@ "confirmInFlight": "Effacement…", "success": "Client effacé", "error": "Impossible d'effacer le client" + }, + "assignedEvents": { + "title": "Gérer les galeries assignées", + "subtitle": "Sélectionnez toutes les galeries auxquelles ce client doit pouvoir accéder depuis son tableau de bord. Retirer une galerie ici révoque l'accès immédiatement à la prochaine requête du client.", + "currentLabel": "Galeries assignées", + "empty": "Aucune galerie assignée pour le moment. Recherchez ci-dessous pour en ajouter une.", + "searchLabel": "Ajouter une galerie", + "searchPlaceholder": "Rechercher par nom d'événement", + "searchHint": "Commencez à taper pour trouver des galeries.", + "noResults": "Aucune galerie correspondante.", + "removeAria": "Retirer {{name}}", + "save": "Enregistrer les assignations", + "saved": "Assignations mises à jour", + "savedDiff": "Assignations mises à jour : {{parts}}", + "addedN": "{{count}} ajoutée(s)", + "removedN": "{{count}} retirée(s)", + "error": "Impossible de mettre à jour les assignations" } }, "clients": { diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 9cf475c7..b72d6864 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -2938,7 +2938,8 @@ "success": "Wachtwoord-resetmail verzonden", "error": "Wachtwoordreset kon niet worden verzonden", "inactive": "Reactiveer de klant voordat je een reset verstuurt." - } + }, + "manageEvents": "Galerijen beheren" }, "reactivate": { "button": "Heractiveren", @@ -2953,6 +2954,23 @@ "confirmInFlight": "Bezig met wissen…", "success": "Klant gewist", "error": "Klant kon niet worden gewist" + }, + "assignedEvents": { + "title": "Toegewezen galerijen beheren", + "subtitle": "Selecteer elke galerij waartoe deze klant toegang moet hebben vanaf zijn dashboard. Een hier verwijderde galerij wordt bij het volgende verzoek van de klant direct geblokkeerd.", + "currentLabel": "Toegewezen galerijen", + "empty": "Nog geen galerijen toegewezen. Zoek hieronder om er een toe te voegen.", + "searchLabel": "Galerij toevoegen", + "searchPlaceholder": "Zoeken op evenementnaam", + "searchHint": "Begin met typen om galerijen te vinden.", + "noResults": "Geen overeenkomende galerijen.", + "removeAria": "{{name}} verwijderen", + "save": "Toewijzingen opslaan", + "saved": "Toewijzingen bijgewerkt", + "savedDiff": "Toewijzingen bijgewerkt: {{parts}}", + "addedN": "{{count}} toegevoegd", + "removedN": "{{count}} verwijderd", + "error": "Toewijzingen konden niet worden bijgewerkt" } }, "clients": { diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 1a1c6397..d9c36027 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -2971,7 +2971,8 @@ "success": "E-mail de redefinição enviado", "error": "Não foi possível enviar a redefinição", "inactive": "Reative o cliente antes de enviar uma redefinição." - } + }, + "manageEvents": "Gerenciar galerias" }, "reactivate": { "button": "Reativar", @@ -2986,6 +2987,23 @@ "confirmInFlight": "Apagando…", "success": "Cliente apagado", "error": "Não foi possível apagar o cliente" + }, + "assignedEvents": { + "title": "Gerenciar galerias atribuídas", + "subtitle": "Selecione todas as galerias às quais este cliente deve poder acessar a partir do seu painel. Remover uma galeria aqui revoga o acesso imediatamente na próxima solicitação do cliente.", + "currentLabel": "Galerias atribuídas", + "empty": "Nenhuma galeria atribuída ainda. Pesquise abaixo para adicionar uma.", + "searchLabel": "Adicionar uma galeria", + "searchPlaceholder": "Pesquisar por nome do evento", + "searchHint": "Comece a digitar para encontrar galerias.", + "noResults": "Nenhuma galeria correspondente.", + "removeAria": "Remover {{name}}", + "save": "Salvar atribuições", + "saved": "Atribuições atualizadas", + "savedDiff": "Atribuições atualizadas: {{parts}}", + "addedN": "{{count}} adicionada(s)", + "removedN": "{{count}} removida(s)", + "error": "Não foi possível atualizar as atribuições" } }, "clients": { diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index c75e0ea4..61e73094 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -3004,7 +3004,8 @@ "success": "Письмо для сброса пароля отправлено", "error": "Не удалось отправить сброс пароля", "inactive": "Активируйте клиента перед отправкой сброса." - } + }, + "manageEvents": "Управление галереями" }, "reactivate": { "button": "Активировать", @@ -3019,6 +3020,23 @@ "confirmInFlight": "Стираем…", "success": "Данные клиента стёрты", "error": "Не удалось стереть данные клиента" + }, + "assignedEvents": { + "title": "Управление назначенными галереями", + "subtitle": "Выберите все галереи, к которым этот клиент должен иметь доступ из своей панели. Удалённая здесь галерея станет недоступной для клиента при его следующем запросе.", + "currentLabel": "Назначенные галереи", + "empty": "Галерей пока не назначено. Воспользуйтесь поиском ниже, чтобы добавить.", + "searchLabel": "Добавить галерею", + "searchPlaceholder": "Поиск по названию события", + "searchHint": "Начните вводить, чтобы найти галереи.", + "noResults": "Подходящих галерей нет.", + "removeAria": "Удалить {{name}}", + "save": "Сохранить назначения", + "saved": "Назначения обновлены", + "savedDiff": "Назначения обновлены: {{parts}}", + "addedN": "добавлено: {{count}}", + "removedN": "удалено: {{count}}", + "error": "Не удалось обновить назначения" } }, "clients": { diff --git a/frontend/src/pages/admin/CustomerDetailPage.tsx b/frontend/src/pages/admin/CustomerDetailPage.tsx index cb331db7..27e9ec57 100644 --- a/frontend/src/pages/admin/CustomerDetailPage.tsx +++ b/frontend/src/pages/admin/CustomerDetailPage.tsx @@ -15,11 +15,12 @@ import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import { ArrowLeft, Mail, MapPin, Phone, Building2, Save, Trash2, AlertTriangle, - CheckCircle2, X, FileText, Calendar, KeyRound, ToggleLeft, + CheckCircle2, X, FileText, Calendar, KeyRound, ToggleLeft, Settings as SettingsIcon, } from 'lucide-react'; import { format } from 'date-fns'; import { Button, Card, Input, Loading } from '../../components/common'; +import { AssignedEventsDialog } from '../../components/admin/AssignedEventsDialog'; import { customerAdminService, type CustomerAccountDetail, @@ -53,6 +54,11 @@ export const CustomerDetailPage: React.FC = () => { const [form, setForm] = useState>>({}); const [confirmDeactivate, setConfirmDeactivate] = useState(false); const [confirmErase, setConfirmErase] = useState(false); + // Drives the "Manage galleries" modal launched from the Assigned + // events card. We hold open-state here (rather than inside the + // dialog) so the parent decides when to mount/unmount and the + // dialog can hard-reset its internal state per open. + const [assignedDialogOpen, setAssignedDialogOpen] = useState(false); // Hydrate the form from the fetched record once. We deliberately do NOT // re-sync on every refetch so an admin's in-progress edits aren't blown @@ -311,12 +317,28 @@ export const CustomerDetailPage: React.FC = () => { {/* Assigned events */} -

- {t('customers.detail.eventsSection', 'Assigned events')} -

+
+

+ {t('customers.detail.eventsSection', 'Assigned events')} +

+ {/* Manage galleries: opens the multi-select dialog that + replaces the customer's full assignment list. Disabled + for deactivated customers because their login is off + anyway — re-enable first if the admin wants to plan + their access. */} + +
{customer.events.length === 0 ? (

- {t('customers.detail.noEvents', 'Not assigned to any events yet. Add this customer to an event from the event form.')} + {t('customers.detail.noEvents', 'Not assigned to any events yet. Use "Manage galleries" to add some.')}

) : (
    @@ -335,6 +357,20 @@ export const CustomerDetailPage: React.FC = () => { )} + ({ + id: ev.id, + eventName: ev.eventName, + eventDate: ev.eventDate || null, + }))} + onClose={() => setAssignedDialogOpen(false)} + onSaved={() => { + // Parent refetch is handled by the dialog's invalidateQueries. + }} + /> + {/* Address + billing */}

    diff --git a/frontend/src/services/customerAdmin.service.ts b/frontend/src/services/customerAdmin.service.ts index eeca7fe9..3ac75ed0 100644 --- a/frontend/src/services/customerAdmin.service.ts +++ b/frontend/src/services/customerAdmin.service.ts @@ -159,6 +159,26 @@ export const customerAdminService = { return ((response.data as any).data ?? response.data) as { email: string; expiresAt: string }; }, + /** + * Replace the full set of events this customer is assigned to. + * Empty array clears every assignment. The backend rejects any + * archived event ids it sees, so the response { added, removed } + * counts may be lower than the input length if the admin selected + * something stale — surface the numbers in a toast. + * + * Access revocation: gallery middleware re-checks the assignment + * row on every customer-minted JWT, so removing an event here + * immediately blocks the customer's next request to that gallery. + * No separate token-blacklist call needed. + */ + async setEvents(id: number, eventIds: number[]): Promise<{ added: number; removed: number }> { + const response = await api.put<{ data: { added: number; removed: number } } | { added: number; removed: number }>( + `/admin/customers/${id}/events`, + { event_ids: eventIds }, + ); + return ((response.data as any).data ?? response.data) as { added: number; removed: number }; + }, + /** * Invite a customer. `prefill` is an optional set of profile fields the * admin can pre-populate on the invitation row — the customer sees them