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 { 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() {
|
|||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
<Route path="/analytics" element={<DashboardPage />} />
|
||||
<Route path="/members" element={<OrganizationsPage />} />
|
||||
<Route path="/organization/settings" element={<OrganizationsPage />} />
|
||||
<Route path="/members" element={<MembersPage />} />
|
||||
<Route path="/organization/settings" element={<OrganizationSettingsPage />} />
|
||||
<Route path="/profile" element={<DashboardPage />} />
|
||||
</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
|
||||
}
|
||||
|
||||
export type PendingJoinRequest = {
|
||||
userId: number
|
||||
name: string
|
||||
email: string
|
||||
photo?: string
|
||||
}
|
||||
|
||||
export const organizationService = {
|
||||
async create(name: string): Promise<CreateOrgResponse> {
|
||||
return apiFetch<CreateOrgResponse>('/organizations', {
|
||||
|
|
@ -50,5 +57,21 @@ export const organizationService = {
|
|||
async listMembers(organizationId: number): Promise<OrganizationMember[]> {
|
||||
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