diff --git a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/error.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/error.tsx new file mode 100644 index 000000000..3a1cbd3bc --- /dev/null +++ b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/error.tsx @@ -0,0 +1,7 @@ +'use client'; + +import PageError, { ErrorProps } from '@/components/screens/PageError'; + +export default function Error(props: ErrorProps) { + return ; +} diff --git a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/loading.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/loading.tsx new file mode 100644 index 000000000..f3756896e --- /dev/null +++ b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton } from '@op/ui/Skeleton'; + +export default function Loading() { + return ( +
+
+ +
+
+
+
+ + +
+ +
+
+
+ ); +} diff --git a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx new file mode 100644 index 000000000..847ae7948 --- /dev/null +++ b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/members/page.tsx @@ -0,0 +1,53 @@ +import { createClient } from '@op/api/serverClient'; +import { notFound } from 'next/navigation'; + +import { TranslatedText } from '@/components/TranslatedText'; +import { ProfileUsersAccessHeader } from '@/components/decisions/ProfileUsersAccessHeader'; +import { ProfileUsersAccessPage } from '@/components/decisions/ProfileUsersAccessPage'; + +const ProfileMembersContent = async ({ slug }: { slug: string }) => { + const client = await createClient(); + + const decisionProfile = await client.decision.getDecisionBySlug({ + slug, + }); + + if (!decisionProfile || !decisionProfile.processInstance) { + notFound(); + } + + const profileId = decisionProfile.id; + const ownerSlug = decisionProfile.processInstance.owner?.slug; // will exist for all new processes + const decisionName = decisionProfile.processInstance.name; + + if (!ownerSlug) { + notFound(); + } + + return ( +
+ } + /> +
+ +
+
+ ); +}; + +const MembersPage = async ({ + params, +}: { + params: Promise<{ slug: string }>; +}) => { + const { slug } = await params; + + return ; +}; + +export default MembersPage; diff --git a/apps/app/src/components/ProfileAvatar/index.tsx b/apps/app/src/components/ProfileAvatar/index.tsx new file mode 100644 index 000000000..5063328bd --- /dev/null +++ b/apps/app/src/components/ProfileAvatar/index.tsx @@ -0,0 +1,62 @@ +import { getPublicUrl } from '@/utils'; +import { Avatar, AvatarSkeleton } from '@op/ui/Avatar'; +import { cn } from '@op/ui/utils'; +import Image from 'next/image'; + +import { Link } from '@/lib/i18n'; + +type ProfileAvatarProps = { + profile?: { + name?: string | null; + slug?: string | null; + avatarImage?: { name?: string | null } | null; + } | null; + withLink?: boolean; + className?: string; +}; + +export const ProfileAvatar = ({ + profile, + withLink = true, + className, +}: ProfileAvatarProps) => { + if (!profile) { + return null; + } + + const name = profile.name ?? ''; + const avatarImage = profile.avatarImage; + const slug = profile.slug; + + const avatar = ( + + {avatarImage?.name ? ( + {name + ) : null} + + ); + + return withLink && slug ? ( + + {avatar} + + ) : ( + avatar + ); +}; + +export const ProfileAvatarSkeleton = ({ + className, +}: { + className?: string; +}) => { + return ; +}; diff --git a/apps/app/src/components/decisions/ProfileUsersAccessHeader.tsx b/apps/app/src/components/decisions/ProfileUsersAccessHeader.tsx new file mode 100644 index 000000000..4c7ba21ac --- /dev/null +++ b/apps/app/src/components/decisions/ProfileUsersAccessHeader.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { Header1 } from '@op/ui/Header'; +import type { ReactNode } from 'react'; +import { LuArrowLeft } from 'react-icons/lu'; + +import { useTranslations } from '@/lib/i18n'; +import { Link } from '@/lib/i18n/routing'; + +import { LocaleChooser } from '../LocaleChooser'; +import { UserAvatarMenu } from '../SiteHeader'; + +export const ProfileUsersAccessHeader = ({ + backTo, + title, +}: { + backTo: { + label?: string; + href: string; + }; + title: ReactNode; +}) => { + const t = useTranslations(); + return ( +
+
+ + + + {t('Back')} {backTo.label ? `${t('to')} ${backTo.label}` : ''} + + +
+ +
+ + {title} + +
+ +
+ + +
+
+ ); +}; diff --git a/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx b/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx new file mode 100644 index 000000000..bc3f7f397 --- /dev/null +++ b/apps/app/src/components/decisions/ProfileUsersAccessPage.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { trpc } from '@op/api/client'; +import type { SortDir } from '@op/common'; +import { useCursorPagination, useDebounce } from '@op/hooks'; +import { Pagination } from '@op/ui/Pagination'; +import { SearchField } from '@op/ui/SearchField'; +import { useEffect, useState } from 'react'; +import type { SortDescriptor } from 'react-aria-components'; + +import { useTranslations } from '@/lib/i18n'; + +import { ProfileUsersAccessTable } from './ProfileUsersAccessTable'; + +// Sort columns supported by profile.listUsers endpoint +type SortColumn = 'name' | 'email' | 'role'; + +const ITEMS_PER_PAGE = 25; + +export const ProfileUsersAccessPage = ({ + profileId, +}: { + profileId: string; +}) => { + const t = useTranslations(); + const [searchQuery, setSearchQuery] = useState(''); + const [debouncedQuery] = useDebounce(searchQuery, 200); + + // Sorting state + const [sortDescriptor, setSortDescriptor] = useState({ + column: 'name', + direction: 'ascending', + }); + + // Cursor pagination + const { cursor, handleNext, handlePrevious, canGoPrevious, reset } = + useCursorPagination(ITEMS_PER_PAGE); + + // Reset pagination when search or sort changes + useEffect(() => { + reset(); + }, [debouncedQuery, sortDescriptor.column, sortDescriptor.direction, reset]); + + // Convert React Aria sort descriptor to API format + const orderBy = sortDescriptor.column as SortColumn; + const dir: SortDir = + sortDescriptor.direction === 'ascending' ? 'asc' : 'desc'; + + // Build query input - only include query if >= 2 chars + const queryInput = { + profileId, + cursor, + limit: ITEMS_PER_PAGE, + orderBy, + dir, + query: debouncedQuery.length >= 2 ? debouncedQuery : undefined, + }; + + // Use regular query - cache handles exact query matches, loading shown for uncached queries. We don't use a Suspense due to not wanting to suspend the entire table + const { data, isPending, isError, refetch } = + trpc.profile.listUsers.useQuery(queryInput); + + // Fetch roles in parallel to avoid waterfall loading + const { data: rolesData, isPending: rolesPending } = + trpc.organization.getRoles.useQuery(); + + const { items: profileUsers = [], next } = data ?? {}; + const { roles = [] } = rolesData ?? {}; + + const onNext = () => { + if (next) { + handleNext(next); + } + }; + + return ( +
+

+ {t('Members')} +

+ + + + void refetch()} + roles={roles} + /> + + +
+ ); +}; diff --git a/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx new file mode 100644 index 000000000..4a5614395 --- /dev/null +++ b/apps/app/src/components/decisions/ProfileUsersAccessTable.tsx @@ -0,0 +1,226 @@ +'use client'; + +import { ClientOnly } from '@/utils/ClientOnly'; +import { trpc } from '@op/api/client'; +import type { ProfileUser } from '@op/api/encoders'; +import { Button } from '@op/ui/Button'; +import { EmptyState } from '@op/ui/EmptyState'; +import { Select, SelectItem } from '@op/ui/Select'; +import { Skeleton } from '@op/ui/Skeleton'; +import { toast } from '@op/ui/Toast'; +import { + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, +} from '@op/ui/ui/table'; +import type { SortDescriptor } from 'react-aria-components'; +import { LuUsers } from 'react-icons/lu'; + +import { useTranslations } from '@/lib/i18n'; + +import { ProfileAvatar } from '@/components/ProfileAvatar'; + +// Exported component with loading and error states +export const ProfileUsersAccessTable = ({ + profileUsers, + profileId, + sortDescriptor, + onSortChange, + isLoading, + isError, + onRetry, + roles, +}: { + profileUsers: ProfileUser[]; + profileId: string; + sortDescriptor: SortDescriptor; + onSortChange: (descriptor: SortDescriptor) => void; + isLoading: boolean; + isError: boolean; + onRetry: () => void; + roles: { id: string; name: string }[]; +}) => { + const t = useTranslations(); + + if (isError) { + return ( + + {t('Members could not be loaded')} + + + ); + } + + if (profileUsers.length === 0 && !isLoading) { + return ( + }> + {t('No members found')} + + ); + } + + return ( + + ); +}; + +const getProfileUserStatus = (): string => { + // TODO: We need this logic in the backend + // Default to "Active" for existing profile users + return 'Active'; +}; + +const ProfileUserRoleSelect = ({ + profileUserId, + currentRoleId, + profileId, + roles, +}: { + profileUserId: string; + currentRoleId?: string; + profileId: string; + roles: { id: string; name: string }[]; +}) => { + const t = useTranslations(); + const utils = trpc.useUtils(); + + const updateRoles = trpc.profile.updateUserRoles.useMutation({ + onSuccess: () => { + toast.success({ message: t('Role updated successfully') }); + void utils.profile.listUsers.invalidate({ profileId }); + }, + onError: (error) => { + toast.error({ + message: error.message || t('Failed to update role'), + }); + }, + }); + + const handleRoleChange = (roleId: string) => { + if (roleId && roleId !== currentRoleId) { + updateRoles.mutate({ + profileUserId, + roleIds: [roleId], + }); + } + }; + + return ( + + ); +}; + +// Inner table content component +const ProfileUsersAccessTableContent = ({ + profileUsers, + profileId, + sortDescriptor, + onSortChange, + isLoading, + roles, +}: { + profileUsers: ProfileUser[]; + profileId: string; + sortDescriptor: SortDescriptor; + onSortChange: (descriptor: SortDescriptor) => void; + isLoading: boolean; + roles: { id: string; name: string }[]; +}) => { + const t = useTranslations(); + + return ( + }> +
+ {isLoading && ( +
+ +
+ )} + + + + {t('Name')} + + + {t('Email')} + + + {t('Role')} + + + + {profileUsers.map((profileUser) => { + const displayName = + profileUser.profile?.name || + profileUser.name || + (profileUser.email?.split('@')?.[0] ?? 'Unknown'); + const currentRole = profileUser.roles[0]; + const status = getProfileUserStatus(); + + return ( + + +
+ +
+ + {displayName} + + + {status} + +
+
+
+ + + {profileUser.email} + + + + + +
+ ); + })} +
+
+
+
+ ); +}; diff --git a/apps/app/src/components/decisions/pages/ResultsPage.tsx b/apps/app/src/components/decisions/pages/ResultsPage.tsx index 2ca04fa86..e471d6309 100644 --- a/apps/app/src/components/decisions/pages/ResultsPage.tsx +++ b/apps/app/src/components/decisions/pages/ResultsPage.tsx @@ -146,7 +146,7 @@ function ResultsPageContent({ ( }> {t('Results are still being processed.')} @@ -169,7 +169,7 @@ function ResultsPageContent({ , + default: () => , }} >
diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index ae8340944..a933f97dd 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -437,5 +437,10 @@ "User avatar": "ব্যবহারকারীর অবতার", "Done": "সম্পন্ন", "How do you want to structure your decision-making process?": "আপনি কীভাবে আপনার সিদ্ধান্ত গ্রহণ প্রক্রিয়া গঠন করতে চান?", - "No templates found": "কোনো টেমপ্লেট পাওয়া যায়নি" + "No templates found": "কোনো টেমপ্লেট পাওয়া যায়নি", + "Members could not be loaded": "সদস্যদের লোড করা যায়নি", + "Try again": "আবার চেষ্টা করুন", + "Role updated successfully": "ভূমিকা সফলভাবে আপডেট করা হয়েছে", + "Failed to update role": "ভূমিকা আপডেট করতে ব্যর্থ", + "Members list": "সদস্যদের তালিকা" } diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index 6dd83eeb0..593042006 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -437,5 +437,10 @@ "User avatar": "User avatar", "Done": "Done", "How do you want to structure your decision-making process?": "How do you want to structure your decision-making process?", - "No templates found": "No templates found" + "No templates found": "No templates found", + "Members could not be loaded": "Members could not be loaded", + "Try again": "Try again", + "Role updated successfully": "Role updated successfully", + "Failed to update role": "Failed to update role", + "Members list": "Members list" } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index 3bc51293f..0688b03e6 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -436,5 +436,10 @@ "User avatar": "Avatar del usuario", "Done": "Listo", "How do you want to structure your decision-making process?": "¿Cómo quieres estructurar tu proceso de toma de decisiones?", - "No templates found": "No se encontraron plantillas" + "No templates found": "No se encontraron plantillas", + "Members could not be loaded": "No se pudieron cargar los miembros", + "Try again": "Intentar de nuevo", + "Role updated successfully": "Rol actualizado exitosamente", + "Failed to update role": "Error al actualizar el rol", + "Members list": "Lista de miembros" } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index d1acc507d..99e93685c 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -437,5 +437,10 @@ "User avatar": "Avatar de l'utilisateur", "Done": "Terminé", "How do you want to structure your decision-making process?": "Comment souhaitez-vous structurer votre processus de prise de décision ?", - "No templates found": "Aucun modèle trouvé" + "No templates found": "Aucun modèle trouvé", + "Members could not be loaded": "Les membres n'ont pas pu être chargés", + "Try again": "Réessayer", + "Role updated successfully": "Rôle mis à jour avec succès", + "Failed to update role": "Échec de la mise à jour du rôle", + "Members list": "Liste des membres" } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 63543c6fe..34f7dc4a2 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -437,5 +437,10 @@ "User avatar": "Avatar do usuário", "Done": "Concluído", "How do you want to structure your decision-making process?": "Como você deseja estruturar seu processo de tomada de decisão?", - "No templates found": "Nenhum modelo encontrado" + "No templates found": "Nenhum modelo encontrado", + "Members could not be loaded": "Não foi possível carregar os membros", + "Try again": "Tentar novamente", + "Role updated successfully": "Função atualizada com sucesso", + "Failed to update role": "Falha ao atualizar a função", + "Members list": "Lista de membros" } diff --git a/apps/app/src/utils/APIErrorBoundary.tsx b/apps/app/src/utils/APIErrorBoundary.tsx index 7e3c8e47c..a58c54339 100644 --- a/apps/app/src/utils/APIErrorBoundary.tsx +++ b/apps/app/src/utils/APIErrorBoundary.tsx @@ -1,13 +1,13 @@ 'use client'; -import { ReactElement, ReactNode } from 'react'; +import { ReactNode } from 'react'; import { FallbackProps, ErrorBoundary as ReactErrorBoundary, } from 'react-error-boundary'; type APIFallbacks = { - [code: string]: ReactElement | null; + [code: string]: ((props: FallbackProps) => ReactNode) | null; }; export const APIErrorBoundary = ({ @@ -19,16 +19,16 @@ export const APIErrorBoundary = ({ }) => { return ( { + fallbackRender={({ error, resetErrorBoundary }: FallbackProps) => { const fallback = fallbacks[error.data?.httpStatus]; if (fallback) { - return fallback; + return fallback({ error, resetErrorBoundary }); } // support a default fallback if (fallbacks['default']) { - return fallbacks['default']; + return fallbacks['default']({ error, resetErrorBoundary }); } throw error; diff --git a/apps/app/src/utils/ClientOnly.tsx b/apps/app/src/utils/ClientOnly.tsx index 44b731990..8ab2c4fac 100644 --- a/apps/app/src/utils/ClientOnly.tsx +++ b/apps/app/src/utils/ClientOnly.tsx @@ -3,7 +3,13 @@ import { useEffect, useState } from 'react'; import type { ReactNode } from 'react'; -export const ClientOnly = ({ children }: { children: ReactNode }) => { +export const ClientOnly = ({ + children, + fallback = null, +}: { + children: ReactNode; + fallback?: ReactNode; +}) => { const [isMounted, setIsMounted] = useState(false); useEffect(() => { @@ -11,7 +17,7 @@ export const ClientOnly = ({ children }: { children: ReactNode }) => { }, []); if (!isMounted) { - return
; + return <>{fallback}; } return <>{children}; diff --git a/packages/hooks/src/useCursorPagination.ts b/packages/hooks/src/useCursorPagination.ts index 8ef945a29..a74257cda 100644 --- a/packages/hooks/src/useCursorPagination.ts +++ b/packages/hooks/src/useCursorPagination.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; /** * Custom hook for managing cursor-based pagination state. @@ -55,10 +55,10 @@ export function useCursorPagination(limit: number) { * Reset pagination to the first page. * Useful when filters or search queries change. */ - const reset = () => { + const reset = useCallback(() => { setCursor(null); setCursorHistory([null]); - }; + }, []); return { cursor, diff --git a/packages/styles/shared-styles.css b/packages/styles/shared-styles.css index 08440c5b4..d4fe25221 100644 --- a/packages/styles/shared-styles.css +++ b/packages/styles/shared-styles.css @@ -320,7 +320,7 @@ --radius-lg: 0.375rem; --radius-xs: calc(var(--radius-lg) * 0.25); --radius-sm: calc(var(--radius-lg) * 0.5); - --radius-md: calc(var(--radius-lg) * 0.75); + --radius-md: calc(var(--radius-lg) * 1.33); --radius-xl: calc(var(--radius-lg) * 1.5); --radius-2xl: calc(var(--radius-lg) * 2); --radius-3xl: calc(var(--radius-lg) * 3); @@ -614,6 +614,7 @@ 100% { transform: rotate(-5deg); } + 50% { transform: rotate(5deg); } @@ -624,6 +625,7 @@ 100% { background-position: 0% 0%; } + 50% { background-position: 100% 100%; } diff --git a/services/api/src/encoders/profiles.ts b/services/api/src/encoders/profiles.ts index 3ce29b72c..256650c30 100644 --- a/services/api/src/encoders/profiles.ts +++ b/services/api/src/encoders/profiles.ts @@ -70,3 +70,5 @@ export const profileUserEncoder = createSelectSchema(profileUsers).extend({ // Roles using shared minimal encoder roles: z.array(accessRoleMinimalEncoder), }); + +export type ProfileUser = z.infer;