Modal do primeiro acesso
This commit is contained in:
parent
829ef310a5
commit
e99e1cd586
11 changed files with 528 additions and 4 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { Layout, ListTodo, PlusCircle, Columns, LogOut } from 'lucide-react'
|
||||
import { Layout, ListTodo, PlusCircle, Columns, LogOut, Settings } from 'lucide-react'
|
||||
import { NavLink, Outlet, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
|
|
@ -60,6 +60,12 @@ export function AppShell() {
|
|||
<PlusCircle size={18} />
|
||||
Nova Tarefa
|
||||
</NavLink>
|
||||
{user?.organizations.some(org => org.roles.includes('OWNER') || org.roles.includes('ADMIN')) && (
|
||||
<NavLink to="/admin" className={navLinkClassName}>
|
||||
<Settings size={18} />
|
||||
Gestão
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
|
@ -108,6 +114,12 @@ export function AppShell() {
|
|||
<PlusCircle size={18} />
|
||||
Criar
|
||||
</NavLink>
|
||||
{user?.organizations.some(org => org.roles.includes('OWNER') || org.roles.includes('ADMIN')) && (
|
||||
<NavLink to="/admin" className={navLinkClassName}>
|
||||
<Settings size={18} />
|
||||
Gestão
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
63
frontend/src/components/OnboardingModal.tsx
Normal file
63
frontend/src/components/OnboardingModal.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { PlusCircle, Search, Layout } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
interface OnboardingModalProps {
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
export const OnboardingModal: React.FC<OnboardingModalProps> = ({ isOpen }) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-app-bg/80 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="w-full max-w-lg bg-surface border border-border-soft rounded-[2.5rem] shadow-panel p-10 overflow-hidden relative"
|
||||
>
|
||||
{/* Decorations */}
|
||||
<div className="absolute -top-20 -right-20 w-40 h-40 bg-brand/10 blur-[80px] rounded-full" />
|
||||
<div className="absolute -bottom-20 -left-20 w-40 h-40 bg-accent/10 blur-[80px] rounded-full" />
|
||||
|
||||
<div className="relative z-10 text-center mb-10">
|
||||
<div className="w-16 h-16 bg-brand text-white rounded-2xl flex items-center justify-center mb-6 shadow-lg shadow-brand/20 mx-auto">
|
||||
<Layout size={32} />
|
||||
</div>
|
||||
<h2 className="text-3xl font-black text-text-main tracking-tight mb-3">Quase lá!</h2>
|
||||
<p className="text-text-muted font-medium">Você ainda não faz parte de nenhuma organização. Como deseja começar?</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 relative z-10">
|
||||
<button
|
||||
onClick={() => navigate('/organizations/new')}
|
||||
className="group p-6 bg-surface-muted/30 border border-border-soft rounded-[2rem] text-left hover:border-brand hover:bg-brand/5 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<div className="w-12 h-12 bg-brand/10 text-brand rounded-xl flex items-center justify-center mb-4 group-hover:bg-brand group-hover:text-white transition-colors">
|
||||
<PlusCircle size={24} />
|
||||
</div>
|
||||
<h3 className="font-bold text-text-main mb-1">Criar uma</h3>
|
||||
<p className="text-sm text-text-muted">Crie seu próprio workspace e convide sua equipe.</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/organizations/join')}
|
||||
className="group p-6 bg-surface-muted/30 border border-border-soft rounded-[2rem] text-left hover:border-accent hover:bg-accent/5 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<div className="w-12 h-12 bg-accent/10 text-accent rounded-xl flex items-center justify-center mb-4 group-hover:bg-accent group-hover:text-white transition-colors">
|
||||
<Search size={24} />
|
||||
</div>
|
||||
<h3 className="font-bold text-text-main mb-1">Participar</h3>
|
||||
<p className="text-sm text-text-muted">Busque por uma organização existente e peça para entrar.</p>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,8 +11,9 @@ interface AuthContextType {
|
|||
user: LoginResponse | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
login: (email: string, password: string) => Promise<LoginResponse>;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
|
@ -94,6 +95,25 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||
const response = await authService.login(email, password);
|
||||
localStorage.setItem("user", JSON.stringify(response));
|
||||
setUser(response);
|
||||
return response;
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
try {
|
||||
const response = await authService.me();
|
||||
// We keep the old tokens since /me might not return new ones
|
||||
// or we just update the profile part.
|
||||
// But actually, let's merge the updated profile data into the stored user.
|
||||
const storedUser = localStorage.getItem("user");
|
||||
if (storedUser) {
|
||||
const userData = JSON.parse(storedUser);
|
||||
const updatedUser = { ...userData, ...response };
|
||||
localStorage.setItem("user", JSON.stringify(updatedUser));
|
||||
setUser(updatedUser);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh user profile", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -104,6 +124,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
refreshUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,18 @@ type RequestOptions = Omit<RequestInit, 'body'> & {
|
|||
export async function apiFetch<T>(path: string, options: RequestOptions = {}) {
|
||||
const { body, headers, ...rest } = options
|
||||
|
||||
const storedUser = localStorage.getItem('user')
|
||||
let authHeader = {}
|
||||
if (storedUser) {
|
||||
const userData = JSON.parse(storedUser)
|
||||
authHeader = { 'Authorization': `Bearer ${userData.jwt}` }
|
||||
}
|
||||
|
||||
const response = await fetch(buildUrl(path), {
|
||||
...rest,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeader,
|
||||
...headers,
|
||||
},
|
||||
body: body !== undefined && body !== null && !(body instanceof FormData)
|
||||
|
|
|
|||
88
frontend/src/pages/CreateOrganizationPage.tsx
Normal file
88
frontend/src/pages/CreateOrganizationPage.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Layout, Building2, ArrowRight } from 'lucide-react'
|
||||
import { organizationService } from '../services/organizationService'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
export const CreateOrganizationPage: React.FC = () => {
|
||||
const [name, setName] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const { refreshUser } = useAuth()
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await organizationService.create(name)
|
||||
await refreshUser()
|
||||
alert('Organização criada com sucesso!')
|
||||
navigate('/')
|
||||
} catch (error) {
|
||||
console.error('Failed to create organization', error)
|
||||
alert('Erro ao criar organização.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app-bg flex items-center justify-center p-4 relative overflow-hidden font-sans">
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none opacity-50">
|
||||
<div className="absolute top-0 left-0 w-[40%] h-[40%] bg-brand/15 blur-[100px] rounded-full" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="w-full max-w-md relative z-10"
|
||||
>
|
||||
<div className="bg-surface border border-border-soft p-10 rounded-[2.5rem] shadow-panel">
|
||||
<div className="flex flex-col items-center mb-10 text-center">
|
||||
<div className="w-14 h-14 bg-brand text-white rounded-2xl flex items-center justify-center mb-6 shadow-lg shadow-brand/20">
|
||||
<Building2 size={28} />
|
||||
</div>
|
||||
<h1 className="text-3xl font-black text-text-main tracking-tight mb-3">Nova Organização</h1>
|
||||
<p className="text-text-muted font-medium">Dê um nome ao seu novo workspace</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreate} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-bold text-text-main ml-1">Nome da Organização</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<Layout className="h-5 w-5 text-text-muted group-focus-within:text-brand transition-colors" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="block w-full pl-12 pr-4 py-3.5 bg-surface-muted/50 border border-border-soft rounded-2xl text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-4 focus:ring-brand/10 focus:border-brand transition-all font-medium"
|
||||
placeholder="Ex: Bee Tech"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !name}
|
||||
className="w-full flex items-center justify-center gap-3 bg-brand hover:bg-brand-strong disabled:bg-brand/50 disabled:cursor-not-allowed text-white font-bold py-4 px-6 rounded-2xl transition-all shadow-lg shadow-brand/20 active:scale-[0.98]"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="w-5 h-5 border-2 border-surface/30 border-t-surface rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Criar Agora
|
||||
<ArrowRight size={20} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
frontend/src/pages/JoinOrganizationPage.tsx
Normal file
122
frontend/src/pages/JoinOrganizationPage.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import React, { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Search, Building2, Send, CheckCircle2 } from 'lucide-react'
|
||||
import { organizationService } from '../services/organizationService'
|
||||
|
||||
interface OrgResult {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export const JoinOrganizationPage: React.FC = () => {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<OrgResult[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [requestedId, setRequestedId] = useState<number | null>(null)
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!query) return
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const data = await organizationService.search(query)
|
||||
setResults(data)
|
||||
} catch (error) {
|
||||
console.error('Search failed', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleJoinRequest = async (id: number) => {
|
||||
try {
|
||||
await organizationService.requestJoin(id)
|
||||
setRequestedId(id)
|
||||
alert('Solicitação enviada! Aguarde a aprovação do dono da organização.')
|
||||
} catch (error) {
|
||||
console.error('Join request failed', error)
|
||||
alert(error instanceof Error ? error.message : 'Erro ao enviar solicitação.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app-bg flex items-center justify-center p-4 relative overflow-hidden font-sans">
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none opacity-50">
|
||||
<div className="absolute top-0 right-0 w-[40%] h-[40%] bg-accent/15 blur-[100px] rounded-full" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="w-full max-w-2xl relative z-10"
|
||||
>
|
||||
<div className="bg-surface border border-border-soft p-10 rounded-[2.5rem] shadow-panel">
|
||||
<div className="flex flex-col items-center mb-10 text-center">
|
||||
<div className="w-14 h-14 bg-accent text-white rounded-2xl flex items-center justify-center mb-6 shadow-lg shadow-accent/20">
|
||||
<Search size={28} />
|
||||
</div>
|
||||
<h1 className="text-3xl font-black text-text-main tracking-tight mb-3">Buscar Organização</h1>
|
||||
<p className="text-text-muted font-medium">Encontre uma organização pelo nome</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch} className="flex gap-3 mb-8">
|
||||
<div className="relative flex-1 group">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<Building2 className="h-5 w-5 text-text-muted group-focus-within:text-accent transition-colors" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="block w-full pl-12 pr-4 py-3.5 bg-surface-muted/50 border border-border-soft rounded-2xl text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-4 focus:ring-accent/10 focus:border-accent transition-all font-medium"
|
||||
placeholder="Nome da organização..."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="bg-accent hover:bg-accent/90 text-white font-bold px-8 rounded-2xl transition-all shadow-lg shadow-accent/20 active:scale-[0.98] flex items-center gap-2"
|
||||
>
|
||||
{isLoading ? <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : 'Buscar'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-3">
|
||||
{results.length > 0 ? (
|
||||
results.map((org) => (
|
||||
<div
|
||||
key={org.id}
|
||||
className="flex items-center justify-between p-5 bg-surface-muted/30 border border-border-soft rounded-2xl hover:border-accent/50 transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-accent/10 text-accent rounded-lg flex items-center justify-center">
|
||||
<Building2 size={20} />
|
||||
</div>
|
||||
<span className="font-bold text-text-main">{org.name}</span>
|
||||
</div>
|
||||
{requestedId === org.id ? (
|
||||
<div className="flex items-center gap-2 text-green-500 font-bold text-sm bg-green-500/10 px-4 py-2 rounded-xl">
|
||||
<CheckCircle2 size={16} />
|
||||
Solicitado
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleJoinRequest(org.id)}
|
||||
className="flex items-center gap-2 text-accent font-bold text-sm hover:bg-accent hover:text-white px-4 py-2 rounded-xl border border-accent/20 transition-all"
|
||||
>
|
||||
<Send size={16} />
|
||||
Solicitar entrada
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : query && !isLoading ? (
|
||||
<p className="text-center text-text-muted py-10 font-medium">Nenhuma organização encontrada.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,11 +3,13 @@ import { useNavigate, Link } from 'react-router-dom'
|
|||
import { motion } from 'framer-motion'
|
||||
import { Mail, Lock, LogIn, Layout } from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { OnboardingModal } from '../components/OnboardingModal'
|
||||
|
||||
export const LoginPage: React.FC = () => {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const { login } = useAuth()
|
||||
|
||||
|
|
@ -16,8 +18,12 @@ export const LoginPage: React.FC = () => {
|
|||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await login(email, password)
|
||||
const response = await login(email, password)
|
||||
if (response.organizations.length === 0) {
|
||||
setShowOnboarding(true)
|
||||
} else {
|
||||
navigate('/')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login failed', error)
|
||||
alert('Erro ao fazer login. Tente novamente.')
|
||||
|
|
@ -110,6 +116,7 @@ export const LoginPage: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<OnboardingModal isOpen={showOnboarding} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
141
frontend/src/pages/OrganizationAdminPage.tsx
Normal file
141
frontend/src/pages/OrganizationAdminPage.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Users, Check, X, ShieldAlert, Mail } from 'lucide-react'
|
||||
import { organizationService, type JoinRequest } from '../services/organizationService'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
export const OrganizationAdminPage: React.FC = () => {
|
||||
const { user } = useAuth()
|
||||
const [requests, setRequests] = useState<JoinRequest[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// For this simplified version, we manage the first organization where the user is OWNER
|
||||
const orgToManage = user?.organizations.find(org => org.roles.includes('OWNER') || org.roles.includes('ADMIN'))
|
||||
|
||||
useEffect(() => {
|
||||
if (orgToManage) {
|
||||
loadRequests()
|
||||
}
|
||||
}, [orgToManage])
|
||||
|
||||
const loadRequests = async () => {
|
||||
if (!orgToManage) return
|
||||
try {
|
||||
const data = await organizationService.listPendingRequests(orgToManage.id)
|
||||
setRequests(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load requests', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = async (userId: number, approved: boolean) => {
|
||||
if (!orgToManage) return
|
||||
try {
|
||||
if (approved) {
|
||||
await organizationService.approveRequest(orgToManage.id, userId)
|
||||
} else {
|
||||
await organizationService.rejectRequest(orgToManage.id, userId)
|
||||
}
|
||||
setRequests(prev => prev.filter(r => r.userId !== userId))
|
||||
} catch (error) {
|
||||
console.error('Action failed', error)
|
||||
alert('Erro ao processar solicitação.')
|
||||
}
|
||||
}
|
||||
|
||||
if (!orgToManage) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-16 h-16 bg-danger/10 text-danger rounded-2xl flex items-center justify-center mb-6">
|
||||
<ShieldAlert size={32} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-text-main mb-2">Acesso Negado</h2>
|
||||
<p className="text-text-muted max-w-xs">Você não tem permissão para gerenciar nenhuma organização.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-black tracking-tight text-text-main">
|
||||
Gestão da <span className="text-brand">{orgToManage.name}</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-text-muted font-medium">
|
||||
Gerencie membros e solicitações de entrada
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface border border-border-soft rounded-[2.5rem] shadow-panel overflow-hidden">
|
||||
<div className="p-8 border-b border-border-soft bg-surface-muted/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-brand/10 text-brand rounded-xl flex items-center justify-center">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-text-main">Solicitações Pendentes</h2>
|
||||
<span className="ml-auto bg-brand text-white text-xs font-bold px-3 py-1 rounded-full">
|
||||
{requests.length} novos
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border-soft">
|
||||
{isLoading ? (
|
||||
<div className="p-20 flex justify-center">
|
||||
<div className="w-8 h-8 border-4 border-brand/30 border-t-brand rounded-full animate-spin" />
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="p-20 text-center text-text-muted font-medium">
|
||||
Nenhuma solicitação pendente no momento.
|
||||
</div>
|
||||
) : (
|
||||
requests.map((request) => (
|
||||
<motion.div
|
||||
key={request.userId}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="p-6 flex flex-col sm:flex-row items-center justify-between gap-6 hover:bg-surface-muted/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={request.photo || `https://ui-avatars.com/api/?name=${request.name}&background=random`}
|
||||
alt={request.name}
|
||||
className="w-12 h-12 rounded-full border border-border-soft object-cover"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="font-bold text-text-main text-lg">{request.name}</h3>
|
||||
<div className="flex items-center gap-2 text-text-muted text-sm font-medium">
|
||||
<Mail size={14} />
|
||||
{request.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<button
|
||||
onClick={() => handleAction(request.userId, false)}
|
||||
className="flex-1 sm:flex-none flex items-center justify-center gap-2 px-6 py-2.5 rounded-xl border border-border-soft text-text-muted font-bold hover:bg-danger/10 hover:text-danger hover:border-danger/20 transition-all"
|
||||
>
|
||||
<X size={18} />
|
||||
Recusar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction(request.userId, true)}
|
||||
className="flex-1 sm:flex-none flex items-center justify-center gap-2 px-6 py-2.5 rounded-xl bg-brand text-white font-bold hover:bg-brand-strong shadow-lg shadow-brand/20 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<Check size={18} />
|
||||
Aprovar
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,9 @@ import { TaskListPage } from "./pages/TaskListPage";
|
|||
import { TaskBoardPage } from "./pages/TaskBoardPage";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { RegisterPage } from "./pages/RegisterPage";
|
||||
import { CreateOrganizationPage } from "./pages/CreateOrganizationPage";
|
||||
import { JoinOrganizationPage } from "./pages/JoinOrganizationPage";
|
||||
import { OrganizationAdminPage } from "./pages/OrganizationAdminPage";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
|
|
@ -16,6 +19,14 @@ export const router = createBrowserRouter([
|
|||
path: "/register",
|
||||
element: <RegisterPage />,
|
||||
},
|
||||
{
|
||||
path: "/organizations/new",
|
||||
element: <CreateOrganizationPage />,
|
||||
},
|
||||
{
|
||||
path: "/organizations/join",
|
||||
element: <JoinOrganizationPage />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <AppShell />,
|
||||
|
|
@ -32,6 +43,10 @@ export const router = createBrowserRouter([
|
|||
path: "board",
|
||||
element: <TaskBoardPage />,
|
||||
},
|
||||
{
|
||||
path: "admin",
|
||||
element: <OrganizationAdminPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export type LoginResponse = {
|
|||
expiresIn: number // seconds
|
||||
issuedAt: string // ISO date
|
||||
organizations: {
|
||||
id: number
|
||||
name: string
|
||||
roles: string[]
|
||||
}[]
|
||||
|
|
@ -35,4 +36,8 @@ export const authService = {
|
|||
body: { refreshToken },
|
||||
})
|
||||
},
|
||||
|
||||
async me(): Promise<LoginResponse> {
|
||||
return apiFetch<LoginResponse>('/auth/me')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
frontend/src/services/organizationService.ts
Normal file
42
frontend/src/services/organizationService.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { apiFetch } from '../lib/api'
|
||||
|
||||
export interface Organization {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface JoinRequest {
|
||||
userId: number
|
||||
name: string
|
||||
email: string
|
||||
photo?: string
|
||||
}
|
||||
|
||||
export const organizationService = {
|
||||
create: async (name: string): Promise<Organization> => {
|
||||
return apiFetch('/organizations', {
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
})
|
||||
},
|
||||
|
||||
search: async (query: string): Promise<Organization[]> => {
|
||||
return apiFetch<Organization[]>(`/organizations/search?q=${query}`)
|
||||
},
|
||||
|
||||
requestJoin: async (id: number): Promise<void> => {
|
||||
return apiFetch(`/organizations/${id}/join`, { method: 'POST' })
|
||||
},
|
||||
|
||||
listPendingRequests: async (organizationId: number): Promise<JoinRequest[]> => {
|
||||
return apiFetch<JoinRequest[]>(`/organizations/${organizationId}/requests`)
|
||||
},
|
||||
|
||||
approveRequest: async (organizationId: number, userId: number): Promise<void> => {
|
||||
return apiFetch(`/organizations/${organizationId}/requests/${userId}/approve`, { method: 'PATCH' })
|
||||
},
|
||||
|
||||
rejectRequest: async (organizationId: number, userId: number): Promise<void> => {
|
||||
return apiFetch(`/organizations/${organizationId}/requests/${userId}/reject`, { method: 'PATCH' })
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue