diff --git a/app/[locale]/admin/page.tsx b/app/[locale]/admin/page.tsx index 5ed8adc..53ddd7b 100644 --- a/app/[locale]/admin/page.tsx +++ b/app/[locale]/admin/page.tsx @@ -4,6 +4,7 @@ import { notFound } from 'next/navigation'; import type { ReactNode } from 'react'; import { ArrowLeft } from 'lucide-react'; +import { AdminMatchmakerPanel } from '@/components/admin/admin-matchmaker-panel'; import { Link } from '@/i18n/navigation'; import type { AppLocale } from '@/i18n/routing'; import { canAccessAdminConsole } from '@/lib/admin/access'; @@ -22,7 +23,6 @@ type AdminPolicyPageProps = { saved?: string; matchmaker?: string; matchGroup?: string; - userSearch?: string; }; }; @@ -45,26 +45,9 @@ export default async function AdminPolicyPage({ const [policy, matchmakerData] = await Promise.all([ getAppPolicySettingsForAdmin(), - getAdminMatchmakerData(searchParams.userSearch, searchParams.matchGroup), + getAdminMatchmakerData(undefined, searchParams.matchGroup), ]); const copy = getCopy(locale); - const userById = new Map( - matchmakerData.users.map((matchmakerUser) => [ - matchmakerUser.id, - matchmakerUser, - ]), - ); - const selectedGroup = - matchmakerData.groups.find( - (group) => group.id === searchParams.matchGroup, - ) ?? null; - const selectedGroupMemberIds = new Set( - selectedGroup?.members.map((member) => member.userId) ?? [], - ); - const selectedLeaderId = - selectedGroup?.members.find((member) => member.isFounder)?.userId ?? - selectedGroup?.createdBy ?? - ''; return (
@@ -102,186 +85,13 @@ export default async function AdminPolicyPage({ ) : null} -
-
-
-
-

- {copy.matchmakerEyebrow} -

-

- {copy.matchmakerTitle} -

-

- {copy.matchmakerDescription} -

-
- {selectedGroup ? ( - - {copy.newGroup} - - ) : null} -
- -
- - - -
- - - -
- -
-
-

- {copy.members} -

-

- {copy.membersHint} -

-
-
- {matchmakerData.users.length} {copy.usersShown} -
-
- -
- {matchmakerData.users.map((matchmakerUser) => { - const isSelected = selectedGroupMemberIds.has(matchmakerUser.id); - const isLeader = selectedLeaderId === matchmakerUser.id; - - return ( - - ); - })} -
- - -
-
- - -
+
@@ -361,7 +171,11 @@ export default async function AdminPolicyPage({ /> {copy.fullAccess}} + limit={ +
+ {copy.fullAccess} +
+ } condition={
part[0]?.toUpperCase()) - .join(''); - - if (user.avatarUrl) { - return ( - // eslint-disable-next-line @next/next/no-img-element - - ); - } - - return ( - - {initials || 'U'} - - ); -} - function getMatchmakerFeedback( copy: ReturnType, value: string, @@ -666,8 +449,9 @@ function getCopy(locale: AppLocale) { unnamedUser: 'Utilisateur sans nom', createGroup: 'Créer le groupe', updateGroup: 'Mettre à jour le groupe', + saving: 'Enregistrement...', searchUsers: 'Rechercher', - searchPlaceholder: 'Nom ou e-mail', + searchPlaceholder: 'Rechercher un nom ou un e-mail', currentGroups: 'Groupes actuels', notSet: 'non défini', matchmakerSaved: 'Composition du groupe enregistrée.', @@ -676,6 +460,12 @@ function getCopy(locale: AppLocale) { matchmakerInvalidUser: 'Un utilisateur sélectionné est introuvable.', matchmakerActiveSession: 'Impossible de modifier ce groupe pendant une session active.', + selectedMembers: 'Membres sélectionnés', + clearSearch: 'Effacer la recherche', + noUserMatch: 'Aucun utilisateur ne correspond à cette recherche.', + noGroups: 'Aucun groupe ne correspond à cette recherche.', + groupSummary: + 'La sauvegarde met à jour le groupe et ses membres sans passer par le flux invitation.', }; } @@ -731,8 +521,9 @@ function getCopy(locale: AppLocale) { unnamedUser: 'Unnamed user', createGroup: 'Create group', updateGroup: 'Update group', + saving: 'Saving...', searchUsers: 'Search', - searchPlaceholder: 'Name or email', + searchPlaceholder: 'Search by name or email', currentGroups: 'Current groups', notSet: 'not set', matchmakerSaved: 'Group composition saved.', @@ -741,5 +532,11 @@ function getCopy(locale: AppLocale) { matchmakerInvalidUser: 'One selected user could not be found.', matchmakerActiveSession: 'This group cannot be changed during an active session.', + selectedMembers: 'Selected members', + clearSearch: 'Clear search', + noUserMatch: 'No user matches this search.', + noGroups: 'No group matches this search.', + groupSummary: + 'Saving updates the group and its members without using the invitation flow.', }; } diff --git a/components/admin/admin-matchmaker-panel.tsx b/components/admin/admin-matchmaker-panel.tsx new file mode 100644 index 0000000..0fd53c2 --- /dev/null +++ b/components/admin/admin-matchmaker-panel.tsx @@ -0,0 +1,620 @@ +'use client'; + +import { useCallback, useDeferredValue, useMemo, useState } from 'react'; +import { useFormStatus } from 'react-dom'; +import { + Check, + Crown, + Plus, + Search, + UserPlus, + Users, + X, +} from 'lucide-react'; + +import type { + AdminMatchmakerData, + AdminMatchmakerGroup, + AdminMatchmakerUser, +} from '@/lib/admin/matchmaker'; + +export type AdminMatchmakerCopy = { + matchmakerEyebrow: string; + matchmakerTitle: string; + matchmakerDescription: string; + newGroup: string; + groupName: string; + capacity: string; + difficulty: string; + difficultyLow: string; + difficultyMedium: string; + difficultyHigh: string; + members: string; + membersHint: string; + usersShown: string; + leader: string; + unnamedUser: string; + createGroup: string; + updateGroup: string; + saving: string; + searchUsers: string; + searchPlaceholder: string; + currentGroups: string; + notSet: string; + selectedMembers: string; + clearSearch: string; + noUserMatch: string; + noGroups: string; + groupSummary: string; +}; + +type AdminMatchmakerPanelProps = { + locale: 'en' | 'fr'; + data: AdminMatchmakerData; + initialGroupId?: string; + copy: AdminMatchmakerCopy; + action: (formData: FormData) => void | Promise; +}; + +const MAX_VISIBLE_USERS = 80; +const MAX_VISIBLE_GROUPS = 120; + +function normalize(value: string) { + return value.trim().toLowerCase(); +} + +function getGroupLeaderId(group: AdminMatchmakerGroup | null) { + return ( + group?.members.find((member) => member.isFounder)?.userId ?? + group?.createdBy ?? + '' + ); +} + +function getUserLabel(user: AdminMatchmakerUser, unnamedUser: string) { + return user.displayName?.trim() || user.email || unnamedUser; +} + +function UserAvatar({ + user, + unnamedUser, + className = 'h-9 w-9', +}: { + user: AdminMatchmakerUser; + unnamedUser: string; + className?: string; +}) { + const label = getUserLabel(user, unnamedUser); + const initials = label + .split(/\s|@/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join(''); + + if (user.avatarUrl) { + return ( + // eslint-disable-next-line @next/next/no-img-element + + ); + } + + return ( + + {initials || 'U'} + + ); +} + +function SubmitButton({ + disabled, + label, + savingLabel, +}: { + disabled: boolean; + label: string; + savingLabel: string; +}) { + const { pending } = useFormStatus(); + + return ( + + ); +} + +export function AdminMatchmakerPanel({ + locale, + data, + initialGroupId, + copy, + action, +}: AdminMatchmakerPanelProps) { + const initialGroup = + data.groups.find((group) => group.id === initialGroupId) ?? null; + const [selectedGroupId, setSelectedGroupId] = useState(initialGroup?.id ?? ''); + const [groupName, setGroupName] = useState(initialGroup?.name ?? ''); + const [maxMembers, setMaxMembers] = useState(initialGroup?.maxMembers ?? 5); + const [difficultyLevel, setDifficultyLevel] = useState< + 'low' | 'medium' | 'high' + >(initialGroup?.difficultyLevel ?? 'medium'); + const [memberIds, setMemberIds] = useState( + () => new Set(initialGroup?.members.map((member) => member.userId) ?? []), + ); + const [leaderUserId, setLeaderUserId] = useState(getGroupLeaderId(initialGroup)); + const [query, setQuery] = useState(''); + const [groupQuery, setGroupQuery] = useState(''); + const deferredQuery = useDeferredValue(query); + const deferredGroupQuery = useDeferredValue(groupQuery); + + const userById = useMemo( + () => new Map(data.users.map((user) => [user.id, user])), + [data.users], + ); + + const sortedUsers = useMemo( + () => + [...data.users].sort((left, right) => + getUserLabel(left, copy.unnamedUser).localeCompare( + getUserLabel(right, copy.unnamedUser), + ), + ), + [copy.unnamedUser, data.users], + ); + + const selectedGroup = useMemo( + () => data.groups.find((group) => group.id === selectedGroupId) ?? null, + [data.groups, selectedGroupId], + ); + + const selectedUsers = useMemo( + () => + [...memberIds] + .map((userId) => userById.get(userId)) + .filter((user): user is AdminMatchmakerUser => Boolean(user)), + [memberIds, userById], + ); + + const filteredUsers = useMemo(() => { + const normalizedQuery = normalize(deferredQuery); + const selectedIds = new Set(memberIds); + const selectedMatches: AdminMatchmakerUser[] = []; + const unselectedMatches: AdminMatchmakerUser[] = []; + + for (const user of sortedUsers) { + if (!normalizedQuery) { + if (selectedIds.has(user.id)) { + selectedMatches.push(user); + } else { + unselectedMatches.push(user); + } + continue; + } + + const matches = + user.email.toLowerCase().includes(normalizedQuery) || + (user.displayName ?? '').toLowerCase().includes(normalizedQuery); + + if (selectedIds.has(user.id)) { + selectedMatches.push(user); + } else if (matches) { + unselectedMatches.push(user); + } + } + + return [...selectedMatches, ...unselectedMatches]; + }, [deferredQuery, memberIds, sortedUsers]); + + const visibleUsers = useMemo( + () => filteredUsers.slice(0, MAX_VISIBLE_USERS), + [filteredUsers], + ); + + const filteredGroups = useMemo(() => { + const normalizedQuery = normalize(deferredGroupQuery); + if (!normalizedQuery) { + return data.groups.slice(0, MAX_VISIBLE_GROUPS); + } + + return data.groups + .filter((group) => group.name.toLowerCase().includes(normalizedQuery)) + .slice(0, MAX_VISIBLE_GROUPS); + }, [data.groups, deferredGroupQuery]); + + const startNewGroup = useCallback(() => { + setSelectedGroupId(''); + setGroupName(''); + setMaxMembers(5); + setDifficultyLevel('medium'); + setMemberIds(new Set()); + setLeaderUserId(''); + setQuery(''); + }, []); + + const selectGroup = useCallback((group: AdminMatchmakerGroup) => { + setSelectedGroupId(group.id); + setGroupName(group.name); + setMaxMembers(group.maxMembers); + setDifficultyLevel(group.difficultyLevel); + setMemberIds(new Set(group.members.map((member) => member.userId))); + setLeaderUserId(getGroupLeaderId(group)); + setQuery(''); + }, []); + + const toggleMember = useCallback((userId: string) => { + setMemberIds((current) => { + const next = new Set(current); + if (next.has(userId)) { + next.delete(userId); + if (leaderUserId === userId) { + setLeaderUserId(''); + } + } else { + next.add(userId); + if (!leaderUserId) { + setLeaderUserId(userId); + } + } + return next; + }); + }, [leaderUserId]); + + const assignLeader = useCallback((userId: string) => { + setLeaderUserId(userId); + setMemberIds((current) => { + const next = new Set(current); + next.add(userId); + return next; + }); + }, []); + + const leader = leaderUserId ? userById.get(leaderUserId) : null; + const capacityWarning = memberIds.size > maxMembers; + + return ( +
+
+
+
+

+ {copy.matchmakerEyebrow} +

+

+ {copy.matchmakerTitle} +

+

+ {copy.matchmakerDescription} +

+
+ +
+ + + + + {[...memberIds].map((memberId) => ( + + ))} + {leaderUserId ? ( + + ) : null} + +
+ + + +
+ +
+
+
+
+
+
+ + {selectedUsers.length > 0 ? ( +
+

+ {copy.selectedMembers} +

+
+ {selectedUsers.map((user) => ( + + ))} +
+
+ ) : null} + +
+
+ +
+ + {filteredUsers.length} {copy.usersShown} + + {filteredUsers.length > visibleUsers.length ? ( + + {visibleUsers.length}/{filteredUsers.length} + + ) : null} +
+ +
+ {visibleUsers.length > 0 ? ( + visibleUsers.map((user) => { + const isSelected = memberIds.has(user.id); + const isLeader = leaderUserId === user.id; + + return ( +
+ + + {user.questionsAnswered} Q + + +
+ ); + }) + ) : ( +
+ {copy.noUserMatch} +
+ )} +
+ + + +
+ + +
+ ); +}