feat(members-settings): implement Members management, join request approvals, and Workspace settings
This commit is contained in:
parent
640af48a9c
commit
297a2ed9e4
4 changed files with 729 additions and 2 deletions
|
|
@ -10,6 +10,8 @@ import { OrganizationsPage } from './pages/OrganizationsPage'
|
||||||
import { TasksPage } from './pages/TasksPage'
|
import { TasksPage } from './pages/TasksPage'
|
||||||
import { ProjectsPage } from './pages/ProjectsPage'
|
import { ProjectsPage } from './pages/ProjectsPage'
|
||||||
import { CategoriesPage } from './pages/CategoriesPage'
|
import { CategoriesPage } from './pages/CategoriesPage'
|
||||||
|
import { MembersPage } from './pages/MembersPage'
|
||||||
|
import { OrganizationSettingsPage } from './pages/OrganizationSettingsPage'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -37,8 +39,8 @@ export function App() {
|
||||||
<Route path="/projects" element={<ProjectsPage />} />
|
<Route path="/projects" element={<ProjectsPage />} />
|
||||||
<Route path="/categories" element={<CategoriesPage />} />
|
<Route path="/categories" element={<CategoriesPage />} />
|
||||||
<Route path="/analytics" element={<DashboardPage />} />
|
<Route path="/analytics" element={<DashboardPage />} />
|
||||||
<Route path="/members" element={<OrganizationsPage />} />
|
<Route path="/members" element={<MembersPage />} />
|
||||||
<Route path="/organization/settings" element={<OrganizationsPage />} />
|
<Route path="/organization/settings" element={<OrganizationSettingsPage />} />
|
||||||
<Route path="/profile" element={<DashboardPage />} />
|
<Route path="/profile" element={<DashboardPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|
|
||||||
533
new-frontend/src/pages/MembersPage.tsx
Normal file
533
new-frontend/src/pages/MembersPage.tsx
Normal file
|
|
@ -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<OrganizationMember[]>([])
|
||||||
|
const [pendingRequests, setPendingRequests] = useState<PendingJoinRequest[]>([])
|
||||||
|
const [myRequests, setMyRequests] = useState<JoinRequestOutput[]>([])
|
||||||
|
const [searchResults, setSearchResults] = useState<SearchOrgOutput[]>([])
|
||||||
|
|
||||||
|
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<number | null>(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 (
|
||||||
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
Membros & Equipe
|
||||||
|
</h1>
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-brand/15 text-brand border border-brand/25">
|
||||||
|
{activeOrg?.name || 'Workspace'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Gerencie os colaboradores ativos, controle solicitações de ingresso e descubra outros workspaces.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={refreshing}
|
||||||
|
title="Recarregar dados"
|
||||||
|
className="self-start sm:self-auto p-2 rounded-xl border border-border bg-surface text-muted hover:text-foreground hover:bg-surface-raised transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin text-brand' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback Alert */}
|
||||||
|
{feedbackMsg && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between p-3.5 rounded-xl border text-xs ${
|
||||||
|
feedbackMsg.type === 'success'
|
||||||
|
? 'bg-emerald-500/10 border-emerald-500/25 text-emerald-300'
|
||||||
|
: 'bg-rose-500/10 border-rose-500/25 text-rose-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{feedbackMsg.type === 'success' ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-4 h-4 text-rose-400" />
|
||||||
|
)}
|
||||||
|
<span>{feedbackMsg.text}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFeedbackMsg(null)}
|
||||||
|
className="text-xs font-bold hover:opacity-80"
|
||||||
|
>
|
||||||
|
Fechar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs Switcher */}
|
||||||
|
<div className="flex items-center gap-2 border-b border-border pb-1 overflow-x-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('members')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-semibold transition-all whitespace-nowrap ${
|
||||||
|
activeTab === 'members'
|
||||||
|
? 'bg-brand text-white shadow-xs'
|
||||||
|
: 'text-muted hover:text-foreground hover:bg-surface-raised'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
<span>Membros Ativos</span>
|
||||||
|
<span className="px-1.5 py-0.2 rounded-full text-[10px] bg-white/20">
|
||||||
|
{members.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('pending')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-semibold transition-all whitespace-nowrap ${
|
||||||
|
activeTab === 'pending'
|
||||||
|
? 'bg-amber-600 text-white shadow-xs'
|
||||||
|
: 'text-muted hover:text-foreground hover:bg-surface-raised'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>Solicitações Pendentes</span>
|
||||||
|
{pendingRequests.length > 0 && (
|
||||||
|
<span className="px-1.5 py-0.2 rounded-full text-[10px] bg-rose-500 text-white font-bold animate-pulse">
|
||||||
|
{pendingRequests.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('discover')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-semibold transition-all whitespace-nowrap ${
|
||||||
|
activeTab === 'discover'
|
||||||
|
? 'bg-brand text-white shadow-xs'
|
||||||
|
: 'text-muted hover:text-foreground hover:bg-surface-raised'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
<span>Ingressar em Outros Workspaces</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 1: Membros Ativos */}
|
||||||
|
{activeTab === 'members' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Member Search Bar */}
|
||||||
|
<div className="p-3.5 rounded-2xl border border-border bg-surface flex items-center justify-between gap-3 shadow-xs">
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<Search className="w-4 h-4 text-muted absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={memberSearchTerm}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted font-medium">
|
||||||
|
{filteredMembers.length} {filteredMembers.length === 1 ? 'membro' : 'membros'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-28 rounded-2xl border border-border bg-surface-sunken/40 animate-pulse p-4"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filteredMembers.length === 0 ? (
|
||||||
|
<div className="text-center py-16 rounded-2xl border border-dashed border-border bg-surface/50">
|
||||||
|
<Users className="w-8 h-8 text-muted mx-auto mb-2" />
|
||||||
|
<p className="text-xs text-muted">Nenhum membro encontrado com este termo.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredMembers.map((member) => {
|
||||||
|
const isCurrentUser = member.userEmail === user?.email
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={member.userId}
|
||||||
|
className="p-4 rounded-2xl border border-border bg-surface hover:border-zinc-700 transition-all flex items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 truncate">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-brand/15 border border-brand/30 flex items-center justify-center text-sm font-bold text-brand uppercase shrink-0">
|
||||||
|
{member.userName ? member.userName.charAt(0) : member.userEmail.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div className="truncate">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<h4 className="text-xs font-bold text-foreground truncate">
|
||||||
|
{member.userName}
|
||||||
|
</h4>
|
||||||
|
{isCurrentUser && (
|
||||||
|
<span className="px-1.5 py-0.2 rounded-md bg-brand/20 text-brand text-[9px] font-bold">
|
||||||
|
Você
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted truncate flex items-center gap-1">
|
||||||
|
<Mail className="w-3 h-3 text-muted/70" />
|
||||||
|
{member.userEmail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0">
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium bg-surface-raised border border-border text-muted">
|
||||||
|
<UserCheck className="w-3 h-3 text-emerald-400" />
|
||||||
|
Ativo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 2: Solicitações Pendentes (Owner Only) */}
|
||||||
|
{activeTab === 'pending' && isOwner && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 rounded-2xl border border-amber-500/20 bg-amber-500/5 text-xs text-amber-300 flex items-center gap-3">
|
||||||
|
<ShieldCheck className="w-5 h-5 text-amber-400 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">Painel de Aprovação do Proprietário</p>
|
||||||
|
<p className="text-[11px] text-amber-300/80">
|
||||||
|
Apenas proprietários podem aprovar novos membros para acessar as tarefas e projetos deste workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pendingRequests.length === 0 ? (
|
||||||
|
<div className="text-center py-16 rounded-2xl border border-dashed border-border bg-surface/50">
|
||||||
|
<CheckCircle2 className="w-8 h-8 text-emerald-400 mx-auto mb-2" />
|
||||||
|
<h4 className="text-sm font-bold text-foreground">Tudo limpo!</h4>
|
||||||
|
<p className="text-xs text-muted">Não há solicitações pendentes de aprovação no momento.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{pendingRequests.map((req) => (
|
||||||
|
<div
|
||||||
|
key={req.userId}
|
||||||
|
className="p-4 rounded-2xl border border-border bg-surface flex items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 truncate">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-amber-500/15 border border-amber-500/30 flex items-center justify-center text-sm font-bold text-amber-400 uppercase shrink-0">
|
||||||
|
{req.name ? req.name.charAt(0) : req.email.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div className="truncate">
|
||||||
|
<h4 className="text-xs font-bold text-foreground truncate">{req.name}</h4>
|
||||||
|
<p className="text-[11px] text-muted truncate">{req.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionLoadingId === req.userId}
|
||||||
|
onClick={() => handleReject(req.userId)}
|
||||||
|
className="p-2 rounded-xl border border-rose-500/30 text-rose-400 hover:bg-rose-500/15 transition-colors"
|
||||||
|
title="Rejeitar"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionLoadingId === req.userId}
|
||||||
|
onClick={() => handleApprove(req.userId)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-brand text-white text-xs font-bold hover:bg-brand-hover shadow-xs transition-colors"
|
||||||
|
>
|
||||||
|
{actionLoadingId === req.userId ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
<span>Aprovar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 3: Descobrir e Solicitar Ingresso */}
|
||||||
|
{activeTab === 'discover' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Search other organizations */}
|
||||||
|
<div className="p-5 rounded-2xl border border-border bg-surface shadow-xs space-y-4">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-brand/15 border border-brand/30 flex items-center justify-center text-brand">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-foreground">
|
||||||
|
Buscar Organizações
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Procure por empresas parceiras ou squads para solicitar acesso.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSearchOrg} className="flex gap-2.5">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="w-4 h-4 text-muted absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchOrgTerm}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={searching || !searchOrgTerm.trim()}
|
||||||
|
className="px-4 py-2.5 rounded-xl bg-brand text-white text-xs font-semibold hover:bg-brand-hover shadow-xs flex items-center gap-1.5 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{searching ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||||
|
<span>Buscar</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="space-y-2 pt-2 border-t border-border">
|
||||||
|
<p className="text-[11px] font-semibold text-muted uppercase tracking-wider">
|
||||||
|
Resultados da Busca
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{searchResults.map((org) => {
|
||||||
|
const alreadyRequested = myRequests.some(
|
||||||
|
(r) => r.organizationId === org.id
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={org.id}
|
||||||
|
className="p-3.5 rounded-xl border border-border bg-surface-raised flex items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<div className="truncate">
|
||||||
|
<p className="text-xs font-bold text-foreground truncate">{org.name}</p>
|
||||||
|
<p className="text-[10px] text-muted">ID #{org.id}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{alreadyRequested ? (
|
||||||
|
<span className="px-2.5 py-1 rounded-lg text-[10px] font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||||
|
Solicitado
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionLoadingId === org.id}
|
||||||
|
onClick={() => handleRequestJoin(org.id)}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-brand text-white text-xs font-semibold hover:bg-brand-hover transition-colors"
|
||||||
|
>
|
||||||
|
{actionLoadingId === org.id ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
<span>Solicitar</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Minhas solicitações enviadas */}
|
||||||
|
<div className="p-5 rounded-2xl border border-border bg-surface shadow-xs space-y-3">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-wider text-muted">
|
||||||
|
Minhas Solicitações de Ingresso
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{myRequests.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted py-4">Você ainda não solicitou entrada em outras organizações.</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
|
{myRequests.map((req, idx) => (
|
||||||
|
<div key={idx} className="py-2.5 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-foreground">{req.organizationName}</p>
|
||||||
|
<p className="text-[10px] text-muted">ID #{req.organizationId}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-2.5 py-0.5 rounded-md text-[10px] font-bold ${
|
||||||
|
req.status === 'APPROVED'
|
||||||
|
? 'bg-emerald-500/15 text-emerald-300 border border-emerald-500/30'
|
||||||
|
: req.status === 'REJECTED'
|
||||||
|
? 'bg-rose-500/15 text-rose-300 border border-rose-500/30'
|
||||||
|
: 'bg-amber-500/15 text-amber-300 border border-amber-500/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{req.status === 'APPROVED'
|
||||||
|
? 'Aprovado'
|
||||||
|
: req.status === 'REJECTED'
|
||||||
|
? 'Recusado'
|
||||||
|
: 'Pendente'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
169
new-frontend/src/pages/OrganizationSettingsPage.tsx
Normal file
169
new-frontend/src/pages/OrganizationSettingsPage.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="p-8 text-center text-muted">
|
||||||
|
Nenhuma organização ativa selecionada.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = activeOrg.roles?.includes('OWNER') || false
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
Configurações do Workspace
|
||||||
|
</h1>
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-brand/15 text-brand border border-brand/25">
|
||||||
|
{activeOrg.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Informações gerais, permissões e detalhes estruturais do seu espaço de trabalho.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Settings Card */}
|
||||||
|
<div className="rounded-2xl border border-border bg-surface p-6 shadow-xs space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4 pb-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-brand to-teal-700 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-brand/20">
|
||||||
|
{activeOrg.name.slice(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-foreground">{activeOrg.name}</h2>
|
||||||
|
<p className="text-xs text-muted">Identificador Único: #{activeOrg.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-bold border ${
|
||||||
|
isOwner
|
||||||
|
? 'bg-amber-500/15 text-amber-300 border-amber-500/30'
|
||||||
|
: 'bg-teal-500/15 text-teal-300 border-teal-500/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Shield className="w-3.5 h-3.5" />
|
||||||
|
{isOwner ? 'Proprietário (Owner)' : 'Colaborador (Member)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workspace Details List */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 rounded-xl bg-surface-raised border border-border">
|
||||||
|
<span className="text-[11px] font-semibold text-muted block mb-1">
|
||||||
|
Nome da Organização
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-foreground">{activeOrg.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-xl bg-surface-raised border border-border">
|
||||||
|
<span className="text-[11px] font-semibold text-muted block mb-1">
|
||||||
|
Seu Perfil Conectado
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-foreground">
|
||||||
|
{user?.name} ({user?.email})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-xl bg-surface-raised border border-border">
|
||||||
|
<span className="text-[11px] font-semibold text-muted block mb-1">
|
||||||
|
Permissões Ativas
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{activeOrg.roles.map((r) => (
|
||||||
|
<span
|
||||||
|
key={r}
|
||||||
|
className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-brand/15 text-brand border border-brand/25"
|
||||||
|
>
|
||||||
|
{r}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-xl bg-surface-raised border border-border">
|
||||||
|
<span className="text-[11px] font-semibold text-muted block mb-1">
|
||||||
|
Status da Conexão
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-emerald-400 font-semibold">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
<span>Sincronizado com Quarkus API</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-wider text-muted mb-3">
|
||||||
|
Ações Rápidas do Workspace
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/members')}
|
||||||
|
className="p-3.5 rounded-xl border border-border bg-surface-raised hover:border-brand transition-colors text-left flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Users className="w-4 h-4 text-brand" />
|
||||||
|
<span className="text-xs font-semibold text-foreground">Gerenciar Membros</span>
|
||||||
|
</div>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5 text-muted" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/projects')}
|
||||||
|
className="p-3.5 rounded-xl border border-border bg-surface-raised hover:border-brand transition-colors text-left flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<FolderKanban className="w-4 h-4 text-brand" />
|
||||||
|
<span className="text-xs font-semibold text-foreground">Projetos do Time</span>
|
||||||
|
</div>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5 text-muted" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/organizations')}
|
||||||
|
className="p-3.5 rounded-xl border border-border bg-surface-raised hover:border-brand transition-colors text-left flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Building2 className="w-4 h-4 text-brand" />
|
||||||
|
<span className="text-xs font-semibold text-foreground">Trocar de Workspace</span>
|
||||||
|
</div>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5 text-muted" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security Note */}
|
||||||
|
<div className="p-4 rounded-xl bg-surface-sunken/60 border border-border flex items-start gap-3 text-xs text-muted">
|
||||||
|
<Info className="w-4 h-4 text-brand shrink-0 mt-0.5" />
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,13 @@ export type OrganizationMember = {
|
||||||
userPhoto?: string
|
userPhoto?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PendingJoinRequest = {
|
||||||
|
userId: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
photo?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const organizationService = {
|
export const organizationService = {
|
||||||
async create(name: string): Promise<CreateOrgResponse> {
|
async create(name: string): Promise<CreateOrgResponse> {
|
||||||
return apiFetch<CreateOrgResponse>('/organizations', {
|
return apiFetch<CreateOrgResponse>('/organizations', {
|
||||||
|
|
@ -50,5 +57,21 @@ export const organizationService = {
|
||||||
async listMembers(organizationId: number): Promise<OrganizationMember[]> {
|
async listMembers(organizationId: number): Promise<OrganizationMember[]> {
|
||||||
return apiFetch<OrganizationMember[]>(`/organizations/${organizationId}/members`)
|
return apiFetch<OrganizationMember[]>(`/organizations/${organizationId}/members`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listPendingRequests(organizationId: number): Promise<PendingJoinRequest[]> {
|
||||||
|
return apiFetch<PendingJoinRequest[]>(`/organizations/${organizationId}/requests`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async approveRequest(organizationId: number, userId: number): Promise<void> {
|
||||||
|
return apiFetch<void>(`/organizations/${organizationId}/requests/${userId}/approve`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async rejectRequest(organizationId: number, userId: number): Promise<void> {
|
||||||
|
return apiFetch<void>(`/organizations/${organizationId}/requests/${userId}/reject`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue