feat(customers): "Manage galleries" dialog on customer detail page
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user