feat(customers): "Manage galleries" dialog on customer detail page

This commit is contained in:
Luca
2026-05-11 23:40:07 +02:00
parent 55a5846f6f
commit 6d1af7a011
9 changed files with 482 additions and 11 deletions
@@ -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<Props> = ({ customerId, isOpen, initial, onClose, onSaved }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<SelectedEvent[]>(initial);
const [query, setQuery] = useState('');
const [results, setResults] = useState<AdminEvent[]>([]);
const [isSearching, setIsSearching] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
role="dialog"
aria-modal="true"
onClick={(e) => {
// 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();
}}
>
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('customers.assignedEvents.title', 'Manage assigned galleries')}
</h2>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
{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.',
)}
</p>
</div>
<button
type="button"
onClick={onClose}
disabled={saveMutation.isPending}
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 flex-shrink-0"
aria-label={t('common.close', 'Close')}
>
<X className="w-5 h-5 text-neutral-500" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Selected chips */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
{t('customers.assignedEvents.currentLabel', 'Assigned galleries')}
<span className="ml-1.5 normal-case text-neutral-400">({selected.length})</span>
</label>
{selected.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
{t('customers.assignedEvents.empty', 'No galleries assigned yet. Search below to add one.')}
</p>
) : (
<ul className="flex flex-wrap gap-2">
{selected.map((s) => (
<li
key={s.id}
className="inline-flex items-center gap-2 pl-2 pr-1 py-1 rounded-full text-sm bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700"
>
<CalendarIcon className="w-3.5 h-3.5 text-neutral-500" />
<span className="truncate max-w-[220px]">{s.eventName}</span>
<button
type="button"
onClick={() => remove(s.id)}
disabled={saveMutation.isPending}
aria-label={t('customers.assignedEvents.removeAria', 'Remove {{name}}', { name: s.eventName })}
className="p-0.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-700"
>
<X className="w-3.5 h-3.5 text-neutral-500" />
</button>
</li>
))}
</ul>
)}
</div>
{/* Search */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
{t('customers.assignedEvents.searchLabel', 'Add a gallery')}
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
<input
ref={searchInputRef}
type="text"
value={query}
onChange={(e) => 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"
/>
</div>
{/* Results dropdown — inline (not a popover) since this is
already inside a modal, no nested-popover headaches. */}
<div className="mt-2 border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden bg-white dark:bg-neutral-800">
{!query.trim() ? (
<p className="px-3 py-3 text-sm text-neutral-500 dark:text-neutral-400">
{t('customers.assignedEvents.searchHint', 'Start typing to find galleries.')}
</p>
) : isSearching ? (
<p className="px-3 py-3 text-sm text-neutral-500 dark:text-neutral-400">
{t('common.searching', 'Searching…')}
</p>
) : results.length === 0 ? (
<p className="px-3 py-3 text-sm text-neutral-500 dark:text-neutral-400">
{t('customers.assignedEvents.noResults', 'No matching galleries.')}
</p>
) : (
<ul role="listbox">
{results.map((ev) => (
<li key={ev.id}>
<button
type="button"
onClick={() => add(ev)}
disabled={saveMutation.isPending}
className="w-full text-left px-3 py-2 flex items-center justify-between gap-3 hover:bg-neutral-50 dark:hover:bg-neutral-700"
>
<span className="flex items-center gap-2 min-w-0">
<CalendarIcon className="w-4 h-4 flex-shrink-0 text-neutral-400" />
<span className="truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
{ev.event_name}
</span>
</span>
{ev.event_date && (
<span className="text-xs text-neutral-500 dark:text-neutral-400 flex-shrink-0">
{ev.event_date}
</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-neutral-200 dark:border-neutral-700 flex items-center justify-end gap-2">
<Button
variant="outline"
onClick={onClose}
disabled={saveMutation.isPending}
>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
onClick={() => saveMutation.mutate()}
disabled={!isDirty || saveMutation.isPending}
isLoading={saveMutation.isPending}
>
{t('customers.assignedEvents.save', 'Save assignments')}
</Button>
</div>
</div>
</div>
);
};
+19 -1
View File
@@ -2938,7 +2938,8 @@
"success": "Passwort-Reset-E-Mail gesendet", "success": "Passwort-Reset-E-Mail gesendet",
"error": "Passwort-Reset konnte nicht gesendet werden", "error": "Passwort-Reset konnte nicht gesendet werden",
"inactive": "Aktiviere den Kunden, bevor du einen Reset sendest." "inactive": "Aktiviere den Kunden, bevor du einen Reset sendest."
} },
"manageEvents": "Galerien verwalten"
}, },
"reactivate": { "reactivate": {
"button": "Reaktivieren", "button": "Reaktivieren",
@@ -2953,6 +2954,23 @@
"confirmInFlight": "Lösche…", "confirmInFlight": "Lösche…",
"success": "Kunde gelöscht", "success": "Kunde gelöscht",
"error": "Kunde konnte nicht gelöscht werden" "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": { "clients": {
+19 -1
View File
@@ -2938,7 +2938,8 @@
"success": "Password reset email sent", "success": "Password reset email sent",
"error": "Could not send password reset", "error": "Could not send password reset",
"inactive": "Reactivate the customer before sending a reset." "inactive": "Reactivate the customer before sending a reset."
} },
"manageEvents": "Manage galleries"
}, },
"reactivate": { "reactivate": {
"button": "Reactivate", "button": "Reactivate",
@@ -2953,6 +2954,23 @@
"confirmInFlight": "Erasing…", "confirmInFlight": "Erasing…",
"success": "Customer erased", "success": "Customer erased",
"error": "Could not erase customer" "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": { "clients": {
+19 -1
View File
@@ -2892,7 +2892,8 @@
"success": "E-mail de réinitialisation envoyé", "success": "E-mail de réinitialisation envoyé",
"error": "Impossible d'envoyer la réinitialisation", "error": "Impossible d'envoyer la réinitialisation",
"inactive": "Réactivez le client avant d'envoyer une réinitialisation." "inactive": "Réactivez le client avant d'envoyer une réinitialisation."
} },
"manageEvents": "Gérer les galeries"
}, },
"reactivate": { "reactivate": {
"button": "Réactiver", "button": "Réactiver",
@@ -2907,6 +2908,23 @@
"confirmInFlight": "Effacement…", "confirmInFlight": "Effacement…",
"success": "Client effacé", "success": "Client effacé",
"error": "Impossible d'effacer le client" "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": { "clients": {
+19 -1
View File
@@ -2938,7 +2938,8 @@
"success": "Wachtwoord-resetmail verzonden", "success": "Wachtwoord-resetmail verzonden",
"error": "Wachtwoordreset kon niet worden verzonden", "error": "Wachtwoordreset kon niet worden verzonden",
"inactive": "Reactiveer de klant voordat je een reset verstuurt." "inactive": "Reactiveer de klant voordat je een reset verstuurt."
} },
"manageEvents": "Galerijen beheren"
}, },
"reactivate": { "reactivate": {
"button": "Heractiveren", "button": "Heractiveren",
@@ -2953,6 +2954,23 @@
"confirmInFlight": "Bezig met wissen…", "confirmInFlight": "Bezig met wissen…",
"success": "Klant gewist", "success": "Klant gewist",
"error": "Klant kon niet worden 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": { "clients": {
+19 -1
View File
@@ -2971,7 +2971,8 @@
"success": "E-mail de redefinição enviado", "success": "E-mail de redefinição enviado",
"error": "Não foi possível enviar a redefinição", "error": "Não foi possível enviar a redefinição",
"inactive": "Reative o cliente antes de enviar uma redefinição." "inactive": "Reative o cliente antes de enviar uma redefinição."
} },
"manageEvents": "Gerenciar galerias"
}, },
"reactivate": { "reactivate": {
"button": "Reativar", "button": "Reativar",
@@ -2986,6 +2987,23 @@
"confirmInFlight": "Apagando…", "confirmInFlight": "Apagando…",
"success": "Cliente apagado", "success": "Cliente apagado",
"error": "Não foi possível apagar o cliente" "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": { "clients": {
+19 -1
View File
@@ -3004,7 +3004,8 @@
"success": "Письмо для сброса пароля отправлено", "success": "Письмо для сброса пароля отправлено",
"error": "Не удалось отправить сброс пароля", "error": "Не удалось отправить сброс пароля",
"inactive": "Активируйте клиента перед отправкой сброса." "inactive": "Активируйте клиента перед отправкой сброса."
} },
"manageEvents": "Управление галереями"
}, },
"reactivate": { "reactivate": {
"button": "Активировать", "button": "Активировать",
@@ -3019,6 +3020,23 @@
"confirmInFlight": "Стираем…", "confirmInFlight": "Стираем…",
"success": "Данные клиента стёрты", "success": "Данные клиента стёрты",
"error": "Не удалось стереть данные клиента" "error": "Не удалось стереть данные клиента"
},
"assignedEvents": {
"title": "Управление назначенными галереями",
"subtitle": "Выберите все галереи, к которым этот клиент должен иметь доступ из своей панели. Удалённая здесь галерея станет недоступной для клиента при его следующем запросе.",
"currentLabel": "Назначенные галереи",
"empty": "Галерей пока не назначено. Воспользуйтесь поиском ниже, чтобы добавить.",
"searchLabel": "Добавить галерею",
"searchPlaceholder": "Поиск по названию события",
"searchHint": "Начните вводить, чтобы найти галереи.",
"noResults": "Подходящих галерей нет.",
"removeAria": "Удалить {{name}}",
"save": "Сохранить назначения",
"saved": "Назначения обновлены",
"savedDiff": "Назначения обновлены: {{parts}}",
"addedN": "добавлено: {{count}}",
"removedN": "удалено: {{count}}",
"error": "Не удалось обновить назначения"
} }
}, },
"clients": { "clients": {
@@ -15,11 +15,12 @@ import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { import {
ArrowLeft, Mail, MapPin, Phone, Building2, Save, Trash2, AlertTriangle, 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'; } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Button, Card, Input, Loading } from '../../components/common'; import { Button, Card, Input, Loading } from '../../components/common';
import { AssignedEventsDialog } from '../../components/admin/AssignedEventsDialog';
import { import {
customerAdminService, customerAdminService,
type CustomerAccountDetail, type CustomerAccountDetail,
@@ -53,6 +54,11 @@ export const CustomerDetailPage: React.FC = () => {
const [form, setForm] = useState<Partial<Pick<CustomerAccountDetail, EditableFields>>>({}); const [form, setForm] = useState<Partial<Pick<CustomerAccountDetail, EditableFields>>>({});
const [confirmDeactivate, setConfirmDeactivate] = useState(false); const [confirmDeactivate, setConfirmDeactivate] = useState(false);
const [confirmErase, setConfirmErase] = 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 // 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 // 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 */} {/* Assigned events */}
<Card padding="lg"> <Card padding="lg">
<h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2"> <div className="flex items-center justify-between gap-4 mb-4 flex-wrap">
<h2 className="text-lg font-semibold text-theme flex items-center gap-2">
<Calendar className="w-5 h-5" /> {t('customers.detail.eventsSection', 'Assigned events')} <Calendar className="w-5 h-5" /> {t('customers.detail.eventsSection', 'Assigned events')}
</h2> </h2>
{/* 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. */}
<Button
variant="outline"
size="sm"
leftIcon={<SettingsIcon className="w-4 h-4" />}
onClick={() => setAssignedDialogOpen(true)}
disabled={!customer.isActive}
>
{t('customers.detail.manageEvents', 'Manage galleries')}
</Button>
</div>
{customer.events.length === 0 ? ( {customer.events.length === 0 ? (
<p className="text-sm text-muted-theme"> <p className="text-sm text-muted-theme">
{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.')}
</p> </p>
) : ( ) : (
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}> <ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
@@ -335,6 +357,20 @@ export const CustomerDetailPage: React.FC = () => {
)} )}
</Card> </Card>
<AssignedEventsDialog
customerId={customer.id}
isOpen={assignedDialogOpen}
initial={customer.events.map((ev) => ({
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 */} {/* Address + billing */}
<Card padding="lg"> <Card padding="lg">
<h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2"> <h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2">
@@ -159,6 +159,26 @@ export const customerAdminService = {
return ((response.data as any).data ?? response.data) as { email: string; expiresAt: string }; 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 * Invite a customer. `prefill` is an optional set of profile fields the
* admin can pre-populate on the invitation row — the customer sees them * admin can pre-populate on the invitation row — the customer sees them