From 297a2ed9e49479489ddffdaf748b3403e7963627 Mon Sep 17 00:00:00 2001 From: Gabriel Menezes Date: Sat, 10 Oct 2026 22:50:35 -0400 Subject: [PATCH] feat(members-settings): implement Members management, join request approvals, and Workspace settings --- new-frontend/src/App.tsx | 6 +- new-frontend/src/pages/MembersPage.tsx | 533 ++++++++++++++++++ .../src/pages/OrganizationSettingsPage.tsx | 169 ++++++ .../src/services/organizationService.ts | 23 + 4 files changed, 729 insertions(+), 2 deletions(-) create mode 100644 new-frontend/src/pages/MembersPage.tsx create mode 100644 new-frontend/src/pages/OrganizationSettingsPage.tsx diff --git a/new-frontend/src/App.tsx b/new-frontend/src/App.tsx index b321a5e..d45be76 100644 --- a/new-frontend/src/App.tsx +++ b/new-frontend/src/App.tsx @@ -10,6 +10,8 @@ import { OrganizationsPage } from './pages/OrganizationsPage' import { TasksPage } from './pages/TasksPage' import { ProjectsPage } from './pages/ProjectsPage' import { CategoriesPage } from './pages/CategoriesPage' +import { MembersPage } from './pages/MembersPage' +import { OrganizationSettingsPage } from './pages/OrganizationSettingsPage' export function App() { return ( @@ -37,8 +39,8 @@ export function App() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> diff --git a/new-frontend/src/pages/MembersPage.tsx b/new-frontend/src/pages/MembersPage.tsx new file mode 100644 index 0000000..7785b68 --- /dev/null +++ b/new-frontend/src/pages/MembersPage.tsx @@ -0,0 +1,533 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { + Users, + UserCheck, + UserPlus, + Clock, + CheckCircle2, + XCircle, + Search, + RefreshCw, + Sparkles, + ShieldCheck, + AlertCircle, + Mail, + Loader2, + Send, +} from 'lucide-react' +import { useAuth } from '../contexts/AuthContext' +import { + organizationService, + type OrganizationMember, + type PendingJoinRequest, + type SearchOrgOutput, + type JoinRequestOutput, +} from '../services/organizationService' + +export const MembersPage: React.FC = () => { + const { user, activeOrg } = useAuth() + + const [activeTab, setActiveTab] = useState<'members' | 'pending' | 'discover'>('members') + const [members, setMembers] = useState([]) + const [pendingRequests, setPendingRequests] = useState([]) + const [myRequests, setMyRequests] = useState([]) + const [searchResults, setSearchResults] = useState([]) + + const [searchOrgTerm, setSearchOrgTerm] = useState('') + const [memberSearchTerm, setMemberSearchTerm] = useState('') + + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [searching, setSearching] = useState(false) + const [actionLoadingId, setActionLoadingId] = useState(null) + const [feedbackMsg, setFeedbackMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null) + + const activeOrgId = activeOrg?.id + const isOwner = activeOrg?.roles?.includes('OWNER') || false + + const loadData = useCallback(async () => { + if (!activeOrgId) return + try { + setRefreshing(true) + const [membersRes, requestsRes, myReqsRes] = await Promise.allSettled([ + organizationService.listMembers(activeOrgId), + isOwner ? organizationService.listPendingRequests(activeOrgId) : Promise.resolve([]), + organizationService.listMyRequests(), + ]) + + if (membersRes.status === 'fulfilled') setMembers(membersRes.value || []) + if (requestsRes.status === 'fulfilled') setPendingRequests(requestsRes.value || []) + if (myReqsRes.status === 'fulfilled') setMyRequests(myReqsRes.value || []) + } catch (err: unknown) { + console.error(err) + setFeedbackMsg({ type: 'error', text: 'Falha ao carregar informações da equipe.' }) + } finally { + setLoading(false) + setRefreshing(false) + } + }, [activeOrgId, isOwner]) + + useEffect(() => { + loadData() + }, [loadData]) + + const handleApprove = async (userId: number) => { + if (!activeOrgId) return + setActionLoadingId(userId) + setFeedbackMsg(null) + try { + await organizationService.approveRequest(activeOrgId, userId) + setPendingRequests((prev) => prev.filter((r) => r.userId !== userId)) + setFeedbackMsg({ type: 'success', text: 'Solicitação aprovada! O usuário agora é membro da organização.' }) + // Reload members list + const updatedMembers = await organizationService.listMembers(activeOrgId) + setMembers(updatedMembers) + } catch (err: unknown) { + console.error(err) + setFeedbackMsg({ type: 'error', text: 'Falha ao aprovar solicitação.' }) + } finally { + setActionLoadingId(null) + } + } + + const handleReject = async (userId: number) => { + if (!activeOrgId) return + setActionLoadingId(userId) + setFeedbackMsg(null) + try { + await organizationService.rejectRequest(activeOrgId, userId) + setPendingRequests((prev) => prev.filter((r) => r.userId !== userId)) + setFeedbackMsg({ type: 'success', text: 'Solicitação rejeitada.' }) + } catch (err: unknown) { + console.error(err) + setFeedbackMsg({ type: 'error', text: 'Falha ao rejeitar solicitação.' }) + } finally { + setActionLoadingId(null) + } + } + + const handleSearchOrg = async (e: React.FormEvent) => { + e.preventDefault() + if (!searchOrgTerm.trim()) return + + setSearching(true) + setFeedbackMsg(null) + try { + const results = await organizationService.search(searchOrgTerm.trim()) + setSearchResults(results) + } catch (err: unknown) { + console.error(err) + setFeedbackMsg({ type: 'error', text: 'Erro ao buscar organizações.' }) + } finally { + setSearching(false) + } + } + + const handleRequestJoin = async (targetOrgId: number) => { + setActionLoadingId(targetOrgId) + setFeedbackMsg(null) + try { + await organizationService.requestJoin(targetOrgId) + setFeedbackMsg({ + type: 'success', + text: 'Solicitação enviada com sucesso! Aguarde a aprovação do proprietário.', + }) + const updatedMyReqs = await organizationService.listMyRequests() + setMyRequests(updatedMyReqs) + } catch (err: unknown) { + console.error(err) + setFeedbackMsg({ type: 'error', text: 'Falha ao solicitar entrada na organização.' }) + } finally { + setActionLoadingId(null) + } + } + + const filteredMembers = members.filter((m) => { + if (!memberSearchTerm.trim()) return true + const term = memberSearchTerm.toLowerCase() + return m.userName.toLowerCase().includes(term) || m.userEmail.toLowerCase().includes(term) + }) + + return ( +
+ {/* Header */} +
+
+
+

+ Membros & Equipe +

+ + {activeOrg?.name || 'Workspace'} + +
+

+ Gerencie os colaboradores ativos, controle solicitações de ingresso e descubra outros workspaces. +

+
+ + +
+ + {/* Feedback Alert */} + {feedbackMsg && ( +
+
+ {feedbackMsg.type === 'success' ? ( + + ) : ( + + )} + {feedbackMsg.text} +
+ +
+ )} + + {/* Tabs Switcher */} +
+ + + {isOwner && ( + + )} + + +
+ + {/* Tab 1: Membros Ativos */} + {activeTab === 'members' && ( +
+ {/* Member Search Bar */} +
+
+ + setMemberSearchTerm(e.target.value)} + placeholder="Filtrar por nome ou e-mail..." + className="w-full pl-9 pr-3 py-2 rounded-xl border border-border bg-surface-raised text-xs text-foreground placeholder-muted/60 focus:outline-none focus:ring-2 focus:ring-brand/40 focus:border-brand" + /> +
+ + {filteredMembers.length} {filteredMembers.length === 1 ? 'membro' : 'membros'} + +
+ + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : filteredMembers.length === 0 ? ( +
+ +

Nenhum membro encontrado com este termo.

+
+ ) : ( +
+ {filteredMembers.map((member) => { + const isCurrentUser = member.userEmail === user?.email + return ( +
+
+
+ {member.userName ? member.userName.charAt(0) : member.userEmail.charAt(0)} +
+
+
+

+ {member.userName} +

+ {isCurrentUser && ( + + Você + + )} +
+

+ + {member.userEmail} +

+
+
+ +
+ + + Ativo + +
+
+ ) + })} +
+ )} +
+ )} + + {/* Tab 2: Solicitações Pendentes (Owner Only) */} + {activeTab === 'pending' && isOwner && ( +
+
+ +
+

Painel de Aprovação do Proprietário

+

+ Apenas proprietários podem aprovar novos membros para acessar as tarefas e projetos deste workspace. +

+
+
+ + {pendingRequests.length === 0 ? ( +
+ +

Tudo limpo!

+

Não há solicitações pendentes de aprovação no momento.

+
+ ) : ( +
+ {pendingRequests.map((req) => ( +
+
+
+ {req.name ? req.name.charAt(0) : req.email.charAt(0)} +
+
+

{req.name}

+

{req.email}

+
+
+ +
+ + +
+
+ ))} +
+ )} +
+ )} + + {/* Tab 3: Descobrir e Solicitar Ingresso */} + {activeTab === 'discover' && ( +
+ {/* Search other organizations */} +
+
+
+ +
+
+

+ Buscar Organizações +

+

+ Procure por empresas parceiras ou squads para solicitar acesso. +

+
+
+ +
+
+ + setSearchOrgTerm(e.target.value)} + placeholder="Digite o nome da organização (ex: BeeTech, Squad Core...)" + className="w-full pl-9 pr-3 py-2.5 rounded-xl border border-border bg-surface-raised text-xs text-foreground placeholder-muted/60 focus:outline-none focus:ring-2 focus:ring-brand/40 focus:border-brand" + /> +
+ +
+ + {/* Results */} + {searchResults.length > 0 && ( +
+

+ Resultados da Busca +

+
+ {searchResults.map((org) => { + const alreadyRequested = myRequests.some( + (r) => r.organizationId === org.id + ) + return ( +
+
+

{org.name}

+

ID #{org.id}

+
+ + {alreadyRequested ? ( + + Solicitado + + ) : ( + + )} +
+ ) + })} +
+
+ )} +
+ + {/* Minhas solicitações enviadas */} +
+

+ Minhas Solicitações de Ingresso +

+ + {myRequests.length === 0 ? ( +

Você ainda não solicitou entrada em outras organizações.

+ ) : ( +
+ {myRequests.map((req, idx) => ( +
+
+

{req.organizationName}

+

ID #{req.organizationId}

+
+ + {req.status === 'APPROVED' + ? 'Aprovado' + : req.status === 'REJECTED' + ? 'Recusado' + : 'Pendente'} + +
+ ))} +
+ )} +
+
+ )} +
+ ) +} diff --git a/new-frontend/src/pages/OrganizationSettingsPage.tsx b/new-frontend/src/pages/OrganizationSettingsPage.tsx new file mode 100644 index 0000000..8a45f8d --- /dev/null +++ b/new-frontend/src/pages/OrganizationSettingsPage.tsx @@ -0,0 +1,169 @@ +import React from 'react' +import { useNavigate } from 'react-router-dom' +import { + Building2, + Shield, + Users, + FolderKanban, + ExternalLink, + Info, + CheckCircle2, +} from 'lucide-react' +import { useAuth } from '../contexts/AuthContext' + +export const OrganizationSettingsPage: React.FC = () => { + const { activeOrg, user } = useAuth() + const navigate = useNavigate() + + if (!activeOrg) { + return ( +
+ Nenhuma organização ativa selecionada. +
+ ) + } + + const isOwner = activeOrg.roles?.includes('OWNER') || false + + return ( +
+ {/* Header */} +
+
+

+ Configurações do Workspace +

+ + {activeOrg.name} + +
+

+ Informações gerais, permissões e detalhes estruturais do seu espaço de trabalho. +

+
+ + {/* Main Settings Card */} +
+
+
+
+ {activeOrg.name.slice(0, 2).toUpperCase()} +
+
+

{activeOrg.name}

+

Identificador Único: #{activeOrg.id}

+
+
+ + + + {isOwner ? 'Proprietário (Owner)' : 'Colaborador (Member)'} + +
+ + {/* Workspace Details List */} +
+
+ + Nome da Organização + + {activeOrg.name} +
+ +
+ + Seu Perfil Conectado + + + {user?.name} ({user?.email}) + +
+ +
+ + Permissões Ativas + +
+ {activeOrg.roles.map((r) => ( + + {r} + + ))} +
+
+ +
+ + Status da Conexão + +
+ + Sincronizado com Quarkus API +
+
+
+ + {/* Quick Links */} +
+

+ Ações Rápidas do Workspace +

+
+ + + + + +
+
+ + {/* Security Note */} +
+ +

+ As alterações nos dados cadastrais da organização respeitam a política de isolamento multi-tenant do BeeTask. Apenas os usuários autenticados e aprovados têm acesso aos dados deste workspace. +

+
+
+
+ ) +} diff --git a/new-frontend/src/services/organizationService.ts b/new-frontend/src/services/organizationService.ts index 8d03bd8..22d446a 100644 --- a/new-frontend/src/services/organizationService.ts +++ b/new-frontend/src/services/organizationService.ts @@ -25,6 +25,13 @@ export type OrganizationMember = { userPhoto?: string } +export type PendingJoinRequest = { + userId: number + name: string + email: string + photo?: string +} + export const organizationService = { async create(name: string): Promise { return apiFetch('/organizations', { @@ -50,5 +57,21 @@ export const organizationService = { async listMembers(organizationId: number): Promise { return apiFetch(`/organizations/${organizationId}/members`) }, + + async listPendingRequests(organizationId: number): Promise { + return apiFetch(`/organizations/${organizationId}/requests`) + }, + + async approveRequest(organizationId: number, userId: number): Promise { + return apiFetch(`/organizations/${organizationId}/requests/${userId}/approve`, { + method: 'PATCH', + }) + }, + + async rejectRequest(organizationId: number, userId: number): Promise { + return apiFetch(`/organizations/${organizationId}/requests/${userId}/reject`, { + method: 'PATCH', + }) + }, }