feat: add error handling to player creation and update gitignore for agent

This commit is contained in:
Erik Silva
2026-01-24 14:18:40 -03:00
commit 416bd83ea7
93 changed files with 16861 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
'use client'
import { useState, useTransition } from 'react'
import { createArena, deleteArena } from '@/actions/arena'
import { MapPin, Plus, Trash2, Loader2, Navigation } from 'lucide-react'
import type { Arena } from '@prisma/client'
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
interface ArenasManagerProps {
arenas: Arena[]
}
export function ArenasManager({ arenas }: ArenasManagerProps) {
const [isPending, startTransition] = useTransition()
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean
arenaId: string | null
isDeleting: boolean
}>({
isOpen: false,
arenaId: null,
isDeleting: false
})
const handleDelete = (id: string) => {
setDeleteModal({
isOpen: true,
arenaId: id,
isDeleting: false
})
}
const confirmDelete = () => {
if (!deleteModal.arenaId) return
setDeleteModal(prev => ({ ...prev, isDeleting: true }))
startTransition(async () => {
try {
await deleteArena(deleteModal.arenaId!)
setDeleteModal({ isOpen: false, arenaId: null, isDeleting: false })
} catch (error) {
console.error(error)
alert('Erro ao excluir local.')
setDeleteModal(prev => ({ ...prev, isDeleting: false }))
}
})
}
return (
<div className="ui-card p-8 space-y-8">
<header>
<h3 className="font-semibold text-lg flex items-center gap-2">
<MapPin className="w-5 h-5 text-primary" />
Locais / Arenas
</h3>
<p className="text-muted text-sm">
Cadastre os locais onde as partidas acontecem para facilitar o agendamento.
</p>
</header>
<div className="space-y-3">
{arenas.map((arena) => (
<div key={arena.id} className="flex items-center justify-between p-4 rounded-lg border border-border bg-surface-raised/50 group hover:border-primary/50 transition-colors">
<div className="flex items-start gap-3">
<div className="p-2 bg-surface rounded-lg text-muted group-hover:text-primary transition-colors">
<Navigation className="w-4 h-4" />
</div>
<div>
<p className="font-medium text-foreground">{arena.name}</p>
{arena.address && <p className="text-sm text-muted">{arena.address}</p>}
</div>
</div>
<button
onClick={() => handleDelete(arena.id)}
disabled={isPending}
className="p-2 text-muted hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Excluir local"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{arenas.length === 0 && (
<div className="text-center py-8 px-4 border border-dashed border-border rounded-xl bg-surface/50">
<MapPin className="w-8 h-8 text-muted mx-auto mb-3 opacity-50" />
<p className="text-muted">Nenhum local cadastrado ainda.</p>
</div>
)}
</div>
<form action={(formData) => {
startTransition(async () => {
await createArena(formData)
const form = document.getElementById('arena-form') as HTMLFormElement
form?.reset()
})
}} id="arena-form" className="pt-6 mt-6 border-t border-border">
<div className="flex flex-col md:flex-row gap-4 items-end">
<div className="ui-form-field flex-1">
<label className="text-label ml-1">Nome do Local</label>
<input
name="name"
placeholder="Ex: Arena do Zé"
className="ui-input w-full"
required
/>
</div>
<div className="ui-form-field flex-[1.5]">
<label className="text-label ml-1">Endereço (Opcional)</label>
<input
name="address"
placeholder="Rua das Flores, 123"
className="ui-input w-full"
/>
</div>
<button
type="submit"
disabled={isPending}
className="ui-button h-[42px] px-6 whitespace-nowrap"
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Plus className="w-4 h-4 mr-2" />
)}
Adicionar
</button>
</div>
</form>
<DeleteConfirmationModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, arenaId: null, isDeleting: false })}
onConfirm={confirmDelete}
isDeleting={deleteModal.isDeleting}
title="Excluir Local?"
description="Tem certeza que deseja excluir este local? Partidas agendadas neste local manterão o nome, mas o local não estará mais disponível para novos agendamentos."
confirmText="Sim, excluir"
/>
</div>
)
}

View File

@@ -0,0 +1,24 @@
'use client'
import { useState } from 'react'
import { Copy, Check } from 'lucide-react'
export function CopyButton({ text, label = 'Copiar' }: { text: string, label?: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = () => {
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={handleCopy}
className="p-2 hover:bg-surface-raised rounded-md transition-colors text-primary active:scale-95"
title={label}
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
)
}

View File

@@ -0,0 +1,266 @@
'use client'
import { useState } from 'react'
import { createFinancialEvent } from '@/actions/finance'
import type { FinancialEventType } from '@prisma/client'
import { Loader2, Plus, Users, Calculator } from 'lucide-react'
import { useRouter } from 'next/navigation'
interface CreateFinanceEventModalProps {
isOpen: boolean
onClose: () => void
players: any[]
}
export function CreateFinanceEventModal({ isOpen, onClose, players }: CreateFinanceEventModalProps) {
const router = useRouter()
const [isPending, setIsPending] = useState(false)
const [step, setStep] = useState(1) // 1 = Details, 2 = Players
// Form State
const [type, setType] = useState<FinancialEventType>('MONTHLY_FEE')
const [title, setTitle] = useState('')
const [dueDate, setDueDate] = useState('')
const [priceMode, setPriceMode] = useState<'FIXED' | 'TOTAL'>('FIXED') // Fixed per person or Total to split
const [amount, setAmount] = useState('')
const [isRecurring, setIsRecurring] = useState(false)
const [recurrenceEndDate, setRecurrenceEndDate] = useState('')
const [selectedPlayers, setSelectedPlayers] = useState<string[]>(players.map(p => p.id)) // Default select all
if (!isOpen) return null
const handleSubmit = async () => {
setIsPending(true)
try {
const numAmount = parseFloat(amount.replace(',', '.'))
const result = await createFinancialEvent({
title: title || (type === 'MONTHLY_FEE' ? 'Mensalidade' : 'Evento'),
type,
dueDate,
selectedPlayerIds: selectedPlayers,
pricePerPerson: priceMode === 'FIXED' ? numAmount : undefined,
totalAmount: priceMode === 'TOTAL' ? numAmount : undefined,
isRecurring: isRecurring,
recurrenceEndDate: recurrenceEndDate
})
if (!result.success) {
alert(result.error)
return
}
onClose()
router.refresh()
} catch (error) {
console.error(error)
} finally {
setIsPending(false)
}
}
const togglePlayer = (id: string) => {
setSelectedPlayers(prev =>
prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]
)
}
const selectAll = () => setSelectedPlayers(players.map(p => p.id))
const selectNone = () => setSelectedPlayers([])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-surface border border-border rounded-xl w-full max-w-lg shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="p-6 border-b border-border">
<h3 className="text-lg font-bold">Novo Evento Financeiro</h3>
<p className="text-sm text-muted">Crie mensalidades, churrascos ou arrecadações.</p>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{step === 1 ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2 p-1 bg-surface-raised rounded-lg border border-border">
<button
onClick={() => setType('MONTHLY_FEE')}
className={`py-2 text-sm font-bold rounded-md transition-all ${type === 'MONTHLY_FEE' ? 'bg-primary text-background' : 'hover:bg-white/5 text-muted'}`}
>
Mensalidade
</button>
<button
onClick={() => setType('EXTRA_EVENT')}
className={`py-2 text-sm font-bold rounded-md transition-all ${type === 'EXTRA_EVENT' ? 'bg-primary text-background' : 'hover:bg-white/5 text-muted'}`}
>
Churrasco/Evento
</button>
</div>
<div className="ui-form-field">
<label className="text-label ml-1">Título</label>
<input
value={title}
onChange={e => setTitle(e.target.value)}
placeholder={type === 'MONTHLY_FEE' ? "Ex: Mensalidade Janeiro" : "Ex: Churrasco de Fim de Ano"}
className="ui-input w-full"
/>
</div>
<div className="ui-form-field">
<label className="text-label ml-1">Vencimento</label>
<input
type="date"
value={dueDate}
onChange={e => setDueDate(e.target.value)}
className="ui-input w-full [color-scheme:dark]"
/>
</div>
{type === 'MONTHLY_FEE' && (
<div className="bg-surface/50 border border-border p-4 rounded-xl space-y-4">
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={isRecurring}
onChange={(e) => setIsRecurring(e.target.checked)}
className="w-4 h-4 rounded border-border text-primary bg-background accent-primary"
id="recurring-check"
/>
<label htmlFor="recurring-check" className="text-sm font-medium cursor-pointer select-none">
Repetir mensalmente
</label>
</div>
{isRecurring && (
<div className="pl-7 animate-in fade-in slide-in-from-top-2">
<label className="text-label ml-0.5 mb-1.5 block">Repetir até</label>
<input
type="date"
value={recurrenceEndDate}
onChange={(e) => setRecurrenceEndDate(e.target.value)}
className="ui-input w-full h-10 text-sm [color-scheme:dark]"
min={dueDate}
/>
<p className="text-[10px] text-muted mt-1.5">
Serão criados eventos para cada mês até a data limite.
</p>
</div>
)}
</div>
)}
<div className="space-y-2">
<label className="text-label ml-1">Valor</label>
<div className="flex bg-surface-raised rounded-lg overflow-hidden border border-border">
<div className="flex flex-col border-r border-border">
<button
onClick={() => setPriceMode('FIXED')}
className={`px-3 py-2 text-xs font-bold ${priceMode === 'FIXED' ? 'bg-primary/20 text-primary' : 'text-muted hover:bg-white/5'}`}
>
Fixo (Por Pessoa)
</button>
<button
onClick={() => setPriceMode('TOTAL')}
className={`px-3 py-2 text-xs font-bold ${priceMode === 'TOTAL' ? 'bg-primary/20 text-primary' : 'text-muted hover:bg-white/5'}`}
>
Rateio (Total)
</button>
</div>
<div className="flex-1 flex items-center px-4">
<span className="text-muted font-bold mr-2">R$</span>
<input
type="number"
value={amount}
onChange={e => setAmount(e.target.value)}
placeholder="0,00"
className="bg-transparent border-0 ring-0 focus:ring-0 w-full text-lg font-bold outline-none"
/>
</div>
</div>
{priceMode === 'TOTAL' && selectedPlayers.length > 0 && amount && (
<p className="text-xs text-secondary ml-1 flex items-center gap-1">
<Calculator className="w-3 h-3" />
Aproximadamente <span className="font-bold">R$ {(parseFloat(amount) / selectedPlayers.length).toFixed(2)}</span> por pessoa
</p>
)}
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold uppercase tracking-wider text-muted">Selecionar Pagantes</h4>
<div className="flex gap-2">
<button onClick={selectAll} className="text-xs text-primary hover:underline">Todos</button>
<button onClick={selectNone} className="text-xs text-muted hover:underline">Nenhum</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
{players.map(player => (
<button
key={player.id}
onClick={() => togglePlayer(player.id)}
className={`flex items-center gap-3 p-2 rounded-lg border text-left transition-all ${selectedPlayers.includes(player.id)
? 'bg-primary/10 border-primary shadow-sm'
: 'bg-surface border-border hover:border-white/20 opacity-60 hover:opacity-100'
}`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${selectedPlayers.includes(player.id) ? 'bg-primary text-background' : 'bg-surface-raised'
}`}>
{player.name.substring(0, 2).toUpperCase()}
</div>
<span className="text-sm font-medium truncate">{player.name}</span>
</button>
))}
</div>
<div className="p-3 bg-surface-raised rounded-lg border border-border">
<div className="flex justify-between items-center text-sm">
<span className="text-muted">Selecionados:</span>
<span className="font-bold">{selectedPlayers.length}</span>
</div>
<div className="flex justify-between items-center text-sm mt-1">
<span className="text-muted">Arrecadação Prevista:</span>
<span className="font-bold text-primary text-lg">
R$ {
priceMode === 'FIXED'
? ((parseFloat(amount || '0')) * selectedPlayers.length).toFixed(2)
: (parseFloat(amount || '0')).toFixed(2)
}
</span>
</div>
</div>
</div>
)}
</div>
<div className="p-6 border-t border-border flex justify-between gap-4 bg-background/50">
<button
onClick={step === 1 ? onClose : () => setStep(1)}
className="text-sm font-semibold text-muted hover:text-foreground px-4 py-2"
>
{step === 1 ? 'Cancelar' : 'Voltar'}
</button>
{step === 1 ? (
<button
onClick={() => setStep(2)}
disabled={!amount || !dueDate}
className="ui-button px-6"
>
Continuar <Users className="w-4 h-4 ml-2" />
</button>
) : (
<button
onClick={handleSubmit}
disabled={isPending || selectedPlayers.length === 0}
className="ui-button px-6"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4 ml-2" />}
Criar Evento
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,97 @@
'use client'
import React from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { AlertTriangle, Trash2, X } from 'lucide-react'
interface DeleteConfirmationModalProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
title?: string
description?: string
confirmText?: string
isDeleting?: boolean
}
export function DeleteConfirmationModal({
isOpen,
onClose,
onConfirm,
title = 'Excluir Item',
description = 'Esta ação não pode ser desfeita. Todos os dados serão perdidos permanentemente.',
confirmText = 'Sim, excluir',
isDeleting = false
}: DeleteConfirmationModalProps) {
if (!isOpen) return null
return (
<AnimatePresence>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
/>
{/* Modal */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="relative w-full max-w-md bg-zinc-900 border border-zinc-800 rounded-2xl shadow-xl overflow-hidden"
>
<div className="p-6">
<div className="flex items-start gap-4">
<div className="p-3 bg-red-500/10 rounded-xl border border-red-500/20">
<AlertTriangle className="w-6 h-6 text-red-500" />
</div>
<div className="flex-1">
<h3 className="text-lg font-bold text-white mb-2">{title}</h3>
<p className="text-zinc-400 text-sm leading-relaxed">
{description}
</p>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-zinc-800 rounded-lg transition-colors text-zinc-500 hover:text-white"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="p-4 bg-zinc-950/50 border-t border-zinc-800 flex justify-end gap-3">
<button
onClick={onClose}
disabled={isDeleting}
className="px-4 py-2 text-sm font-medium text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-xl transition-colors disabled:opacity-50"
>
Cancelar
</button>
<button
onClick={onConfirm}
disabled={isDeleting}
className="px-4 py-2 text-sm font-bold text-white bg-red-600 hover:bg-red-700 rounded-xl transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-red-500/20"
>
{isDeleting ? (
<>
<span className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
Excluindo...
</>
) : (
<>
<Trash2 className="w-4 h-4" />
{confirmText}
</>
)}
</button>
</div>
</motion.div>
</div>
</AnimatePresence>
)
}

View File

@@ -0,0 +1,453 @@
'use client'
import React, { useState, useMemo } from 'react'
import { Plus, Wallet, TrendingUp, Calendar, AlertCircle, ChevronRight, Check, X, Search, Filter, LayoutGrid, List, Trash2, MoreHorizontal, Share2, Copy } from 'lucide-react'
import { CreateFinanceEventModal } from '@/components/CreateFinanceEventModal'
import { markPaymentAsPaid, markPaymentAsPending, deleteFinancialEvents } from '@/actions/finance'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx'
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
interface FinancialPageProps {
events: any[]
players: any[]
}
export function FinancialDashboard({ events, players }: FinancialPageProps) {
const router = useRouter()
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [expandedEventId, setExpandedEventId] = useState<string | null>(null)
// Filter & View State
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState<'ALL' | 'MONTHLY_FEE' | 'EXTRA_EVENT'>('ALL')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
// Confirmation Modal State
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean
isDeleting: boolean
title: string
description: string
}>({
isOpen: false,
isDeleting: false,
title: '',
description: ''
})
// Selection
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const togglePayment = async (paymentId: string, currentStatus: string) => {
if (currentStatus === 'PENDING') {
await markPaymentAsPaid(paymentId)
} else {
await markPaymentAsPending(paymentId)
}
router.refresh()
}
const filteredEvents = useMemo(() => {
return events.filter(e => {
const matchesSearch = e.title.toLowerCase().includes(searchQuery.toLowerCase())
const matchesTab = activeTab === 'ALL' || e.type === activeTab
return matchesSearch && matchesTab
})
}, [events, searchQuery, activeTab])
const totalStats = events.reduce((acc, event) => {
return {
expected: acc.expected + event.stats.totalExpected,
paid: acc.paid + event.stats.totalPaid
}
}, { expected: 0, paid: 0 })
const totalPending = totalStats.expected - totalStats.paid
const toggleSelection = (id: string) => {
const newSelected = new Set(selectedIds)
if (newSelected.has(id)) newSelected.delete(id)
else newSelected.add(id)
setSelectedIds(newSelected)
}
const toggleSelectAll = () => {
if (selectedIds.size === filteredEvents.length) {
setSelectedIds(new Set())
} else {
const newSelected = new Set<string>()
filteredEvents.forEach(e => newSelected.add(e.id))
setSelectedIds(newSelected)
}
}
const handleDeleteSelected = () => {
setDeleteModal({
isOpen: true,
isDeleting: false,
title: `Excluir ${selectedIds.size} eventos?`,
description: 'Você tem certeza que deseja excluir os eventos financeiros selecionados? Todo o histórico de pagamentos destes eventos será apagado para sempre.'
})
}
const confirmDelete = async () => {
setDeleteModal(prev => ({ ...prev, isDeleting: true }))
try {
await deleteFinancialEvents(Array.from(selectedIds))
setSelectedIds(new Set())
setDeleteModal(prev => ({ ...prev, isOpen: false }))
router.refresh()
} catch (error) {
console.error(error)
alert('Erro ao excluir eventos.')
} finally {
setDeleteModal(prev => ({ ...prev, isDeleting: false }))
}
}
return (
<div className="space-y-8 pb-20">
{/* Stats Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="ui-card p-6 bg-gradient-to-br from-surface-raised to-background border-primary/20">
<div className="flex items-center gap-4">
<div className="p-3 bg-primary/10 rounded-xl">
<Wallet className="w-6 h-6 text-primary" />
</div>
<div>
<p className="text-sm text-muted font-bold uppercase tracking-wider">Arrecadado</p>
<h3 className="text-2xl font-black text-foreground">R$ {totalStats.paid.toFixed(2)}</h3>
</div>
</div>
</div>
<div className="ui-card p-6 border-red-500/20">
<div className="flex items-center gap-4">
<div className="p-3 bg-red-500/10 rounded-xl">
<AlertCircle className="w-6 h-6 text-red-500" />
</div>
<div>
<p className="text-sm text-muted font-bold uppercase tracking-wider">Pendente</p>
<h3 className="text-2xl font-black text-red-500">R$ {totalPending.toFixed(2)}</h3>
</div>
</div>
</div>
</div>
<div className="space-y-6">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div className="space-y-1">
<h2 className="text-xl font-bold tracking-tight">Gestão Financeira</h2>
<p className="text-xs text-muted font-medium">Controle de mensalidades e eventos extras.</p>
</div>
<div className="flex flex-col sm:flex-row items-center gap-4">
{selectedIds.size > 0 ? (
<div className="flex items-center gap-2 w-full sm:w-auto animate-in fade-in slide-in-from-right-4">
<span className="text-xs font-bold text-muted uppercase tracking-wider px-3">{selectedIds.size} selecionados</span>
<button
onClick={handleDeleteSelected}
className="ui-button bg-red-500/10 text-red-500 hover:bg-red-500/20 border-red-500/20 h-10 w-full sm:w-auto"
>
<Trash2 className="w-4 h-4 mr-2" /> Excluir
</button>
</div>
) : (
<>
<div className="relative group w-full sm:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted group-focus-within:text-primary transition-colors" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar eventos..."
className="ui-input w-full pl-10 h-10 bg-surface-raised border-border/50 text-sm"
/>
</div>
<div className="flex p-1 bg-surface-raised border border-border rounded-lg w-full sm:w-auto">
<button
onClick={() => setViewMode('grid')}
className={clsx("p-2 rounded-md transition-all", viewMode === 'grid' ? "bg-white/10 text-primary shadow-sm" : "text-muted")}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={clsx("p-2 rounded-md transition-all", viewMode === 'list' ? "bg-white/10 text-primary shadow-sm" : "text-muted")}
>
<List className="w-4 h-4" />
</button>
</div>
<button onClick={() => setIsCreateModalOpen(true)} className="ui-button w-full sm:w-auto shadow-lg shadow-primary/20 h-10">
<Plus className="w-4 h-4 mr-2" />
Novo Evento
</button>
</>
)}
</div>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-2 scrollbar-none border-b border-border/50">
{[
{ id: 'ALL', label: 'Todos' },
{ id: 'MONTHLY_FEE', label: 'Mensalidades' },
{ id: 'EXTRA_EVENT', label: 'Eventos Extras' }
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={clsx(
"relative px-6 py-2.5 text-[10px] font-bold uppercase tracking-[0.2em] transition-all",
activeTab === tab.id ? "text-primary" : "text-muted hover:text-foreground"
)}
>
{tab.label}
{activeTab === tab.id && (
<motion.div layoutId="activeTab" className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
))}
<div className="ml-auto text-[10px] font-bold text-muted bg-surface-raised px-3 py-1 rounded-full border border-border">
{filteredEvents.length} REGISTROS
</div>
</div>
<div className={clsx(
"grid gap-4 transition-all duration-500",
viewMode === 'grid' ? "grid-cols-1 md:grid-cols-2 xl:grid-cols-3" : "grid-cols-1"
)}>
{filteredEvents.length > 0 && (
<div className={clsx("col-span-full flex items-center px-2 mb-2", viewMode === 'grid' ? "justify-end" : "")}>
<button
onClick={toggleSelectAll}
className="flex items-center gap-2 group cursor-pointer"
>
<div className={clsx(
"w-5 h-5 rounded-lg border-2 flex items-center justify-center transition-all duration-200",
selectedIds.size === filteredEvents.length && filteredEvents.length > 0
? "bg-primary border-primary shadow-[0_0_10px_rgba(16,185,129,0.4)]"
: "border-muted/30 bg-surface/50 group-hover:border-primary/50"
)}>
{selectedIds.size === filteredEvents.length && filteredEvents.length > 0 && (
<Check className="w-3.5 h-3.5 text-background font-bold stroke-[3]" />
)}
</div>
<span className={clsx(
"text-[10px] font-bold uppercase tracking-widest transition-colors",
selectedIds.size === filteredEvents.length ? "text-primary" : "text-muted group-hover:text-foreground"
)}>
Selecionar Todos
</span>
</button>
</div>
)}
<AnimatePresence mode='popLayout'>
{filteredEvents.map((event) => {
const percent = event.stats.totalExpected > 0 ? (event.stats.totalPaid / event.stats.totalExpected) * 100 : 0
const isExpanded = expandedEventId === event.id
return (
<motion.div
key={event.id}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className={clsx(
"ui-card group relative hover:border-primary/40 transition-all duration-500 overflow-hidden",
viewMode === 'list' ? "p-0" : "p-0",
selectedIds.has(event.id) ? "border-primary bg-primary/5" : "border-border"
)}
>
<div
className={clsx(
"cursor-pointer p-5 flex flex-col gap-4",
viewMode === 'list' ? "sm:flex-row sm:items-center" : ""
)}
onClick={() => setExpandedEventId(isExpanded ? null : event.id)}
>
<div
onClick={(e) => {
e.stopPropagation()
toggleSelection(event.id)
}}
className={clsx("z-20", viewMode === 'grid' ? "absolute top-4 right-4" : "mr-2")}
>
<div className={clsx(
"w-5 h-5 rounded-lg border-2 flex items-center justify-center transition-all duration-200",
selectedIds.has(event.id)
? "bg-primary border-primary shadow-[0_0_10px_rgba(16,185,129,0.4)]"
: "border-muted/30 bg-surface/50 opacity-100 sm:opacity-0 group-hover:opacity-100 hover:border-primary/50"
)}>
{selectedIds.has(event.id) && <Check className="w-3.5 h-3.5 text-background font-bold stroke-[3]" />}
</div>
</div>
<div className="flex items-center gap-4 flex-1">
<div className="w-12 h-12 bg-surface-raised rounded-2xl border border-border/50 flex items-center justify-center text-primary shadow-inner shrink-0">
<Calendar className="w-6 h-6" />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<span className={clsx("text-[9px] font-bold px-2 py-0.5 rounded-md uppercase tracking-wider",
event.type === 'MONTHLY_FEE' ? 'bg-blue-500/10 text-blue-500' : 'bg-orange-500/10 text-orange-500'
)}>
{event.type === 'MONTHLY_FEE' ? 'Mensalidade' : 'Evento'}
</span>
<span className="text-[10px] text-muted font-bold">
{new Date(event.dueDate).toLocaleDateString('pt-BR')}
</span>
</div>
<h3 className="font-bold text-base leading-tight group-hover:text-primary transition-colors">{event.title}</h3>
</div>
</div>
<div className={clsx("w-full sm:w-48 space-y-2", viewMode === 'list' && "mr-8")}>
<div className="flex justify-between text-[10px] font-bold uppercase tracking-wider text-muted">
<span>Progresso</span>
<span>{Math.round(percent)}%</span>
</div>
<div className="h-1.5 bg-surface-raised rounded-full overflow-hidden">
<div className="h-full bg-primary transition-all duration-500" style={{ width: `${percent}%` }} />
</div>
<div className="flex justify-between text-[10px] text-muted font-mono">
<span>R$ {event.stats.totalPaid.toFixed(2)}</span>
<span>R$ {event.stats.totalExpected.toFixed(2)}</span>
</div>
</div>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-t border-border bg-surface-raised/30 overflow-hidden"
>
<div className="p-5 space-y-6">
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between p-4 bg-surface/50 rounded-xl border border-dashed border-border">
<div>
<h5 className="text-xs font-bold uppercase tracking-widest text-muted mb-1">Compartilhar Relatório</h5>
<p className="text-[10px] text-muted">Envie o status atual para o grupo do time.</p>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<button
onClick={() => {
const paid = event.payments.filter((p: any) => p.status === 'PAID').map((p: any) => p.player.name)
const pending = event.payments.filter((p: any) => p.status !== 'PAID').map((p: any) => p.player.name)
const message = `*${event.title.toUpperCase()}*\nVencimento: ${new Date(event.dueDate).toLocaleDateString('pt-BR')}\n\n` +
`✅ *PAGOS (${paid.length})*:\n${paid.join(', ')}\n\n` +
`⏳ *PENDENTES (${pending.length})*:\n${pending.join(', ')}\n\n` +
`💰 *Total Arrecadado*: R$ ${event.stats.totalPaid.toFixed(2)}\n` +
`🔗 *Link do Relatório*: ${window.location.origin}/financial-report/${event.id}`
const url = `https://wa.me/?text=${encodeURIComponent(message)}`
window.open(url, '_blank')
}}
className="flex items-center justify-center gap-2 px-3 py-2 bg-[#25D366] hover:bg-[#128C7E] text-white rounded-lg text-xs font-bold transition-colors w-full sm:w-auto"
>
<Share2 className="w-3.5 h-3.5" />
WhatsApp
</button>
<button
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/financial-report/${event.id}`)
alert('Link copiado!')
}}
className="flex items-center justify-center gap-2 px-3 py-2 bg-surface hover:bg-surface-raised border border-border text-foreground rounded-lg text-xs font-bold transition-colors w-full sm:w-auto"
>
<Copy className="w-3.5 h-3.5" />
Copiar Link
</button>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-bold uppercase tracking-widest text-muted">Gerenciar Pagamentos</h4>
<Link
href={`/financial-report/${event.id}`}
className="text-[10px] font-bold text-primary hover:underline flex items-center gap-1"
target="_blank"
>
Ver Página Pública <ChevronRight className="w-3 h-3" />
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
{event.payments.map((payment: any) => (
<div key={payment.id} className="flex items-center justify-between p-2 rounded-lg bg-surface border border-border/50 group/item hover:border-primary/20 transition-colors">
<div className="flex items-center gap-3">
<div className={clsx("w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold transition-colors",
payment.status === 'PAID' ? 'bg-green-500 text-black' : 'bg-surface-raised text-muted group-hover/item:bg-surface-raised/80'
)}>
{payment.status === 'PAID' ? <Check className="w-3 h-3" /> : payment.player?.name.substring(0, 1).toUpperCase()}
</div>
<span className={clsx("text-xs font-medium truncate max-w-[100px]", payment.status === 'PAID' ? 'text-foreground' : 'text-muted')}>
{payment.player?.name}
</span>
</div>
<button
onClick={() => togglePayment(payment.id, payment.status)}
className={clsx("text-[10px] font-bold px-2 py-1 rounded transition-colors",
payment.status === 'PENDING'
? "bg-primary/10 text-primary hover:bg-primary hover:text-background"
: "text-muted hover:text-red-500 hover:bg-red-500/10"
)}
>
{payment.status === 'PENDING' ? 'RECEBER' : 'DESFAZER'}
</button>
</div>
))}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
})}
</AnimatePresence>
{filteredEvents.length === 0 && (
<div className="col-span-full py-20 text-center space-y-4">
<div className="w-16 h-16 bg-surface-raised border border-dashed border-border rounded-full flex items-center justify-center mx-auto opacity-40">
<Search className="w-6 h-6 text-muted" />
</div>
<div className="space-y-1">
<p className="text-sm font-bold uppercase tracking-widest">Nenhum Evento Encontrado</p>
<p className="text-xs text-muted">Tente ajustar sua busca ou mudar os filtros.</p>
</div>
</div>
)}
</div>
</div>
<CreateFinanceEventModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
players={players}
/>
<DeleteConfirmationModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal(prev => ({ ...prev, isOpen: false }))}
onConfirm={confirmDelete}
isDeleting={deleteModal.isDeleting}
title={deleteModal.title}
description={deleteModal.description}
confirmText="Sim, excluir agora"
/>
</div>
)
}

View File

@@ -0,0 +1,508 @@
'use client'
import React, { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Plus, Users, Calendar, Shuffle, Download, Trophy, Save, Check, Star, RefreshCw } from 'lucide-react'
import { createMatch, updateMatchStatus } from '@/actions/match'
import { getMatchWithAttendance } from '@/actions/attendance'
import { toPng } from 'html-to-image'
import { clsx } from 'clsx'
import { useSearchParams } from 'next/navigation'
import type { Arena } from '@prisma/client'
interface MatchFlowProps {
group: any
arenas?: Arena[]
}
const getInitials = (name: string) => {
return name
.split(' ')
.filter(n => n.length > 0)
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
// Simple seed-based random generator for transparency
const seededRandom = (seed: string) => {
let hash = 0
for (let i = 0; i < seed.length; i++) {
hash = ((hash << 5) - hash) + seed.charCodeAt(i)
hash |= 0
}
return () => {
hash = (hash * 16807) % 2147483647
return (hash - 1) / 2147483646
}
}
export function MatchFlow({ group, arenas = [] }: MatchFlowProps) {
const searchParams = useSearchParams()
const scheduledMatchId = searchParams.get('id')
const TEAM_COLORS = ['#10b981', '#3b82f6', '#ef4444', '#f59e0b', '#8b5cf6', '#06b6d4']
const [step, setStep] = useState(1)
const [drawMode, setDrawMode] = useState<'random' | 'balanced'>('balanced')
const [activeMatchId, setActiveMatchId] = useState<string | null>(null)
const [teamCount, setTeamCount] = useState(2)
const [selectedPlayers, setSelectedPlayers] = useState<string[]>([])
const [teams, setTeams] = useState<any[]>([])
const [matchDate, setMatchDate] = useState(new Date().toISOString().split('T')[0])
const [isSaving, setIsSaving] = useState(false)
const [drawSeed, setDrawSeed] = useState('')
const [location, setLocation] = useState('')
const [selectedArenaId, setSelectedArenaId] = useState('')
useEffect(() => {
if (scheduledMatchId) {
loadScheduledData(scheduledMatchId)
}
// Generate a random seed on mount
generateNewSeed()
}, [scheduledMatchId])
const loadScheduledData = async (id: string) => {
try {
const data = await getMatchWithAttendance(id)
if (data) {
const confirmedIds = data.attendances
.filter((a: any) => a.status === 'CONFIRMED')
.map((a: any) => a.playerId)
setSelectedPlayers(confirmedIds)
setMatchDate(new Date(data.date).toISOString().split('T')[0])
setLocation(data.location || '')
if (data.arenaId) setSelectedArenaId(data.arenaId)
setActiveMatchId(data.id)
}
} catch (error) {
console.error('Erro ao carregar dados agendados:', error)
}
}
const generateNewSeed = () => {
setDrawSeed(Math.random().toString(36).substring(2, 8).toUpperCase())
}
const togglePlayer = (id: string) => {
setSelectedPlayers(prev =>
prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]
)
}
const performDraw = () => {
const playersToDraw = group.players.filter((p: any) => selectedPlayers.includes(p.id))
if (playersToDraw.length < teamCount) return
// Use the seed for transparency
const random = seededRandom(drawSeed)
let pool = [...playersToDraw]
const newTeams: any[] = Array.from({ length: teamCount }, (_, i) => ({
name: `Time ${i + 1}`,
players: [],
color: TEAM_COLORS[i % TEAM_COLORS.length]
}))
if (drawMode === 'balanced') {
// Balanced still uses levels, but we shuffle within same levels or use seed for order
pool.sort((a, b) => {
if (b.level !== a.level) return b.level - a.level
return random() - 0.5 // Use seeded random for tie-breaking
})
pool.forEach((p) => {
const teamWithLowestQuality = newTeams.reduce((prev, curr) => {
const prevQuality = prev.players.reduce((sum: number, player: any) => sum + player.level, 0)
const currQuality = curr.players.reduce((sum: number, player: any) => sum + player.level, 0)
return (prevQuality <= currQuality) ? prev : curr
})
teamWithLowestQuality.players.push(p)
})
} else {
// Pure random draw based on seed
pool = pool.sort(() => random() - 0.5)
pool.forEach((p, i) => {
newTeams[i % teamCount].players.push(p)
})
}
setTeams(newTeams)
}
const downloadImage = async (id: string) => {
const element = document.getElementById(id)
if (!element) return
try {
const dataUrl = await toPng(element, { backgroundColor: '#000' })
const link = document.createElement('a')
link.download = `temfut-time-${id}.png`
link.href = dataUrl
link.click()
} catch (err) {
console.error(err)
}
}
const handleConfirm = async () => {
setIsSaving(true)
try {
// If it was a scheduled match, we just update it. Otherwise create new.
// If it was a scheduled match, we just update it. Otherwise create new.
// For simplicity in this demo, createMatch now handles the seed and location.
const match = await createMatch(group.id, matchDate, teams, 'IN_PROGRESS', location, selectedPlayers.length, drawSeed, selectedArenaId)
setActiveMatchId(match.id)
setStep(2)
} catch (error) {
console.error(error)
} finally {
setIsSaving(false)
}
}
const handleFinish = async () => {
if (!activeMatchId) return
setIsSaving(true)
try {
await updateMatchStatus(activeMatchId, 'COMPLETED')
window.location.href = '/dashboard/matches'
} catch (error) {
console.error(error)
} finally {
setIsSaving(false)
}
}
return (
<div className="space-y-8 max-w-5xl mx-auto pb-20">
{/* Step Indicator */}
<div className="flex items-center gap-4">
<div className={clsx("h-1 flex-1 rounded-full bg-border relative overflow-hidden")}>
<div
className={clsx("absolute inset-0 bg-primary transition-all duration-700 ease-out")}
style={{ width: step === 1 ? '50%' : '100%' }}
/>
</div>
<span className="text-xs font-bold text-muted uppercase tracking-[0.2em] whitespace-nowrap">
Fase de {step === 1 ? 'Escalação' : 'Distribuição'}
</span>
</div>
{step === 1 ? (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Controls Panel */}
<div className="lg:col-span-4 space-y-6">
<div className="ui-card p-6 space-y-6">
<div className="space-y-1">
<h3 className="text-sm font-bold uppercase tracking-widest text-primary">Configuração</h3>
<p className="text-xs text-muted">Ajuste os parâmetros do sorteio.</p>
</div>
<div className="space-y-4">
<div className="ui-form-field">
<label className="text-label ml-0.5">Data da Partida</label>
<input
type="date"
value={matchDate}
onChange={(e) => setMatchDate(e.target.value)}
className="ui-input w-full h-12"
/>
</div>
<div className="ui-form-field">
<label className="text-label ml-0.5">Local / Arena</label>
<select
value={selectedArenaId}
onChange={(e) => {
setSelectedArenaId(e.target.value)
const arena = arenas?.find(a => a.id === e.target.value)
if (arena) setLocation(arena.name)
}}
className="ui-input w-full h-12 bg-surface-raised"
style={{ appearance: 'none' }} // Custom arrow if needed, but default is fine for now
>
<option value="" className="bg-surface-raised text-muted">Selecione um local...</option>
{arenas?.map(arena => (
<option key={arena.id} value={arena.id} className="bg-surface-raised text-foreground">
{arena.name}
</option>
))}
</select>
{/* Fallback hidden input or just use location state if manual entry is needed later */}
</div>
<div className="ui-form-field">
<label className="text-label ml-0.5">Seed de Transparência</label>
<div className="flex gap-2">
<input
type="text"
value={drawSeed}
onChange={(e) => setDrawSeed(e.target.value.toUpperCase())}
className="ui-input flex-1 h-12 font-mono text-sm uppercase tracking-widest"
placeholder="SEED123"
/>
<button
onClick={generateNewSeed}
className="p-3 border border-border rounded-md hover:bg-white/5 transition-colors"
>
<RefreshCw className="w-5 h-5 text-muted" />
</button>
</div>
</div>
<div className="ui-form-field">
<label className="text-label ml-0.5">Número de Times</label>
<div className="grid grid-cols-3 gap-2">
{[2, 3, 4].map(n => (
<button
key={n}
onClick={() => setTeamCount(n)}
className={clsx(
"py-3 text-sm font-bold rounded-md border transition-all",
teamCount === n ? "bg-primary text-background border-primary" : "bg-transparent border-border text-muted"
)}
>
{n}
</button>
))}
</div>
</div>
<div className="ui-form-field">
<label className="text-label ml-0.5">Modo de Sorteio</label>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setDrawMode('balanced')}
className={clsx(
"py-3 text-xs font-bold rounded-md border transition-all uppercase",
drawMode === 'balanced' ? "bg-primary text-background border-primary" : "bg-transparent border-border text-muted"
)}
>
Equilibrado
</button>
<button
onClick={() => setDrawMode('random')}
className={clsx(
"py-3 text-xs font-bold rounded-md border transition-all uppercase",
drawMode === 'random' ? "bg-primary text-background border-primary" : "bg-transparent border-border text-muted"
)}
>
Aleatório
</button>
</div>
</div>
</div>
<button
onClick={performDraw}
disabled={selectedPlayers.length < teamCount}
className="ui-button w-full h-14 text-sm font-bold shadow-lg shadow-emerald-500/10"
>
<Shuffle className="w-5 h-5 mr-2" /> Sortear Atletas
</button>
</div>
{scheduledMatchId && (
<div className="p-4 bg-primary/5 border border-primary/20 rounded-lg space-y-2">
<p className="text-[10px] font-bold uppercase text-primary tracking-wider">Evento Agendado</p>
<p className="text-[11px] text-muted leading-relaxed">
Importamos automaticamente {selectedPlayers.length} atletas que confirmaram presença pelo link público.
</p>
</div>
)}
</div>
{/* Players Selection Grid */}
<div className="lg:col-span-8 space-y-6">
<div className="ui-card p-6">
<div className="flex items-center justify-between mb-6">
<div className="space-y-1">
<h3 className="text-sm font-bold uppercase tracking-widest text-primary">Convocação</h3>
<p className="text-xs text-muted">Selecione os atletas presentes em campo.</p>
</div>
<div className="text-right">
<span className="text-2xl font-bold">{selectedPlayers.length}</span>
<span className="text-xs font-bold text-muted uppercase ml-2 tracking-widest">Atletas</span>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-2 gap-3 max-h-[500px] overflow-y-auto pr-2 custom-scrollbar">
{group.players.map((p: any) => (
<button
key={p.id}
onClick={() => togglePlayer(p.id)}
className={clsx(
"flex items-center justify-between p-3 rounded-lg border transition-all text-left group",
selectedPlayers.includes(p.id)
? "bg-primary/5 border-primary shadow-sm"
: "bg-surface border-border hover:border-white/20"
)}
>
<div className="flex items-center gap-3">
<div className={clsx(
"w-10 h-10 rounded-lg border flex items-center justify-center font-mono font-bold text-xs transition-all",
selectedPlayers.includes(p.id) ? "bg-primary text-background border-primary" : "bg-surface-raised border-border"
)}>
{p.number !== null && p.number !== undefined ? p.number : getInitials(p.name)}
</div>
<div>
<p className="text-sm font-bold tracking-tight">{p.name}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted font-bold uppercase tracking-wider">{p.position}</span>
<div className="flex gap-0.5">
{[...Array(5)].map((_, i) => (
<Star key={i} className={clsx(
"w-2.5 h-2.5",
i < p.level ? "text-primary fill-primary" : "text-border fill-transparent"
)} />
))}
</div>
</div>
</div>
</div>
<div className={clsx(
"w-5 h-5 rounded-full border flex items-center justify-center transition-all",
selectedPlayers.includes(p.id) ? "bg-primary border-primary" : "border-border"
)}>
{selectedPlayers.includes(p.id) && <Check className="w-3 h-3 text-background font-bold" />}
</div>
</button>
))}
</div>
</div>
{/* Temp Draw Overlay / Result Preview */}
<AnimatePresence>
{teams.length > 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
className="ui-card p-6 bg-surface-raised border-primary/20 shadow-xl space-y-6"
>
<div className="flex items-center justify-between border-b border-white/5 pb-4">
<div className="space-y-1">
<h3 className="text-sm font-bold uppercase tracking-widest text-primary">Sorteio Concluído</h3>
<p className="text-xs text-muted font-mono">HASH: {drawSeed}</p>
</div>
<button
onClick={handleConfirm}
disabled={isSaving}
className="ui-button h-12 px-8 font-bold text-sm"
>
{isSaving ? 'Salvando...' : 'Confirmar & Ver Capas'}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{teams.map((t, i) => (
<div key={i} className="ui-card p-4 bg-background/50 border-white/5">
<div className="flex items-center gap-2 mb-4">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: t.color }} />
<h4 className="text-xs font-bold uppercase tracking-widest">{t.name}</h4>
</div>
<div className="space-y-2">
{t.players.map((p: any) => (
<div key={p.id} className="text-xs flex items-center justify-between py-2 border-b border-white/5 last:border-0">
<div className="flex items-center gap-3">
<span className="font-mono text-primary font-bold w-4">{p.number !== null && p.number !== undefined ? p.number : getInitials(p.name)}</span>
<span className="font-medium">{p.name}</span>
</div>
<span className="text-[10px] uppercase font-bold text-muted">{p.position}</span>
</div>
))}
</div>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
) : (
/* Step 2: Custom Download Cards */
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-1000">
<div className="flex items-center justify-between px-2">
<div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight">Capas Exclusivas</h2>
<p className="text-muted text-sm flex items-center gap-2">
Sorteio Auditado com Seed <span className="text-primary font-mono font-bold">{drawSeed}</span>
</p>
</div>
<button
onClick={handleFinish}
disabled={isSaving}
className="ui-button px-10 h-12 text-sm font-bold uppercase tracking-[0.2em]"
>
Finalizar Registro
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{teams.map((t, i) => (
<div key={i} className="space-y-4">
<div
id={`team-${i}`}
className="aspect-[4/5] bg-background rounded-2xl border border-white/10 p-8 flex flex-col items-center justify-between shadow-2xl overflow-hidden relative"
>
{/* Abstract Patterns */}
<div className="absolute top-0 right-0 w-48 h-48 bg-primary/10 blur-[80px] rounded-full -mr-24 -mt-24" />
<div className="absolute bottom-0 left-0 w-32 h-32 bg-primary/5 blur-[60px] rounded-full -ml-16 -mb-16" />
<div className="text-center relative z-10 w-full">
<div className="w-14 h-14 bg-surface-raised rounded-2xl border border-white/10 flex items-center justify-center mx-auto mb-6 shadow-xl">
<Trophy className="w-6 h-6 text-primary" />
</div>
<div className="space-y-1">
<p className="text-[10px] font-bold text-primary uppercase tracking-[0.4em]">TemFut Pro</p>
<h3 className="text-2xl font-black uppercase tracking-tighter">{t.name}</h3>
<div className="h-1.5 w-12 bg-primary mx-auto mt-4 rounded-full" />
</div>
</div>
<div className="w-full space-y-3 relative z-10 flex-1 flex flex-col justify-center my-8 text-sm px-2">
{t.players.map((p: any) => (
<div key={p.id} className="flex items-center justify-between py-2.5 border-b border-white/5 last:border-0">
<div className="flex items-center gap-4">
<span className="font-mono text-primary font-black text-sm w-6">
{p.number !== null && p.number !== undefined ? p.number : getInitials(p.name)}
</span>
<div className="flex flex-col">
<span className="font-bold text-xs uppercase tracking-tight">{p.name}</span>
<div className="flex gap-0.5 mt-1">
{[...Array(5)].map((_, i) => (
<Star key={i} className={clsx(
"w-2 h-2",
i < p.level ? "text-primary fill-primary" : "text-white/10 fill-transparent"
)} />
))}
</div>
</div>
</div>
<div className="text-right">
<p className="text-[9px] font-black uppercase text-primary tracking-widest">{p.position}</p>
</div>
</div>
))}
</div>
<div className="w-full flex items-center justify-between relative z-10">
<p className="text-[8px] text-muted font-black uppercase tracking-[0.3em] font-mono">{drawSeed}</p>
<p className="text-[10px] text-muted font-bold uppercase tracking-widest">{matchDate}</p>
</div>
</div>
<button
onClick={() => downloadImage(`team-${i}`)}
className="ui-button-ghost w-full py-4 text-xs font-bold uppercase tracking-widest hover:border-primary/50"
>
<Download className="w-5 h-5 mr-2" /> Baixar Card de Time
</button>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,608 @@
'use client'
import React, { useState, useMemo } from 'react'
import { Calendar, Users, Trophy, ChevronRight, X, Clock, ExternalLink, Star, Link as LinkIcon, MapPin, Share2, Shuffle, Trash2, MessageCircle, Repeat, Search, LayoutGrid, List, Check } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx'
import Link from 'next/link'
import { deleteMatch, deleteMatches } from '@/actions/match'
import { cancelAttendance } from '@/actions/attendance'
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
const getInitials = (name: string) => {
return name
.split(' ')
.filter(n => n.length > 0)
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
export function MatchHistory({ matches, players = [] }: { matches: any[], players?: any[] }) {
const [selectedMatch, setSelectedMatch] = useState<any | null>(null)
const [modalTab, setModalTab] = useState<'confirmed' | 'unconfirmed'>('confirmed')
// New States
const [searchQuery, setSearchQuery] = useState('')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list') // Default to list for history
const [currentPage, setCurrentPage] = useState(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// Confirmation Modal State
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean
type: 'bulk' | 'match' | 'player' | null
data?: any
isProcessing: boolean
title: string
description: string
}>({
isOpen: false,
type: null,
isProcessing: false,
title: '',
description: ''
})
const itemsPerPage = 10
// Filter & Sort
const filteredMatches = useMemo(() => {
return matches
.filter((m: any) => {
const searchLower = searchQuery.toLowerCase()
return (
(m.location && m.location.toLowerCase().includes(searchLower)) ||
(m.status && m.status.toLowerCase().includes(searchLower)) ||
new Date(m.date).toLocaleDateString().includes(searchLower)
)
})
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
}, [matches, searchQuery])
// Pagination
const totalPages = Math.ceil(filteredMatches.length / itemsPerPage)
const paginatedMatches = filteredMatches.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
)
// Selection Handlers
const toggleSelection = (id: string) => {
const newSelected = new Set(selectedIds)
if (newSelected.has(id)) newSelected.delete(id)
else newSelected.add(id)
setSelectedIds(newSelected)
}
const toggleSelectAll = () => {
if (selectedIds.size === paginatedMatches.length) {
setSelectedIds(new Set())
} else {
const newSelected = new Set<string>()
paginatedMatches.forEach((m: any) => newSelected.add(m.id))
setSelectedIds(newSelected)
}
}
// Modal Action Handlers
const handleBulkDelete = () => {
setDeleteModal({
isOpen: true,
type: 'bulk',
isProcessing: false,
title: `Excluir ${selectedIds.size} eventos?`,
description: 'Você tem certeza que deseja excluir os eventos selecionados? Esta ação é irreversível.'
})
}
const handleDeleteMatch = (id: string) => {
setDeleteModal({
isOpen: true,
type: 'match',
data: id,
isProcessing: false,
title: 'Excluir Evento?',
description: 'Você tem certeza que deseja excluir este evento? Todo o histórico de presença e times será perdido.'
})
}
const handleRemovePlayer = (matchId: string, playerId: string) => {
setDeleteModal({
isOpen: true,
type: 'player',
data: { matchId, playerId },
isProcessing: false,
title: 'Remover Atleta?',
description: 'Deseja remover este atleta da lista de presença? Ele voltará para a lista de pendentes.'
})
}
const executeDeleteAction = async () => {
setDeleteModal(prev => ({ ...prev, isProcessing: true }))
try {
if (deleteModal.type === 'bulk') {
await deleteMatches(Array.from(selectedIds))
setSelectedIds(new Set())
} else if (deleteModal.type === 'match') {
await deleteMatch(deleteModal.data)
setSelectedMatch(null)
} else if (deleteModal.type === 'player') {
await cancelAttendance(deleteModal.data.matchId, deleteModal.data.playerId)
}
setDeleteModal(prev => ({ ...prev, isOpen: false }))
} catch (error) {
console.error(error)
alert('Ocorreu um erro ao processar a ação.')
} finally {
setDeleteModal(prev => ({ ...prev, isProcessing: false }))
}
}
const getStatusInfo = (status: string) => {
switch (status) {
case 'SCHEDULED': return { label: 'Agendado', color: 'bg-blue-500/10 text-blue-500 border-blue-500/20' }
case 'IN_PROGRESS': return { label: 'Em Andamento', color: 'bg-primary/10 text-primary border-primary/20' }
case 'COMPLETED': return { label: 'Concluído', color: 'bg-white/5 text-muted border-white/10' }
default: return { label: status, color: 'bg-white/5 text-muted border-white/10' }
}
}
const shareMatchLink = (match: any) => {
const url = `${window.location.origin}/match/${match.id}/confirmacao`
const dateStr = new Date(match.date).toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'short' })
const timeStr = new Date(match.date).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })
const text = `⚽ *CONVOCAÇÃO: ${match.group?.name || 'TEMFUT'}* ⚽\n\n` +
`📍 *Local:* ${match.location || 'A definir'}\n` +
`📅 *Data:* ${dateStr} às ${timeStr}\n\n` +
`Confirme sua presença pelo link abaixo:\n🔗 ${url}`
navigator.clipboard.writeText(url)
window.open(`https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`, '_blank')
}
const shareWhatsAppList = (match: any) => {
const confirmed = (match.attendances || []).filter((a: any) => a.status === 'CONFIRMED')
const unconfirmed = players.filter(p => !match.attendances?.find((a: any) => a.playerId === p.id && a.status === 'CONFIRMED'))
const url = `${window.location.origin}/match/${match.id}/confirmacao`
const text = `⚽ *CONVOCAÇÃO: ${match.group?.name || 'TEMFUT'}* ⚽\n\n` +
`📍 *Local:* ${match.location || 'A definir'}\n` +
`📅 *Data:* ${new Date(match.date).toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'short' })} às ${new Date(match.date).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}\n\n` +
`✅ *CONFIRMADOS (${confirmed.length}/${match.maxPlayers || '∞'}):*\n` +
confirmed.map((a: any, i: number) => `${i + 1}. ${a.player.name}`).join('\n') +
`\n\n❌ *AGUARDANDO:* \n` +
unconfirmed.map((p: any) => `- ${p.name}`).join('\n') +
`\n\n🔗 *Confirme pelo link:* ${url}`
navigator.clipboard.writeText(text)
const whatsappUrl = `https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`
window.open(whatsappUrl, '_blank')
}
return (
<div className="space-y-6">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div className="space-y-1">
<h2 className="text-xl font-bold tracking-tight">Histórico de Partidas</h2>
<p className="text-xs text-muted font-medium">Gerencie o histórico e agende novos eventos.</p>
</div>
<div className="flex flex-col sm:flex-row items-center gap-4">
{selectedIds.size > 0 ? (
<div className="flex items-center gap-2 w-full sm:w-auto animate-in fade-in slide-in-from-right-4">
<span className="text-xs font-bold text-muted uppercase tracking-wider px-3">{selectedIds.size} selecionados</span>
<button
onClick={handleBulkDelete}
className="ui-button bg-red-500/10 text-red-500 hover:bg-red-500/20 border-red-500/20 h-10 w-full sm:w-auto"
>
<Trash2 className="w-4 h-4 mr-2" /> Excluir
</button>
</div>
) : (
<>
<div className="relative group w-full sm:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted group-focus-within:text-primary transition-colors" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar eventos..."
className="ui-input w-full pl-10 h-10 bg-surface-raised border-border/50 text-sm"
/>
</div>
<div className="flex p-1 bg-surface-raised border border-border rounded-lg w-full sm:w-auto">
<button
onClick={() => setViewMode('grid')}
className={clsx("p-2 rounded-md transition-all", viewMode === 'grid' ? "bg-white/10 text-primary shadow-sm" : "text-muted")}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={clsx("p-2 rounded-md transition-all", viewMode === 'list' ? "bg-white/10 text-primary shadow-sm" : "text-muted")}
>
<List className="w-4 h-4" />
</button>
</div>
</>
)}
</div>
</div>
<div className={clsx(
"grid gap-3 transition-all duration-500",
viewMode === 'grid' ? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3" : "grid-cols-1"
)}>
<AnimatePresence mode='popLayout'>
{paginatedMatches.length > 0 && (
<div className={clsx("col-span-full flex items-center px-2 mb-2", viewMode === 'grid' ? "justify-end" : "")}>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={selectedIds.size === paginatedMatches.length && paginatedMatches.length > 0}
onChange={toggleSelectAll}
className="w-4 h-4 rounded border-border text-primary bg-background"
/>
<span className="text-[10px] font-bold uppercase text-muted">Selecionar Todos</span>
</div>
</div>
)}
{paginatedMatches.map((match: any) => {
const s = getStatusInfo(match.status)
return (
<motion.div
layout
key={match.id}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
onClick={() => setSelectedMatch(match)}
className={clsx(
"ui-card ui-card-hover relative group cursor-pointer border-l-4",
viewMode === 'list' ? "p-4 flex items-center gap-4" : "p-5 flex flex-col gap-4",
selectedIds.has(match.id) ? "border-primary bg-primary/5" : "border-transparent"
)}
>
{/* Checkbox for selection */}
<div onClick={(e) => e.stopPropagation()} className="absolute top-4 right-4 z-20">
<input
type="checkbox"
checked={selectedIds.has(match.id)}
onChange={() => toggleSelection(match.id)}
className={clsx(
"w-5 h-5 rounded border-border text-primary bg-surface transition-all",
selectedIds.has(match.id) || "opacity-0 group-hover:opacity-100"
)}
/>
</div>
<div className={clsx("flex items-center gap-4", viewMode === 'list' ? "flex-1" : "w-full")}>
<div className="p-3 bg-surface-raised border border-border rounded-lg text-center min-w-[56px] shrink-0">
<p className="text-sm font-bold leading-none">{new Date(match.date).getDate()}</p>
<p className="text-[10px] text-muted uppercase font-medium mt-1">
{new Date(match.date).toLocaleDateString('pt-BR', { month: 'short' }).replace('.', '')}
</p>
</div>
<div className="space-y-1 min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold truncate">
{match.status === 'SCHEDULED' ? `Evento: ${match.location || 'Sem local'}` : `Sorteio de ${match.teams.length} times`}
</p>
{match.isRecurring && (
<span className="badge bg-purple-500/10 text-purple-500 border-purple-500/20 px-1.5 py-0.5" title="Recorrente">
<Repeat className="w-3 h-3" />
</span>
)}
</div>
<div className="flex gap-1.5 flex-wrap">
<span className={clsx("badge", s.color)}>{s.label}</span>
</div>
</div>
</div>
<div className={clsx("flex flex-wrap items-center gap-3 text-[11px] text-muted", viewMode === 'list' ? "" : "border-t border-border/50 pt-4 mt-auto")}>
<div className="flex items-center gap-1">
<Users className="w-3 h-3" />
{match.status === 'SCHEDULED'
? `${(match.attendances || []).filter((a: any) => a.status === 'CONFIRMED').length} confirmados`
: `${(match.teams || []).reduce((acc: number, t: any) => acc + t.players.length, 0)} jogadores`}
</div>
<div className="w-1 h-1 rounded-full bg-border" />
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(match.date).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
</div>
{/* Actions only in List Mode here, else in modal */}
{viewMode === 'list' && (
<div className="ml-auto flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{match.status === 'SCHEDULED' && (
<button
onClick={(e) => {
e.stopPropagation()
shareWhatsAppList(match)
}}
className="p-1.5 text-primary hover:bg-primary/10 rounded transition-colors"
title="Copiar Link"
>
<Share2 className="w-4 h-4" />
</button>
)}
<ChevronRight className="w-4 h-4" />
</div>
)}
</div>
</motion.div>
)
})}
</AnimatePresence>
{paginatedMatches.length === 0 && (
<div className="col-span-full ui-card p-20 text-center border-dashed">
<Calendar className="w-8 h-8 text-muted mx-auto mb-4" />
<p className="text-sm font-bold uppercase tracking-widest text-muted">Nenhuma partida encontrada</p>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-4 mt-8">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-2 rounded-lg border border-border hover:bg-surface-raised disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4 rotate-180" />
</button>
<span className="text-xs font-bold text-muted uppercase tracking-widest">
Página {currentPage} de {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-2 rounded-lg border border-border hover:bg-surface-raised disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
{/* Modal Overlay */}
<AnimatePresence>
{selectedMatch && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelectedMatch(null)}
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.98, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98, y: 10 }}
className="ui-card w-full max-w-2xl overflow-hidden relative z-10 shadow-2xl flex flex-col max-h-[90vh]"
>
<div className="border-b border-border p-6 flex items-center justify-between bg-surface-raised/50 shrink-0">
<div>
<div className="flex items-center gap-3 mb-1">
<h3 className="text-lg font-bold tracking-tight">Resumo do Evento</h3>
<span className={clsx("badge", getStatusInfo(selectedMatch.status).color)}>
{getStatusInfo(selectedMatch.status).label}
</span>
</div>
<p className="text-sm text-muted">
{new Date(selectedMatch.date).toLocaleDateString('pt-BR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleDeleteMatch(selectedMatch.id)}
className="p-2.5 text-muted hover:text-red-500 transition-colors rounded-lg"
title="Excluir esse evento"
>
<Trash2 className="w-5 h-5" />
</button>
<button
onClick={() => setSelectedMatch(null)}
className="p-2.5 hover:bg-surface-raised rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto custom-scrollbar">
{selectedMatch.status === 'SCHEDULED' ? (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="ui-card p-4 bg-surface-raised/30">
<p className="text-label mb-2">Local/Arena</p>
<div className="flex items-center gap-2 text-sm font-semibold">
<MapPin className="w-4 h-4 text-primary" />
{selectedMatch.location || 'Não definido'}
</div>
</div>
<div className="ui-card p-4 bg-surface-raised/30">
<p className="text-label mb-2">Limite de Vagas</p>
<div className="flex items-center gap-2 text-sm font-semibold">
<Users className="w-4 h-4 text-primary" />
{selectedMatch.attendances?.filter((a: any) => a.status === 'CONFIRMED').length || 0} / {selectedMatch.maxPlayers || '∞'} Atletas
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex border-b border-border overflow-x-auto">
<button
onClick={() => setModalTab('confirmed')}
className={clsx(
"px-4 py-3 text-xs font-bold uppercase tracking-widest border-b-2 transition-all whitespace-nowrap",
modalTab === 'confirmed' ? "border-primary text-primary" : "border-transparent text-muted hover:text-foreground"
)}
>
Confirmados ({selectedMatch.attendances?.filter((a: any) => a.status === 'CONFIRMED').length || 0})
</button>
<button
onClick={() => setModalTab('unconfirmed')}
className={clsx(
"px-4 py-3 text-xs font-bold uppercase tracking-widest border-b-2 transition-all whitespace-nowrap",
modalTab === 'unconfirmed' ? "border-primary text-primary" : "border-transparent text-muted hover:text-foreground"
)}
>
Pendentes ({
players.filter(p => !selectedMatch.attendances?.find((a: any) => a.playerId === p.id && a.status === 'CONFIRMED')).length || 0
})
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
{modalTab === 'confirmed' ? (
(selectedMatch.attendances || []).filter((a: any) => a.status === 'CONFIRMED').map((a: any) => (
<div key={a.id} className="flex items-center justify-between p-3 rounded-xl bg-surface border border-border group/at hover:border-primary/30 transition-all">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center text-xs text-primary font-bold">
{getInitials(a.player.name)}
</div>
<span className="text-xs font-bold tracking-tight uppercase truncate max-w-[120px]">{a.player.name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] uppercase font-bold text-primary group-hover/at:hidden bg-primary/5 px-2 py-0.5 rounded">OK</span>
<button
onClick={() => handleRemovePlayer(selectedMatch.id, a.playerId)}
className="hidden group-hover/at:flex p-2 text-red-500 hover:bg-red-500/10 rounded-lg transition-all"
title="Remover Atleta"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))
) : (
players.filter(p => !selectedMatch.attendances?.find((a: any) => a.playerId === p.id && a.status === 'CONFIRMED')).map((p: any) => (
<div key={p.id} className="flex items-center justify-between p-3 rounded-xl bg-surface/50 border border-border/50 opacity-70">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-surface-raised border border-border flex items-center justify-center text-xs text-muted font-bold">
{getInitials(p.name)}
</div>
<span className="text-xs font-medium uppercase tracking-tight text-muted truncate max-w-[120px]">{p.name}</span>
</div>
<span className="text-[10px] font-bold text-muted uppercase tracking-wider">Pendente</span>
</div>
))
)}
{modalTab === 'confirmed' && (selectedMatch.attendances || []).filter((a: any) => a.status === 'CONFIRMED').length === 0 && (
<div className="col-span-full py-10 text-center opacity-40">
<p className="text-xs font-bold uppercase tracking-widest">Nenhuma confirmação ainda</p>
</div>
)}
</div>
</div>
<div className="pt-4">
<Link
href={`/dashboard/matches/new?id=${selectedMatch.id}`}
className="ui-button w-full h-12 text-sm font-bold"
>
<Shuffle className="w-4 h-4 mr-2" /> Realizar Sorteio Agora
</Link>
</div>
</div>
) : (
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-xs font-bold text-muted uppercase tracking-widest">
Transparência: <span className="text-primary font-mono">{selectedMatch.drawSeed || 'TRANS-1'}</span>
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{selectedMatch.teams?.map((team: any, i: number) => (
<div key={i} className="ui-card p-4 border-l-4" style={{ borderLeftColor: team.color }}>
<div className="flex items-center justify-between mb-4">
<h4 className="font-bold text-sm uppercase tracking-tight">{team.name}</h4>
<span className="badge badge-muted">{team.players.length} atletas</span>
</div>
<div className="space-y-2">
{team.players.map((p: any, j: number) => (
<div key={j} className="flex items-center justify-between text-xs py-2 border-b border-border/50 last:border-0">
<div className="flex items-center gap-3">
<span className="w-8 font-mono text-primary font-bold text-center">
{p.player.number !== null && p.player.number !== undefined ? p.player.number : getInitials(p.player.name)}
</span>
<div className="flex flex-col">
<span className="font-medium text-sm">{p.player.name}</span>
<div className="flex gap-0.5 mt-0.5">
{[...Array(5)].map((_, i) => (
<Star key={i} className={clsx(
"w-2.5 h-2.5",
i < p.player.level ? "text-primary fill-primary" : "text-border fill-transparent"
)} />
))}
</div>
</div>
</div>
<span className="text-[10px] text-muted font-bold uppercase p-1 bg-surface-raised rounded">{p.player.position}</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
<div className="border-t border-border p-4 flex flex-col sm:flex-row justify-between gap-3 bg-surface-raised/30 shrink-0">
<button
onClick={() => setSelectedMatch(null)}
className="ui-button-ghost py-3 order-3 sm:order-1 h-12 sm:h-auto"
>
Fechar
</button>
<div className="flex flex-col sm:flex-row gap-2 order-1 sm:order-2 w-full sm:w-auto">
<button
onClick={() => shareWhatsAppList(selectedMatch)}
className="ui-button-ghost py-3 border-primary/20 text-primary hover:bg-primary/5 h-12 sm:h-auto"
>
<MessageCircle className="w-4 h-4 mr-2" /> Copiar Lista
</button>
<button
onClick={() => shareMatchLink(selectedMatch)}
className="ui-button py-3 h-12 sm:h-auto"
>
<LinkIcon className="w-4 h-4 mr-2" /> Compartilhar Link
</button>
</div>
</div>
</motion.div>
</div>
)
}
</AnimatePresence >
<DeleteConfirmationModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal(prev => ({ ...prev, isOpen: false }))}
onConfirm={executeDeleteAction}
isDeleting={deleteModal.isProcessing}
title={deleteModal.title}
description={deleteModal.description}
confirmText='Sim, confirmar'
/>
</div >
)
}

View File

@@ -0,0 +1,148 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Menu, X, Trophy, Home, Calendar, Users, Settings, LogOut, ChevronRight } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx'
export function MobileNav({ group }: { group: any }) {
const [isOpen, setIsOpen] = useState(false)
const pathname = usePathname()
// Prevent scrolling when menu is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = 'unset'
}
return () => {
document.body.style.overflow = 'unset'
}
}, [isOpen])
const navItems = [
{ name: 'Início', href: '/dashboard', icon: Home },
{ name: 'Partidas', href: '/dashboard/matches', icon: Calendar },
{ name: 'Jogadores', href: '/dashboard/players', icon: Users },
{ name: 'Configurações', href: '/dashboard/settings', icon: Settings },
]
return (
<div className="md:hidden sticky top-0 z-50">
{/* Top Bar - Glass Effect */}
<div className="bg-background/80 backdrop-blur-md border-b border-white/5 h-16 px-4 flex items-center justify-between relative z-50">
<Link href="/dashboard" className="flex items-center gap-3">
<div className="w-9 h-9 bg-primary/10 rounded-xl flex items-center justify-center border border-primary/20 shadow-inner">
<Trophy className="w-5 h-5 text-primary" />
</div>
<span className="font-bold tracking-tight text-xl">TEMFUT</span>
</Link>
<button
onClick={() => setIsOpen(true)}
className="p-2 -mr-2 text-muted hover:text-foreground active:scale-95 transition-all rounded-lg hover:bg-white/5"
>
<Menu className="w-7 h-7" />
</button>
</div>
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsOpen(false)}
className="fixed inset-0 bg-black/90 backdrop-blur-sm z-50"
/>
{/* Drawer */}
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
className="fixed inset-y-0 right-0 w-full max-w-[320px] bg-surface-raised border-l border-white/10 z-[60] flex flex-col shadow-2xl"
>
{/* Drawer Header */}
<div className="p-6 h-24 flex items-start justify-between bg-surface border-b border-white/5 relative overflow-hidden">
{/* Decor */}
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/5 blur-[50px] rounded-full -mr-10 -mt-10 pointer-events-none" />
<div className="flex items-center gap-4 relative z-10 w-full pr-10">
<div className="w-12 h-12 rounded-full bg-surface-raised border border-white/10 flex items-center justify-center overflow-hidden shadow-lg shrink-0">
{group.logoUrl ? (
<img src={group.logoUrl} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-sm font-bold">{group.name.charAt(0)}</span>
)}
</div>
<div className="min-w-0">
<h3 className="font-bold text-lg leading-tight truncate">{group.name}</h3>
<p className="text-[10px] text-muted font-bold uppercase tracking-widest mt-1">Plano Free</p>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="absolute top-6 right-4 p-2 text-muted hover:text-foreground rounded-full hover:bg-white/10 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Nav Items */}
<nav className="flex-1 p-6 space-y-2 overflow-y-auto">
<p className="text-[10px] font-bold text-muted uppercase tracking-[0.2em] px-4 mb-4">Menu Principal</p>
{navItems.map((item) => {
const isActive = pathname === item.href
return (
<Link
key={item.href}
href={item.href}
onClick={() => setIsOpen(false)}
className={clsx(
"flex items-center gap-4 px-4 py-4 rounded-xl text-base font-medium transition-all duration-200 group relative overflow-hidden",
isActive
? "bg-primary text-background shadow-lg shadow-primary/20"
: "text-muted hover:text-foreground hover:bg-surface border border-transparent hover:border-white/5"
)}
>
<item.icon className={clsx("w-5 h-5", isActive ? "text-background" : "text-muted group-hover:text-primary transition-colors")} />
<span className="flex-1">{item.name}</span>
{!isActive && <ChevronRight className="w-4 h-4 text-white/20 group-hover:text-white/50" />}
</Link>
)
})}
<div className="pt-8 mt-8 border-t border-white/5">
<p className="text-[10px] font-bold text-muted uppercase tracking-[0.2em] px-4 mb-4">Geral</p>
<button
onClick={async () => {
await fetch('/api/auth/logout', { method: 'POST' })
window.location.href = '/'
}}
className="flex items-center gap-4 w-full px-4 py-4 text-base font-medium text-red-400 hover:bg-red-500/10 rounded-xl transition-all group"
>
<LogOut className="w-5 h-5 opacity-70 group-hover:opacity-100" />
<span>Sair da Conta</span>
</button>
</div>
</nav>
{/* Footer Decor */}
<div className="p-6 bg-surface border-t border-white/5 text-center">
<p className="text-[10px] font-medium text-muted/50">TemFut v1.0.0 &copy; 2024</p>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,106 @@
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
import { Users, Mail, Lock, LogIn, AlertCircle, Eye, EyeOff } from 'lucide-react'
import { loginPeladaAction } from '@/app/actions/auth'
interface Props {
slug: string
groupName?: string
}
export default function PeladaLoginPage({ slug, groupName }: Props) {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
async function handleForm(formData: FormData) {
setLoading(true)
setError('')
const result = await loginPeladaAction(formData)
if (result?.error) {
setError(result.error)
setLoading(false)
}
// Se der certo, a Action redireciona automaticamente
}
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center p-4">
<div className="bg-zinc-900 border border-zinc-800 rounded-3xl p-8 shadow-2xl w-full max-w-md relative overflow-hidden">
<div className="text-center mb-8 relative z-10">
<div className="w-16 h-16 rounded-2xl bg-emerald-500 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-emerald-500/20">
<Users className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-white uppercase tracking-tight">
{groupName || slug}
</h1>
<p className="text-zinc-500 text-sm mt-1">Acesse o painel de gestão</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3 text-red-400 text-sm">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<p>{error}</p>
</div>
)}
<form action={handleForm} className="space-y-4">
<input type="hidden" name="slug" value={slug} />
<div className="space-y-1">
<label className="text-[10px] font-bold text-zinc-500 uppercase ml-1">Email</label>
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-600" />
<input
name="email"
type="email"
required
placeholder="seu@email.com"
className="w-full pl-12 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white focus:border-emerald-500 transition-all outline-none"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-[10px] font-bold text-zinc-500 uppercase ml-1">Senha</label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-600" />
<input
name="password"
type={showPassword ? 'text' : 'password'}
required
placeholder="••••••••"
className="w-full pl-12 pr-12 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white focus:border-emerald-500 transition-all outline-none font-mono"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-white"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-4 bg-emerald-500 hover:bg-emerald-400 text-zinc-950 font-black rounded-xl transition-all disabled:opacity-50"
>
{loading ? 'CARREGANDO...' : 'ENTRAR NO TIME'}
</button>
</form>
<div className="mt-8 text-center border-t border-zinc-800 pt-6">
<a href="http://localhost" className="text-xs text-zinc-600 hover:text-zinc-400 uppercase font-bold tracking-widest">
Voltar para TemFut
</a>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,607 @@
'use client'
import React, { useState, useMemo } from 'react'
import { Plus, Trash2, UserPlus, Star, Search, Filter, MoreHorizontal, User, Shield, Target, Zap, ChevronDown, LayoutGrid, List, ChevronRight, Check, X, AlertCircle } from 'lucide-react'
import { addPlayer, deletePlayer, deletePlayers } from '@/actions/player'
import { motion, AnimatePresence } from 'framer-motion'
import { clsx } from 'clsx'
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
export function PlayersList({ group }: { group: any }) {
const [newPlayerName, setNewPlayerName] = useState('')
const [level, setLevel] = useState(3)
const [number, setNumber] = useState<string>('')
const [position, setPosition] = useState<'DEF' | 'MEI' | 'ATA'>('MEI')
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState<'ALL' | 'DEF' | 'MEI' | 'ATA'>('ALL')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [error, setError] = useState<string | null>(null)
const [isAdding, setIsAdding] = useState(false)
const [isFormOpen, setIsFormOpen] = useState(false)
// Pagination & Selection
const [currentPage, setCurrentPage] = useState(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const itemsPerPage = 12
// Confirmation Modal State
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean
type: 'single' | 'bulk' | null
playerId?: string
isDeleting: boolean
title: string
description: string
}>({
isOpen: false,
type: null,
isDeleting: false,
title: '',
description: ''
})
const handleAddPlayer = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!newPlayerName) return
setIsAdding(true)
try {
const playerNumber = number.trim() === '' ? null : parseInt(number)
await addPlayer(group.id, newPlayerName, level, playerNumber, position)
setNewPlayerName('')
setLevel(3)
setNumber('')
setPosition('MEI')
setIsFormOpen(false)
} catch (err: any) {
setError(err.message)
} finally {
setIsAdding(false)
}
}
const filteredPlayers = useMemo(() => {
return group.players.filter((p: any) => {
const matchesSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.number?.toString().includes(searchQuery) ||
p.position.toLowerCase().includes(searchQuery.toLowerCase())
const matchesTab = activeTab === 'ALL' || p.position === activeTab
return matchesSearch && matchesTab
}).sort((a: any, b: any) => {
if (a.number && b.number) return a.number - b.number
if (!a.number && b.number) return 1
if (a.number && !b.number) return -1
return a.name.localeCompare(b.name)
})
}, [group.players, searchQuery, activeTab])
const totalPages = Math.ceil(filteredPlayers.length / itemsPerPage)
const paginatedPlayers = filteredPlayers.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
)
useMemo(() => {
setCurrentPage(1)
setSelectedIds(new Set())
}, [searchQuery, activeTab])
const toggleSelection = (id: string) => {
const newSelected = new Set(selectedIds)
if (newSelected.has(id)) newSelected.delete(id)
else newSelected.add(id)
setSelectedIds(newSelected)
}
const toggleSelectAll = () => {
if (selectedIds.size === paginatedPlayers.length) {
setSelectedIds(new Set())
} else {
const newSelected = new Set<string>()
paginatedPlayers.forEach((p: any) => newSelected.add(p.id))
setSelectedIds(newSelected)
}
}
const handleBulkDelete = () => {
setDeleteModal({
isOpen: true,
type: 'bulk',
isDeleting: false,
title: `Excluir ${selectedIds.size} atletas?`,
description: 'Você tem certeza que deseja excluir os atletas selecionados? Esta ação não pode ser desfeita.'
})
}
const handleSingleDelete = (id: string, name: string) => {
setDeleteModal({
isOpen: true,
type: 'single',
playerId: id,
isDeleting: false,
title: `Excluir ${name}?`,
description: `Deseja realmente excluir o atleta ${name} do elenco?`
})
}
const executeDelete = async () => {
setDeleteModal(prev => ({ ...prev, isDeleting: true }))
try {
if (deleteModal.type === 'bulk') {
await deletePlayers(Array.from(selectedIds))
setSelectedIds(new Set())
} else if (deleteModal.type === 'single' && deleteModal.playerId) {
await deletePlayer(deleteModal.playerId)
}
setDeleteModal(prev => ({ ...prev, isOpen: false }))
} catch (err) {
console.error(err)
alert('Erro ao excluir atleta(s).')
} finally {
setDeleteModal(prev => ({ ...prev, isDeleting: false }))
}
}
const getInitials = (name: string) => {
return name
.split(' ')
.filter(n => n.length > 0)
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
const getLevelInfo = (lvl: number) => {
if (lvl >= 5) return { label: 'Elite', color: 'text-emerald-500' }
if (lvl >= 4) return { label: 'Pro', color: 'text-blue-500' }
if (lvl >= 3) return { label: 'Regular', color: 'text-primary' }
return { label: 'Amador', color: 'text-muted' }
}
return (
<div className="space-y-8 pb-20">
<AnimatePresence>
{!isFormOpen && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="flex justify-end"
>
<button
onClick={() => {
setError(null)
setIsFormOpen(true)
}}
className="ui-button w-full sm:w-auto shadow-lg shadow-primary/20"
>
<UserPlus className="w-5 h-5 mr-2" />
Registrar Novo Atleta
</button>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{isFormOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsFormOpen(false)}
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="ui-card w-full max-w-lg overflow-hidden relative z-10 shadow-2xl border-primary/20"
>
<div className="p-6 bg-surface-raised/50 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center border border-primary/20 shadow-inner">
<UserPlus className="w-6 h-6 text-primary" />
</div>
<div>
<h3 className="text-lg font-bold tracking-tight">Novo Atleta</h3>
<p className="text-xs text-muted font-medium uppercase tracking-wider">Adicionar ao elenco</p>
</div>
</div>
<button
onClick={() => setIsFormOpen(false)}
className="p-2 text-muted hover:text-foreground rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleAddPlayer} className="p-6 space-y-6">
<div className="space-y-4">
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-primary" />
<span className="text-xs font-bold text-muted uppercase tracking-widest">Identificação</span>
</div>
<div className="flex gap-4">
<div className="flex-1 ui-form-field">
<input
value={newPlayerName}
onChange={(e) => {
setNewPlayerName(e.target.value)
setError(null)
}}
placeholder="Nome do Atleta"
className="ui-input w-full h-12 font-medium"
autoFocus
required
/>
</div>
<div className="w-24 ui-form-field">
<input
type="number"
value={number}
onChange={(e) => {
setNumber(e.target.value)
setError(null)
}}
placeholder="Nº"
className="ui-input w-full h-12 text-center font-mono font-bold"
/>
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<Target className="w-4 h-4 text-primary" />
<span className="text-xs font-bold text-muted uppercase tracking-widest">Posição em Campo</span>
</div>
<div className="grid grid-cols-3 gap-3">
{[
{ id: 'DEF', icon: Shield, label: 'Zagueiro', color: 'bg-blue-500' },
{ id: 'MEI', icon: Zap, label: 'Meio-Campo', color: 'bg-emerald-500' },
{ id: 'ATA', icon: Target, label: 'Atacante', color: 'bg-orange-500' },
].map((pos) => {
const isSelected = position === pos.id
return (
<button
key={pos.id}
type="button"
onClick={() => setPosition(pos.id as any)}
className={clsx(
"relative h-24 rounded-xl border-2 flex flex-col items-center justify-center gap-2 transition-all duration-200 overflow-hidden group",
isSelected
? "border-primary bg-primary/5"
: "border-border bg-surface-raised hover:border-primary/50"
)}
>
{isSelected && (
<div className={clsx("absolute top-0 right-0 p-1 rounded-bl-lg text-white", pos.color)}>
<div className="w-1.5 h-1.5 rounded-full bg-white animate-pulse" />
</div>
)}
<pos.icon className={clsx("w-6 h-6 mb-1 transition-colors", isSelected ? "text-primary" : "text-muted group-hover:text-foreground")} />
<span className={clsx("text-[10px] font-black uppercase tracking-widest", isSelected ? "text-foreground" : "text-muted")}>
{pos.label}
</span>
</button>
)
})}
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<Star className="w-4 h-4 text-primary" />
<span className="text-xs font-bold text-muted uppercase tracking-widest">Nível Técnico ({getLevelInfo(level).label})</span>
</div>
<div className="bg-surface-raised p-4 rounded-xl border border-border flex justify-between items-center group hover:border-primary/30 transition-colors">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setLevel(star)}
className="p-2 transition-all hover:scale-125 focus:outline-none"
>
<Star
className={clsx(
"w-8 h-8 transition-all duration-300",
star <= level
? "text-primary fill-primary drop-shadow-[0_0_10px_rgba(16,185,129,0.4)]"
: "text-border fill-transparent group-hover:text-muted"
)}
/>
</button>
))}
</div>
</div>
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-500 flex items-center gap-3"
>
<AlertCircle className="w-5 h-5 shrink-0" />
<p className="text-sm font-bold uppercase tracking-tight">{error}</p>
</motion.div>
)}
</AnimatePresence>
<div className="pt-4 flex gap-3">
<button
type="button"
onClick={() => setIsFormOpen(false)}
className="ui-button-ghost flex-1 h-12"
>
Cancelar
</button>
<button
type="submit"
disabled={isAdding || !newPlayerName}
className="ui-button flex-[2] h-12 shadow-xl shadow-primary/10"
>
{isAdding ? <div className="w-5 h-5 border-2 border-background/30 border-t-background rounded-full animate-spin" /> : 'Confirmar Cadastro'}
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
<div className="space-y-6">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div className="space-y-1">
<h2 className="text-xl font-bold tracking-tight">Elenco do Grupo</h2>
<p className="text-xs text-muted font-medium">Gerencie e visualize todos os atletas registrados abaixo.</p>
</div>
<div className="flex flex-col sm:flex-row items-center gap-4">
{selectedIds.size > 0 ? (
<div className="flex items-center gap-2 w-full sm:w-auto animate-in fade-in slide-in-from-right-4">
<span className="text-xs font-bold text-muted uppercase tracking-wider px-3">{selectedIds.size} selecionados</span>
<button
onClick={handleBulkDelete}
className="ui-button bg-red-500/10 text-red-500 hover:bg-red-500/20 border-red-500/20 h-10 w-full sm:w-auto"
>
<Trash2 className="w-4 h-4 mr-2" /> Excluir
</button>
</div>
) : (
<>
<div className="relative group w-full sm:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted group-focus-within:text-primary transition-colors" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar no elenco..."
className="ui-input w-full pl-10 h-10 bg-surface-raised border-border/50 text-sm"
/>
</div>
<div className="flex p-1 bg-surface-raised border border-border rounded-lg w-full sm:w-auto">
<button
onClick={() => setViewMode('grid')}
className={clsx("p-2 rounded-md transition-all", viewMode === 'grid' ? "bg-white/10 text-primary shadow-sm" : "text-muted")}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={clsx("p-2 rounded-md transition-all", viewMode === 'list' ? "bg-white/10 text-primary shadow-sm" : "text-muted")}
>
<List className="w-4 h-4" />
</button>
</div>
</>
)}
</div>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-2 scrollbar-none border-b border-border/50">
{['ALL', 'DEF', 'MEI', 'ATA'].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab as any)}
className={clsx(
"relative px-6 py-2.5 text-[10px] font-bold uppercase tracking-[0.2em] transition-all",
activeTab === tab ? "text-primary" : "text-muted hover:text-foreground"
)}
>
{tab === 'ALL' ? 'Todos' : tab}
{activeTab === tab && (
<motion.div layoutId="activeTab" className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
))}
<div className="ml-auto text-[10px] font-bold text-muted bg-surface-raised px-3 py-1 rounded-full border border-border">
{filteredPlayers.length} ATLETAS
</div>
</div>
<div className={clsx(
"grid gap-4 transition-all duration-500",
viewMode === 'grid' ? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" : "grid-cols-1"
)}>
<AnimatePresence mode='popLayout'>
{paginatedPlayers.length > 0 && (
<div className={clsx("col-span-full flex items-center px-2 mb-2", viewMode === 'grid' ? "justify-end" : "")}>
<button
onClick={toggleSelectAll}
className="flex items-center gap-2 group cursor-pointer"
>
<div className={clsx(
"w-5 h-5 rounded-lg border-2 flex items-center justify-center transition-all duration-200",
selectedIds.size === paginatedPlayers.length && paginatedPlayers.length > 0
? "bg-primary border-primary shadow-[0_0_10px_rgba(16,185,129,0.4)]"
: "border-muted/30 bg-surface/50 group-hover:border-primary/50"
)}>
{selectedIds.size === paginatedPlayers.length && paginatedPlayers.length > 0 && (
<Check className="w-3.5 h-3.5 text-background font-bold stroke-[3]" />
)}
</div>
<span className={clsx(
"text-[10px] font-bold uppercase tracking-widest transition-colors",
selectedIds.size === paginatedPlayers.length ? "text-primary" : "text-muted group-hover:text-foreground"
)}>
Selecionar Todos
</span>
</button>
</div>
)}
{paginatedPlayers.map((p: any) => (
<motion.div
key={p.id}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className={clsx(
"ui-card group relative hover:border-primary/40 transition-all duration-500 overflow-hidden",
viewMode === 'list' ? "flex items-center p-3 sm:px-6" : "p-5 flex flex-col",
selectedIds.has(p.id) ? "border-primary bg-primary/5" : "border-border"
)}
>
<div
onClick={(e) => {
e.stopPropagation()
toggleSelection(p.id)
}}
className={clsx("z-20 p-2", viewMode === 'grid' ? "absolute top-4 right-4 -mr-2 -mt-2" : "mr-4 -ml-2")}
>
<div className={clsx(
"w-5 h-5 rounded-lg border-2 flex items-center justify-center transition-all duration-200",
selectedIds.has(p.id)
? "bg-primary border-primary shadow-[0_0_10px_rgba(16,185,129,0.4)] scale-110"
: "border-muted/30 bg-surface/50 opacity-0 group-hover:opacity-100 hover:border-primary/50"
)}>
{selectedIds.has(p.id) && <Check className="w-3.5 h-3.5 text-background font-bold stroke-[3]" />}
</div>
</div>
{viewMode === 'grid' && (
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/5 blur-[40px] rounded-full -mr-16 -mt-16 pointer-events-none group-hover:bg-primary/10 transition-all" />
)}
<div className={clsx("flex items-center gap-4 relative z-10", viewMode === 'list' ? "flex-1" : "mb-5")}>
<div className="relative">
<div className="w-12 h-12 bg-surface-raised rounded-2xl border border-border/50 flex items-center justify-center font-mono font-black text-sm text-primary shadow-inner">
{p.number !== null ? p.number : getInitials(p.name)}
</div>
<div className={clsx(
"absolute -bottom-1 -right-1 w-5 h-5 rounded-lg border-2 border-background flex items-center justify-center bg-foreground text-[8px] font-black text-background",
p.position === 'DEF' ? 'bg-blue-500' : p.position === 'MEI' ? 'bg-emerald-500' : 'bg-orange-500'
)}>
{p.position[0]}
</div>
</div>
<div className="min-w-0">
<p className="font-bold text-[13px] uppercase tracking-tight truncate leading-none mb-1 group-hover:text-primary transition-colors">
{p.name}
</p>
<div className="flex items-center gap-2">
<span className="text-[9px] font-bold text-muted uppercase tracking-widest">{p.position}</span>
<span className="w-1 h-1 rounded-full bg-border" />
<div className="flex gap-0.5">
{[...Array(5)].map((_, i) => (
<Star key={i} className={clsx(
"w-2.5 h-2.5",
i < p.level ? "text-primary fill-primary" : "text-border fill-transparent"
)} />
))}
</div>
</div>
</div>
</div>
{viewMode === 'list' && (
<div className="hidden sm:flex items-center gap-8 mr-8">
<div className="text-center">
<p className="text-[8px] font-bold text-muted uppercase tracking-widest mb-0.5">Nivel</p>
<p className={clsx("text-[10px] font-black uppercase", getLevelInfo(p.level).color)}>{getLevelInfo(p.level).label}</p>
</div>
</div>
)}
<div className={clsx("flex items-center gap-2 relative z-10", viewMode === 'list' ? "ml-auto" : "mt-auto justify-between pt-4 border-t border-border/50")}>
{viewMode === 'grid' && (
<div className={clsx("text-[9px] font-black uppercase tracking-widest", getLevelInfo(p.level).color)}>
Status: {getLevelInfo(p.level).label}
</div>
)}
<button
onClick={(e) => {
e.stopPropagation()
handleSingleDelete(p.id, p.name)
}}
className="p-2 text-muted hover:text-red-500 hover:bg-red-500/10 rounded-xl transition-all opacity-0 lg:group-hover:opacity-100"
title="Excluir Atleta"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</motion.div>
))}
</AnimatePresence>
{paginatedPlayers.length === 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="col-span-full py-20 text-center space-y-4"
>
<div className="w-16 h-16 bg-surface-raised border border-dashed border-border rounded-full flex items-center justify-center mx-auto opacity-40">
<Search className="w-6 h-6 text-muted" />
</div>
<div className="space-y-1">
<p className="text-sm font-bold uppercase tracking-widest">Nenhum Atleta Encontrado</p>
<p className="text-xs text-muted">Tente ajustar sua busca ou o filtro por posição.</p>
</div>
</motion.div>
)}
</div>
{totalPages > 1 && (
<div className="flex justify-center items-center gap-4 mt-8">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-2 rounded-lg border border-border hover:bg-surface-raised disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4 rotate-180" />
</button>
<span className="text-xs font-bold text-muted uppercase tracking-widest">
Página {currentPage} de {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-2 rounded-lg border border-border hover:bg-surface-raised disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
<DeleteConfirmationModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal(prev => ({ ...prev, isOpen: false }))}
onConfirm={executeDelete}
isDeleting={deleteModal.isDeleting}
title={deleteModal.title}
description={deleteModal.description}
confirmText="Sim, confirmar"
/>
</div>
)
}

View File

@@ -0,0 +1,229 @@
'use client'
import { useState, useTransition, useEffect } from 'react'
import { updateGroupSettings } from '@/app/actions'
import { Upload, Save, Loader2, Image as ImageIcon, AlertCircle, CheckCircle } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion'
interface SettingsFormProps {
initialData: {
name: string
slug: string
logoUrl: string | null
primaryColor: string
secondaryColor: string
pixKey?: string | null
pixName?: string | null
}
}
export function SettingsForm({ initialData }: SettingsFormProps) {
const [error, setError] = useState<string | null>(null)
const [successMsg, setSuccessMsg] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const [previewUrl, setPreviewUrl] = useState<string | null>(initialData.logoUrl)
const router = useRouter()
// Sincroniza o preview se os dados iniciais mudarem (ex: após redirect/refresh)
useEffect(() => {
setPreviewUrl(initialData.logoUrl)
}, [initialData.logoUrl])
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const url = URL.createObjectURL(file)
setPreviewUrl(url)
}
}
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
setError(null)
setSuccessMsg(null)
startTransition(async () => {
try {
const res = await updateGroupSettings(formData)
if (res.success) {
setSuccessMsg('Configurações salvas com sucesso! Redirecionando...')
// Pequeno delay para o usuário ver a mensagem de sucesso
setTimeout(() => {
const slug = res.slug || initialData.slug
const host = window.location.host
const protocol = window.location.protocol
// Garante que o porto seja preservado se houver (localhost:3000)
const portMatch = host.match(/:\d+$/)
const port = portMatch ? portMatch[0] : ''
// Evita redirecionar para slug vazio
if (slug) {
const targetDomain = host.includes('localhost') ? 'localhost' : 'temfut.com'
window.location.href = `${protocol}//${slug}.${targetDomain}${port}/dashboard/settings`
} else {
router.refresh()
}
}, 1500)
} else {
setError(res.error || 'Erro ao salvar configurações.')
}
} catch (err: any) {
console.error(err)
setError('Ocorreu um erro inesperado ao salvar.')
}
})
}
return (
<form onSubmit={handleSubmit} className="space-y-8 animate-in fade-in duration-500">
{/* Logo Section */}
<div className="ui-card p-8 flex flex-col sm:flex-row items-center gap-8">
<div className="relative group">
<div className="w-32 h-32 rounded-2xl bg-surface-raised border-2 border-dashed border-border flex items-center justify-center overflow-hidden transition-all group-hover:border-primary/50">
{previewUrl ? (
<img
src={previewUrl}
alt="Logo Preview"
className="w-full h-full object-cover"
/>
) : (
<ImageIcon className="w-10 h-10 text-muted group-hover:text-primary transition-colors" />
)}
</div>
<label htmlFor="logo-upload" className="absolute inset-0 cursor-pointer flex items-center justify-center bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-2xl">
<span className="text-xs font-medium text-white flex items-center gap-1">
<Upload className="w-3 h-3" /> Alterar
</span>
</label>
<input
id="logo-upload"
name="logo"
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
<div className="flex-1 text-center sm:text-left space-y-2">
<h3 className="font-semibold text-lg uppercase tracking-tight">Escudo do Grupo</h3>
<p className="text-muted text-xs font-medium uppercase tracking-wider">
Recomendado: 500x500px. JPG, PNG ou WEBP.
</p>
</div>
</div>
{/* General Info Section */}
<div className="ui-card p-8 space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="ui-form-field">
<label className="text-label ml-1">Nome da Pelada</label>
<input
name="name"
defaultValue={initialData.name}
placeholder="Ex: Pelada de Quarta"
className="ui-input w-full h-12 text-lg font-bold"
required
/>
</div>
<div className="ui-form-field">
<label className="text-label ml-1">Chave PIX para Cobranças (Opcional)</label>
<input
name="pixKey"
defaultValue={initialData.pixKey || ''}
placeholder="CPF, Email ou Aleatória"
className="ui-input w-full h-12 text-lg font-mono placeholder:font-sans"
/>
</div>
</div>
<div className="ui-form-field">
<label className="text-label ml-1">Nome Completo do Titular do Pix</label>
<input
name="pixName"
defaultValue={initialData.pixName || ''}
placeholder="Ex: José da Silva (Para o QR Code)"
className="ui-input w-full h-12 text-lg font-medium"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="ui-form-field">
<label className="text-label ml-1">Cor de Destaque (Primária)</label>
<div className="flex gap-4 items-center p-4 rounded-xl border border-border bg-surface-raised/30">
<input
type="color"
name="primaryColor"
defaultValue={initialData.primaryColor}
className="h-14 w-14 rounded-lg cursor-pointer border-0 p-0 overflow-hidden bg-transparent"
/>
<div className="flex-1">
<p className="text-xs font-bold uppercase text-muted tracking-widest mb-1">Status: Ativo</p>
<p className="text-[10px] text-muted font-medium uppercase">Personaliza botões e ícones.</p>
</div>
</div>
</div>
<div className="ui-form-field">
<label className="text-label ml-1">Cor de Fundo (Secundária)</label>
<div className="flex gap-4 items-center p-4 rounded-xl border border-border bg-surface-raised/30">
<input
type="color"
name="secondaryColor"
defaultValue={initialData.secondaryColor}
className="h-14 w-14 rounded-lg cursor-pointer border-0 p-0 overflow-hidden bg-transparent"
/>
<div className="flex-1">
<p className="text-xs font-bold uppercase text-muted tracking-widest mb-1">Contraste</p>
<p className="text-[10px] text-muted font-medium uppercase">Usada em detalhes de UI.</p>
</div>
</div>
</div>
</div>
</div>
{/* Status Messages */}
<AnimatePresence>
{error && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-500 flex items-center gap-3">
<AlertCircle className="w-5 h-5 shrink-0" />
<p className="text-sm font-bold uppercase tracking-tight">{error}</p>
</motion.div>
)}
{successMsg && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-500 flex items-center gap-3">
<CheckCircle className="w-5 h-5 shrink-0" />
<p className="text-sm font-bold uppercase tracking-tight">{successMsg}</p>
</motion.div>
)}
</AnimatePresence>
{/* Submit Button */}
<div className="flex justify-end items-center gap-4 border-t border-border pt-8">
<button
type="submit"
disabled={isPending}
className="ui-button w-full sm:w-auto h-14 min-w-[240px] shadow-xl shadow-primary/20 text-base font-bold uppercase tracking-widest"
>
{isPending ? (
<>
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Processando...
</>
) : (
<>
<Save className="w-5 h-5 mr-2" />
Confirmar Alterações
</>
)}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
import { useState } from 'react'
import { MapPin, Palette } from 'lucide-react'
import { clsx } from 'clsx'
import { motion, AnimatePresence } from 'framer-motion'
interface SettingsTabsProps {
branding: React.ReactNode
arenas: React.ReactNode
}
export function SettingsTabs({ branding, arenas }: SettingsTabsProps) {
const [activeTab, setActiveTab] = useState<'branding' | 'arenas'>('branding')
const tabs = [
{ id: 'branding', label: 'Identidade Visual', icon: Palette },
{ id: 'arenas', label: 'Locais & Arenas', icon: MapPin },
] as const
return (
<div className="space-y-8">
<div className="flex p-1 bg-surface-raised rounded-xl border border-border w-full sm:w-fit">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
"flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-bold transition-all flex-1 sm:flex-none justify-center",
activeTab === tab.id
? "bg-primary text-background shadow-lg shadow-emerald-500/10"
: "text-muted hover:text-foreground hover:bg-white/5"
)}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
<div className="min-h-[500px]">
<AnimatePresence mode="wait">
{activeTab === 'branding' && (
<motion.div
key="branding"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
{branding}
</motion.div>
)}
{activeTab === 'arenas' && (
<motion.div
key="arenas"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
{arenas}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
'use client'
import { createGroup } from '@/actions/group'
import { motion } from 'framer-motion'
import { Plus, Image as ImageIcon } from 'lucide-react'
export function SetupForm() {
const handleAction = async (formData: FormData) => {
await createGroup(formData)
}
return (
<form action={handleAction} className="space-y-6">
<div className="ui-form-field">
<label className="text-label ml-0.5">
Nome do Grupo
</label>
<input
name="name"
placeholder="Ex: Amigos do Futebol"
className="ui-input w-full h-12"
required
/>
</div>
<div className="ui-form-field">
<label className="text-label ml-0.5">
Logo (Opcional)
</label>
<div className="relative group">
<input
name="logo"
type="file"
accept="image/*"
className="ui-input w-full h-12 pt-2.5 file:bg-primary file:border-0 file:rounded file:text-background file:text-xs file:font-bold file:uppercase file:px-3 file:py-1 file:mr-4 file:cursor-pointer transition-all"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-2">
<div className="ui-form-field">
<label className="text-label ml-0.5">
Cor Primária
</label>
<input
name="primaryColor"
type="color"
defaultValue="#10b981"
className="w-full h-12 bg-transparent border border-border rounded-lg cursor-pointer p-1"
/>
</div>
<div className="ui-form-field">
<label className="text-label ml-0.5">
Cor Secundária
</label>
<input
name="secondaryColor"
type="color"
defaultValue="#000000"
className="w-full h-12 bg-transparent border border-border rounded-lg cursor-pointer p-1"
/>
</div>
</div>
<button type="submit" className="ui-button w-full mt-4 h-12 text-sm font-bold">
<Plus className="w-5 h-5 mr-2" /> Finalizar Cadastro
</button>
</form>
)
}

50
src/components/Shell.tsx Normal file
View File

@@ -0,0 +1,50 @@
'use client'
import { motion } from 'framer-motion'
import { Trophy, Search, Bell, User } from 'lucide-react'
import Link from 'next/link'
export function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-background text-foreground font-sans">
<nav className="border-b border-border bg-background/50 backdrop-blur-md sticky top-0 z-50">
<div className="container mx-auto px-4 h-14 flex items-center justify-between">
<div className="flex items-center gap-8">
<Link href="/dashboard" className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary rounded flex items-center justify-center">
<Trophy className="w-4 h-4 text-background" />
</div>
<span className="font-bold tracking-tight text-lg">TEMFUT</span>
</Link>
<div className="hidden md:flex items-center gap-6">
<Link href="/dashboard" className="text-sm font-medium text-muted hover:text-foreground transition-colors">Dashboard</Link>
<Link href="/dashboard/matches" className="text-sm font-medium text-muted hover:text-foreground transition-colors">Partidas</Link>
<Link href="/dashboard/players" className="text-sm font-medium text-muted hover:text-foreground transition-colors">Jogadores</Link>
</div>
</div>
<div className="flex items-center gap-4">
<div className="hidden sm:flex relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted" />
<input
type="text"
placeholder="Search..."
className="bg-surface-raised border border-border rounded-md pl-9 pr-4 py-1.5 text-xs w-64 focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<button className="p-2 text-muted hover:text-foreground transition-colors">
<Bell className="w-4 h-4" />
</button>
<div className="w-8 h-8 rounded-full bg-surface-raised border border-border flex items-center justify-center">
<User className="w-4 h-4 text-muted" />
</div>
</div>
</div>
</nav>
<main className="container mx-auto px-4 py-8 max-w-6xl">
{children}
</main>
</div>
)
}

104
src/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,104 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import {
Trophy,
Users,
Calendar,
Settings,
Home,
Search,
Banknote,
LogOut
} from 'lucide-react'
import { clsx } from 'clsx'
export function Sidebar({ group }: { group: any }) {
const pathname = usePathname()
const router = useRouter()
const navItems = [
{ name: 'Início', href: '/dashboard', icon: Home },
{ name: 'Partidas', href: '/dashboard/matches', icon: Calendar },
{ name: 'Jogadores', href: '/dashboard/players', icon: Users },
{ name: 'Financeiro', href: '/dashboard/financial', icon: Banknote },
{ name: 'Configurações', href: '/dashboard/settings', icon: Settings },
]
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' })
window.location.href = '/'
}
return (
<aside className="hidden md:flex w-64 border-r border-border bg-surface flex-col fixed inset-y-0 z-50 md:sticky">
<div className="p-6 h-14 flex items-center border-b border-border">
<Link href="/dashboard" className="flex items-center gap-2">
<div className="w-7 h-7 bg-primary rounded flex items-center justify-center">
<Trophy className="w-3.5 h-3.5 text-background" />
</div>
<span className="font-bold tracking-tight text-lg">TEMFUT</span>
</Link>
</div>
<div className="p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted" />
<input
type="text"
placeholder="Search..."
className="w-full bg-surface-raised border border-border rounded-md pl-9 pr-3 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
<nav className="flex-1 px-3 py-2 space-y-1">
<p className="text-[10px] font-bold text-muted uppercase tracking-widest px-3 mb-2 mt-4">Navegação</p>
{navItems.map((item) => {
const isActive = pathname === item.href
return (
<Link
key={item.href}
href={item.href}
className={clsx(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all",
isActive
? "bg-primary/5 text-primary border border-primary/20"
: "text-muted hover:text-foreground hover:bg-surface-raised"
)}
>
<item.icon className={clsx("w-4 h-4", isActive ? "text-primary" : "text-muted")} />
<span>{item.name}</span>
</Link>
)
})}
</nav>
<div className="p-4 border-t border-border mt-auto">
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 px-3 py-2 text-sm font-medium text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-md transition-all mb-4"
>
<LogOut className="w-4 h-4" />
<span>Sair</span>
</button>
<div className="flex items-center gap-3 px-2">
<div className="w-8 h-8 rounded-full bg-surface-raised border border-border flex items-center justify-center overflow-hidden">
{group.logoUrl ? (
<img src={group.logoUrl} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-[10px] font-bold">{group.name.charAt(0)}</span>
)}
</div>
<div className="overflow-hidden">
<p className="text-xs font-semibold truncate">{group.name}</p>
<p className="text-[10px] text-muted truncate">Plano Free</p>
</div>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,21 @@
'use client'
import { useEffect } from 'react'
interface ThemeWrapperProps {
primaryColor?: string
children?: React.ReactNode
}
export function ThemeWrapper({ primaryColor, children }: ThemeWrapperProps) {
useEffect(() => {
if (primaryColor) {
document.documentElement.style.setProperty('--primary-color', primaryColor)
// Update other derived colors if necessary
// For example, if we needed to convert hex to HSL for other variables
}
}, [primaryColor])
return children || null
}