feat: adiciona horario e icone de relogio na pagina de confirmacao
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { createArena, deleteArena } from '@/actions/arena'
|
||||
import { MapPin, Plus, Trash2, Loader2, Navigation } from 'lucide-react'
|
||||
import { createArena, deleteArena, updateArena } from '@/actions/arena'
|
||||
import { MapPin, Plus, Trash2, Loader2, Navigation, Pencil, X } from 'lucide-react'
|
||||
import type { Arena } from '@prisma/client'
|
||||
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ArenasManagerProps {
|
||||
|
||||
export function ArenasManager({ arenas }: ArenasManagerProps) {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [editingArena, setEditingArena] = useState<Arena | null>(null)
|
||||
const [deleteModal, setDeleteModal] = useState<{
|
||||
isOpen: boolean
|
||||
arenaId: string | null
|
||||
@@ -46,6 +47,17 @@ export function ArenasManager({ arenas }: ArenasManagerProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = (arena: Arena) => {
|
||||
setEditingArena(arena)
|
||||
document.getElementById('arena-form')?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingArena(null)
|
||||
const form = document.getElementById('arena-form') as HTMLFormElement
|
||||
form?.reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-card p-8 space-y-8">
|
||||
<header>
|
||||
@@ -70,14 +82,23 @@ export function ArenasManager({ arenas }: ArenasManagerProps) {
|
||||
{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 className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleEdit(arena)}
|
||||
className="p-2 text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||
title="Editar local"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(arena.id)}
|
||||
disabled={isPending}
|
||||
className="p-2 text-muted hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
title="Excluir local"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -91,16 +112,36 @@ export function ArenasManager({ arenas }: ArenasManagerProps) {
|
||||
|
||||
<form action={(formData) => {
|
||||
startTransition(async () => {
|
||||
await createArena(formData)
|
||||
const form = document.getElementById('arena-form') as HTMLFormElement
|
||||
form?.reset()
|
||||
if (editingArena) {
|
||||
await updateArena(editingArena.id, formData)
|
||||
} else {
|
||||
await createArena(formData)
|
||||
}
|
||||
cancelEdit()
|
||||
})
|
||||
}} id="arena-form" className="pt-6 mt-6 border-t border-border">
|
||||
}} id="arena-form" className="pt-6 mt-6 border-t border-border space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-bold uppercase tracking-widest text-primary">
|
||||
{editingArena ? 'Editando Local' : 'Adicionar Novo Local'}
|
||||
</h4>
|
||||
{editingArena && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelEdit}
|
||||
className="text-[10px] font-bold uppercase text-muted hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<X className="w-3 h-3" /> Cancelar Edição
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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"
|
||||
defaultValue={editingArena?.name || ''}
|
||||
key={editingArena?.id || 'new'}
|
||||
placeholder="Ex: Arena do Zé"
|
||||
className="ui-input w-full"
|
||||
required
|
||||
@@ -110,6 +151,8 @@ export function ArenasManager({ arenas }: ArenasManagerProps) {
|
||||
<label className="text-label ml-1">Endereço (Opcional)</label>
|
||||
<input
|
||||
name="address"
|
||||
defaultValue={editingArena?.address || ''}
|
||||
key={editingArena?.id ? `addr-${editingArena.id}` : 'new-addr'}
|
||||
placeholder="Rua das Flores, 123"
|
||||
className="ui-input w-full"
|
||||
/>
|
||||
@@ -117,14 +160,14 @@ export function ArenasManager({ arenas }: ArenasManagerProps) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="ui-button h-[42px] px-6 whitespace-nowrap"
|
||||
className="ui-button h-[42px] px-6 whitespace-nowrap min-w-[140px]"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
editingArena ? <Pencil className="w-4 h-4 mr-2" /> : <Plus className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Adicionar
|
||||
{editingArena ? 'Salvar' : 'Adicionar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -74,8 +74,8 @@ export function CreateFinanceEventModal({ isOpen, onClose, players }: CreateFina
|
||||
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="fixed inset-0 z-[100] flex items-start justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200 overflow-y-auto custom-scrollbar">
|
||||
<div className="bg-surface border border-border rounded-xl w-full max-w-lg shadow-2xl overflow-visible flex flex-col my-8">
|
||||
<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>
|
||||
@@ -119,12 +119,14 @@ export function CreateFinanceEventModal({ isOpen, onClose, players }: CreateFina
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DateTimePicker
|
||||
label="Vencimento"
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
mode="date"
|
||||
/>
|
||||
<div className="relative z-[100]">
|
||||
<DateTimePicker
|
||||
label="Vencimento"
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
mode="date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface/50 border border-border p-4 rounded-xl space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
214
src/components/CreateTransactionModal.tsx
Normal file
214
src/components/CreateTransactionModal.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createTransaction } from '@/actions/finance'
|
||||
import { Loader2, Plus, ArrowUpCircle, ArrowDownCircle, Calendar, User, Tag } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { DateTimePicker } from '@/components/DateTimePicker'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface CreateTransactionModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
players: any[]
|
||||
}
|
||||
|
||||
export function CreateTransactionModal({ isOpen, onClose, players }: CreateTransactionModalProps) {
|
||||
const router = useRouter()
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
// Form State
|
||||
const [type, setType] = useState<'INCOME' | 'EXPENSE'>('INCOME')
|
||||
const [description, setDescription] = useState('')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [category, setCategory] = useState('')
|
||||
const [date, setDate] = useState(() => {
|
||||
const d = new Date()
|
||||
const y = d.getFullYear()
|
||||
const m = (d.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = d.getDate().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
})
|
||||
const [playerId, setPlayerId] = useState('')
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!description || !amount || !date) {
|
||||
alert('Por favor, preencha a descrição, valor e data.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsPending(true)
|
||||
try {
|
||||
const numAmount = parseFloat(amount.replace(',', '.'))
|
||||
|
||||
const result = await createTransaction({
|
||||
description,
|
||||
amount: numAmount,
|
||||
type,
|
||||
category,
|
||||
date,
|
||||
playerId: playerId || undefined
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
alert(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setDescription('')
|
||||
setAmount('')
|
||||
setCategory('')
|
||||
setPlayerId('')
|
||||
onClose()
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const categories = type === 'INCOME'
|
||||
? ['Mensalidade', 'Avulso', 'Sobra', 'Patrocínio', 'Outros']
|
||||
: ['Aluguel Quadra', 'Material', 'Churrasco', 'Arbitragem', 'Outros']
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-start justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200 overflow-y-auto custom-scrollbar">
|
||||
<div className="bg-surface border border-border rounded-[2rem] w-full max-w-lg shadow-2xl overflow-visible flex flex-col my-8">
|
||||
<div className="p-8 border-b border-border bg-gradient-to-br from-surface to-background">
|
||||
<h3 className="text-xl font-black uppercase italic tracking-tighter">Nova Movimentação</h3>
|
||||
<p className="text-xs text-muted font-bold uppercase tracking-widest mt-1">Registre entradas ou saídas do caixa.</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-6">
|
||||
{/* Type Selector */}
|
||||
<div className="grid grid-cols-2 gap-3 p-1.5 bg-surface-raised rounded-2xl border border-border">
|
||||
<button
|
||||
onClick={() => setType('INCOME')}
|
||||
className={clsx(
|
||||
"flex items-center justify-center gap-2 py-3 text-xs font-black uppercase tracking-widest rounded-xl transition-all",
|
||||
type === 'INCOME' ? "bg-primary text-background shadow-lg shadow-primary/20" : "text-muted hover:text-white"
|
||||
)}
|
||||
>
|
||||
<ArrowUpCircle className="w-4 h-4" /> Entrada
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setType('EXPENSE')}
|
||||
className={clsx(
|
||||
"flex items-center justify-center gap-2 py-3 text-xs font-black uppercase tracking-widest rounded-xl transition-all",
|
||||
type === 'EXPENSE' ? "bg-red-500 text-white shadow-lg" : "text-muted hover:text-white"
|
||||
)}
|
||||
>
|
||||
<ArrowDownCircle className="w-4 h-4" /> Saída
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="ui-form-field">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-muted ml-1 mb-1.5 block">Descrição</label>
|
||||
<div className="relative group">
|
||||
<Tag className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted group-focus-within:text-primary transition-colors" />
|
||||
<input
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Ex: Pagamento Juiz ou Sobra Mensalidade"
|
||||
className="ui-input w-full pl-11 h-12 bg-surface-raised/50 border-border/50 text-sm font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="ui-form-field">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-muted ml-1 mb-1.5 block">Valor (R$)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
placeholder="0,00"
|
||||
className="ui-input w-full h-12 bg-surface-raised/50 border-border/50 text-base font-black px-4"
|
||||
/>
|
||||
</div>
|
||||
<div className="ui-form-field">
|
||||
<div className="relative z-[150]">
|
||||
<DateTimePicker
|
||||
label="Data"
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
mode="date"
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ui-form-field">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-muted ml-1 mb-1.5 block">Categoria</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setCategory(cat)}
|
||||
className={clsx(
|
||||
"px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border transition-all",
|
||||
category === cat
|
||||
? "bg-primary/20 border-primary text-primary"
|
||||
: "bg-surface-raised border-border text-muted hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
value={!categories.includes(category) ? category : ''}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
placeholder="Outra..."
|
||||
className="px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border bg-surface-raised border-border text-muted hover:border-white/20 outline-none focus:border-primary/50 w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{type === 'INCOME' && (
|
||||
<div className="ui-form-field">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-muted ml-1 mb-1.5 block">Atleta Relacionado (Opcional)</label>
|
||||
<div className="relative group">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted group-focus-within:text-primary transition-colors" />
|
||||
<select
|
||||
value={playerId}
|
||||
onChange={e => setPlayerId(e.target.value)}
|
||||
className="ui-input w-full pl-11 h-12 bg-surface-raised/50 border-border/50 text-sm font-bold appearance-none"
|
||||
>
|
||||
<option value="">Nenhum atleta específico</option>
|
||||
{players.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 border-t border-border flex justify-between gap-4 bg-black/40">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[10px] font-black uppercase tracking-[0.2em] text-muted hover:text-foreground px-6 py-2 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending}
|
||||
className="ui-button px-10 h-14 shadow-xl shadow-primary/20 font-black"
|
||||
>
|
||||
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Plus className="w-5 h-5 mr-3" />}
|
||||
REGISTRAR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
280
src/components/DateRangePicker.tsx
Normal file
280
src/components/DateRangePicker.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react'
|
||||
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, X, Check } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { clsx } from 'clsx'
|
||||
import { format, isWithinInterval, startOfDay, endOfDay, isSameDay } from 'date-fns'
|
||||
import { ptBR } from 'date-fns/locale'
|
||||
|
||||
interface DateRangePickerProps {
|
||||
startDate: string
|
||||
endDate: string
|
||||
onChange: (start: string, end: string) => void
|
||||
label?: string
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DateRangePicker({
|
||||
startDate,
|
||||
endDate,
|
||||
onChange,
|
||||
label,
|
||||
placeholder,
|
||||
className
|
||||
}: DateRangePickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Internal view date for calendar navigation
|
||||
const [viewDate, setViewDate] = useState(() => {
|
||||
if (startDate) return new Date(startDate)
|
||||
return new Date()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Close when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const parseLocalDate = (dateStr: string) => {
|
||||
if (!dateStr) return null
|
||||
const [y, m, d] = dateStr.split('-').map(Number)
|
||||
return new Date(y, m - 1, d)
|
||||
}
|
||||
|
||||
const start = parseLocalDate(startDate)
|
||||
const end = parseLocalDate(endDate)
|
||||
|
||||
const daysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate()
|
||||
const firstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay()
|
||||
|
||||
const calendarDays = useMemo(() => {
|
||||
const year = viewDate.getFullYear()
|
||||
const month = viewDate.getMonth()
|
||||
const days = []
|
||||
|
||||
const prevMonthDays = daysInMonth(year, month - 1)
|
||||
const startDay = firstDayOfMonth(year, month)
|
||||
|
||||
// Previous month days
|
||||
for (let i = startDay - 1; i >= 0; i--) {
|
||||
days.push({
|
||||
date: new Date(month === 0 ? year - 1 : year, month === 0 ? 11 : month - 1, prevMonthDays - i),
|
||||
currentMonth: false
|
||||
})
|
||||
}
|
||||
|
||||
// Current month days
|
||||
const currentMonthDays = daysInMonth(year, month)
|
||||
for (let i = 1; i <= currentMonthDays; i++) {
|
||||
days.push({
|
||||
date: new Date(year, month, i),
|
||||
currentMonth: true
|
||||
})
|
||||
}
|
||||
|
||||
// Next month days
|
||||
const remaining = 42 - days.length
|
||||
for (let i = 1; i <= remaining; i++) {
|
||||
days.push({
|
||||
date: new Date(month === 11 ? year + 1 : year, month === 11 ? 0 : month + 1, i),
|
||||
currentMonth: false
|
||||
})
|
||||
}
|
||||
|
||||
return days
|
||||
}, [viewDate])
|
||||
|
||||
const formatDateToLocal = (date: Date) => {
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getDate().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
const handleDateSelect = (date: Date) => {
|
||||
const dateStr = formatDateToLocal(date)
|
||||
|
||||
// If no selection or range already complete, start fresh with both dates the same
|
||||
if (!start || (start && end && !isSameDay(start, end))) {
|
||||
onChange(dateStr, dateStr)
|
||||
} else if (start && end && isSameDay(start, end)) {
|
||||
// If already have one date (start=end), second click defines the range
|
||||
if (date < start) {
|
||||
onChange(dateStr, formatDateToLocal(start))
|
||||
} else {
|
||||
onChange(formatDateToLocal(start), dateStr)
|
||||
}
|
||||
} else {
|
||||
// Fallback for any other state
|
||||
onChange(dateStr, dateStr)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDisplay = () => {
|
||||
if (!startDate && !endDate) return placeholder || "Selecionar período"
|
||||
|
||||
const startObj = parseLocalDate(startDate)
|
||||
const endObj = parseLocalDate(endDate)
|
||||
|
||||
const startStr = startObj ? format(startObj, 'dd/MM/yy') : '--/--/--'
|
||||
const endStr = endObj ? format(endObj, 'dd/MM/yy') : '--/--/--'
|
||||
return `${startStr} - ${endStr}`
|
||||
}
|
||||
|
||||
const months = [
|
||||
'Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho',
|
||||
'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'
|
||||
]
|
||||
|
||||
const nextMonth = () => {
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
|
||||
}
|
||||
|
||||
const prevMonth = () => {
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("ui-form-field", className)} ref={containerRef}>
|
||||
{label && <label className="text-[10px] font-bold text-muted/60 uppercase ml-1 mb-1 block">{label}</label>}
|
||||
<div className="relative">
|
||||
<div
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={clsx(
|
||||
"ui-input w-full h-9 flex items-center justify-between cursor-pointer transition-all bg-background border-border/40",
|
||||
isOpen ? "border-primary ring-1 ring-primary/20 shadow-lg shadow-primary/5" : ""
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className={clsx("w-3.5 h-3.5 transition-colors", isOpen ? "text-primary" : "text-muted")} />
|
||||
<span className={clsx("text-xs transition-colors", !startDate && "text-muted/60")}>
|
||||
{!mounted ? (placeholder || "Selecionar período") : formatDisplay()}
|
||||
</span>
|
||||
</div>
|
||||
{(startDate || endDate) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange('', '')
|
||||
}}
|
||||
className="p-1 hover:bg-white/10 rounded-md text-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="absolute top-full left-0 mt-2 z-[100] bg-surface-raised border border-border shadow-2xl rounded-2xl overflow-hidden w-[310px]"
|
||||
>
|
||||
<div className="p-6 bg-surface shadow-[0_0_50px_rgba(0,0,0,0.5)]">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-base font-black uppercase italic tracking-tighter leading-none text-white">
|
||||
{months[viewDate.getMonth()]} <span className="text-primary/60">{viewDate.getFullYear()}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); prevMonth(); }}
|
||||
className="p-1.5 bg-white/5 hover:bg-white/10 border border-white/5 rounded-lg transition-all text-muted hover:text-primary active:scale-90"
|
||||
>
|
||||
<ChevronLeft className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); nextMonth(); }}
|
||||
className="p-1.5 bg-white/5 hover:bg-white/10 border border-white/5 rounded-lg transition-all text-muted hover:text-primary active:scale-90"
|
||||
>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{['D', 'S', 'T', 'Q', 'Q', 'S', 'S'].map((d, i) => (
|
||||
<div key={i} className="text-[8px] font-black text-muted/30 text-center uppercase tracking-widest py-1">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{calendarDays.map((d, i) => {
|
||||
const isSelected = (start && isSameDay(d.date, start)) || (end && isSameDay(d.date, end))
|
||||
const isInRange = start && end && isWithinInterval(d.date, { start, end })
|
||||
const isToday = isSameDay(new Date(), d.date)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => handleDateSelect(d.date)}
|
||||
className={clsx(
|
||||
"w-full aspect-square text-[10px] font-bold rounded-lg transition-all flex items-center justify-center relative border overflow-hidden",
|
||||
d.currentMonth ? "text-foreground" : "text-muted/10",
|
||||
isSelected
|
||||
? "bg-primary text-background border-primary shadow-[0_0_15px_rgba(16,185,129,0.4)] z-10"
|
||||
: isInRange
|
||||
? "bg-primary/20 border-primary/20 text-primary rounded-none shadow-inner"
|
||||
: "bg-surface-raised border-white/5 hover:border-primary/40 hover:bg-primary/5 hover:text-primary",
|
||||
!isSelected && isToday && "border-primary/40 ring-1 ring-primary/20",
|
||||
isInRange && isSameDay(d.date, start!) && "rounded-l-lg",
|
||||
isInRange && isSameDay(d.date, end!) && "rounded-r-lg"
|
||||
)}
|
||||
>
|
||||
<span className="relative z-10">{d.date.getDate()}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const today = new Date()
|
||||
const todayStr = formatDateToLocal(today)
|
||||
onChange(todayStr, todayStr)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className="flex-1 py-1.5 bg-white/5 hover:bg-white/10 rounded-lg text-[9px] font-black uppercase tracking-widest transition-all"
|
||||
>
|
||||
Hoje
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex-1 py-1.5 bg-primary text-background rounded-lg text-[9px] font-black uppercase tracking-widest transition-all"
|
||||
>
|
||||
Pronto
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ interface DateTimePickerProps {
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
mode?: 'date' | 'datetime'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DateTimePicker({
|
||||
@@ -20,7 +21,8 @@ export function DateTimePicker({
|
||||
label,
|
||||
placeholder,
|
||||
required,
|
||||
mode = 'datetime'
|
||||
mode = 'datetime',
|
||||
className
|
||||
}: DateTimePickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
@@ -112,15 +114,18 @@ export function DateTimePicker({
|
||||
}, [viewDate])
|
||||
|
||||
const handleDateSelect = (day: number, month: number, year: number) => {
|
||||
const newDate = parseValue(value)
|
||||
newDate.setFullYear(year)
|
||||
newDate.setMonth(month)
|
||||
newDate.setDate(day)
|
||||
|
||||
if (mode === 'date') {
|
||||
onChange(newDate.toISOString().split('T')[0])
|
||||
const y = year
|
||||
const m = (month + 1).toString().padStart(2, '0')
|
||||
const d = day.toString().padStart(2, '0')
|
||||
onChange(`${y}-${m}-${d}`)
|
||||
setIsOpen(false)
|
||||
} else {
|
||||
const newDate = parseValue(value)
|
||||
newDate.setFullYear(year)
|
||||
newDate.setMonth(month)
|
||||
newDate.setDate(day)
|
||||
|
||||
// If it's the first time selecting in datetime mode, set a default time
|
||||
if (!value) {
|
||||
newDate.setHours(19, 0, 0, 0)
|
||||
@@ -169,7 +174,7 @@ export function DateTimePicker({
|
||||
const minutes = Array.from({ length: 12 }, (_, i) => i * 5)
|
||||
|
||||
return (
|
||||
<div className="ui-form-field" ref={containerRef}>
|
||||
<div className={clsx("ui-form-field", className)} ref={containerRef}>
|
||||
{label && <label className="text-label ml-1">{label}</label>}
|
||||
<div className="relative">
|
||||
<div
|
||||
@@ -258,7 +263,7 @@ export function DateTimePicker({
|
||||
"w-full aspect-square text-xs font-black rounded-xl transition-all flex items-center justify-center relative border overflow-hidden group",
|
||||
d.currentMonth ? "text-foreground" : "text-muted/10",
|
||||
isSelected
|
||||
? "bg-primary text-black border-primary shadow-[0_0_15px_rgba(var(--primary-rgb),0.3)] scale-105 z-10"
|
||||
? "bg-primary text-background border-primary shadow-[0_0_15px_rgba(var(--primary-rgb),0.3)] scale-105 z-10"
|
||||
: "bg-white/[0.02] border-white/5 hover:border-primary/40 hover:bg-primary/5 hover:text-primary",
|
||||
!isSelected && isToday && "border-primary/20 after:content-[''] after:absolute after:bottom-1.5 after:w-1 after:h-1 after:bg-primary after:rounded-full"
|
||||
)}
|
||||
@@ -334,7 +339,7 @@ export function DateTimePicker({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="mt-6 w-full h-11 bg-white text-black font-black uppercase text-[10px] tracking-widest rounded-xl hover:scale-[1.02] active:scale-[0.98] transition-all shadow-xl shadow-black/20"
|
||||
className="mt-6 w-full h-11 bg-foreground text-background font-black uppercase text-[10px] tracking-widest rounded-xl hover:scale-[1.02] active:scale-[0.98] transition-all shadow-xl shadow-black/20"
|
||||
>
|
||||
Confirmar
|
||||
</button>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useMemo, useEffect } 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 { Calendar, Users, Trophy, ChevronRight, X, Clock, ExternalLink, Star, Link as LinkIcon, MapPin, Share2, Shuffle, Trash2, MessageCircle, Repeat, Search, LayoutGrid, List, Check, Pencil, Zap } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { clsx } from 'clsx'
|
||||
import Link from 'next/link'
|
||||
@@ -150,10 +150,36 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
|
||||
|
||||
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' }
|
||||
case 'CONVOCACAO': return {
|
||||
label: 'Convocação',
|
||||
color: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
|
||||
actionLabel: 'Clique para Sortear',
|
||||
icon: Shuffle
|
||||
}
|
||||
case 'SORTEIO': return {
|
||||
label: 'Times Definidos',
|
||||
color: 'bg-amber-500/10 text-amber-500 border-amber-500/20',
|
||||
actionLabel: 'Gerar Capas / Iniciar Resenha',
|
||||
icon: Zap
|
||||
}
|
||||
case 'IN_PROGRESS': return {
|
||||
label: 'Votação Aberta',
|
||||
color: 'bg-orange-500/10 text-orange-500 border-orange-500/20',
|
||||
actionLabel: 'Ver Acompanhamento',
|
||||
icon: Users
|
||||
}
|
||||
case 'ENCERRAMENTO': return {
|
||||
label: 'Resenha Finalizada',
|
||||
color: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
|
||||
actionLabel: 'Ver Resultados',
|
||||
icon: Trophy
|
||||
}
|
||||
default: return {
|
||||
label: status,
|
||||
color: 'bg-white/5 text-muted border-white/10',
|
||||
actionLabel: 'Gerenciar',
|
||||
icon: Pencil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,32 +205,44 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
|
||||
}
|
||||
|
||||
const shareWhatsAppList = (match: any) => {
|
||||
const confirmed = (match.attendances || []).filter((a: any) => a.status === 'CONFIRMED')
|
||||
const confirmedIds = new Set(confirmed.map((a: any) => a.playerId))
|
||||
const pending = players.filter(p => !confirmedIds.has(p.id))
|
||||
|
||||
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 url = `${window.location.origin}/match/${match.id}/confirmacao`
|
||||
const finalGroupName = (match.group?.name || groupName).toUpperCase()
|
||||
|
||||
const finalGroupName = match.group?.name || groupName
|
||||
let text = ''
|
||||
|
||||
const text = `⚽ *LISTA DE PRESENÇA: ${finalGroupName.toUpperCase()}* ⚽\n\n` +
|
||||
`📅 *JOGO:* ${dateStr} às ${timeStr}\n` +
|
||||
`📍 *LOCAL:* ${match.location || 'A definir'}\n\n` +
|
||||
`✅ *CONFIRMADOS (${confirmed.length}/${match.maxPlayers || '∞'}):*\n` +
|
||||
(confirmed.length > 0
|
||||
? confirmed.map((a: any) => `✅ ${a.player.name}`).join('\n')
|
||||
: "_Nenhuma confirmação ainda_") +
|
||||
`\n\n⏳ *AGUARDANDO:* \n` +
|
||||
(pending.length > 0
|
||||
? pending.map((p: any) => `▫️ ${p.name}`).join('\n')
|
||||
: "_Todos confirmados!_") +
|
||||
`\n\n🔗 *Confirme sua presença aqui:* ${url}`
|
||||
if (match.status === 'CONVOCACAO') {
|
||||
const confirmed = (match.attendances || []).filter((a: any) => a.status === 'CONFIRMED')
|
||||
const confirmedIds = new Set(confirmed.map((a: any) => a.playerId))
|
||||
const pending = players.filter(p => !confirmedIds.has(p.id))
|
||||
|
||||
text = `⚽ *LISTA DE PRESENÇA: ${finalGroupName}* ⚽\n\n` +
|
||||
`📅 *JOGO:* ${dateStr} às ${timeStr}\n` +
|
||||
`📍 *LOCAL:* ${match.location || 'A definir'}\n\n` +
|
||||
`✅ *CONFIRMADOS (${confirmed.length}/${match.maxPlayers || '∞'}):*\n` +
|
||||
(confirmed.length > 0
|
||||
? confirmed.map((a: any) => `✅ ${a.player.name}`).join('\n')
|
||||
: "_Nenhuma confirmação ainda_") +
|
||||
`\n\n⏳ *AGUARDANDO:* \n` +
|
||||
(pending.length > 0
|
||||
? pending.map((p: any) => `▫️ ${p.name}`).join('\n')
|
||||
: "_Todos confirmados!_") +
|
||||
`\n\n🔗 *Confirme sua presença aqui:* ${url}`
|
||||
} else {
|
||||
// SORTEIO or ENCERRAMENTO
|
||||
text = `⚽ *SORTEIO REALIZADO: ${finalGroupName}* ⚽\n\n` +
|
||||
`📅 *JOGO:* ${dateStr} às ${timeStr}\n` +
|
||||
`📍 *LOCAL:* ${match.location || 'A definir'}\n\n` +
|
||||
(match.teams || []).map((team: any) => {
|
||||
const playersList = (team.players || []).map((p: any) => `▫️ ${p.player.name}`).join('\n')
|
||||
return `👕 *${team.name.toUpperCase()}:*\n${playersList}`
|
||||
}).join('\n\n') +
|
||||
`\n\n🔗 *Veja os detalhes e vote no craque:* ${url}`
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopySuccess('Lista formatada copiada!')
|
||||
setCopySuccess('Texto para WhatsApp copiado!')
|
||||
setTimeout(() => setCopySuccess(null), 2000)
|
||||
|
||||
window.open(`https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`, '_blank')
|
||||
@@ -359,7 +397,7 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
|
||||
<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`}
|
||||
{match.status === 'CONVOCACAO' ? `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">
|
||||
@@ -367,18 +405,43 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
|
||||
</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`}
|
||||
{viewMode === 'grid' && (
|
||||
<div className="w-full mb-3">
|
||||
<Link
|
||||
href={`/dashboard/matches/new?id=${match.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={clsx(
|
||||
"w-full flex items-center justify-center gap-2 py-2.5 rounded-xl border text-[10px] font-black uppercase tracking-widest transition-all hover:shadow-lg shadow-sm border-white/5",
|
||||
s.color
|
||||
)}
|
||||
>
|
||||
<s.icon className="w-4 h-4" />
|
||||
{s.actionLabel}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
<span className="font-bold">
|
||||
{match.status === 'CONVOCACAO'
|
||||
? `${(match.attendances || []).filter((a: any) => a.status === 'CONFIRMED').length} confirmados`
|
||||
: `${(match.teams || []).reduce((acc: number, t: any) => acc + t.players.length, 0)} jogadores`}
|
||||
</span>
|
||||
</div>
|
||||
<div className={clsx(
|
||||
"px-2 py-0.5 rounded-full text-[8px] font-black uppercase tracking-widest border",
|
||||
s.color
|
||||
)}>
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1 h-1 rounded-full bg-border" />
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -388,20 +451,33 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
|
||||
|
||||
{/* 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' && (
|
||||
<div className="ml-auto flex items-center gap-4 opacity-0 group-hover:opacity-100 transition-all duration-300">
|
||||
<Link
|
||||
href={`/dashboard/matches/new?id=${match.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-xl border text-[9px] font-black uppercase tracking-[0.15em] transition-all hover:scale-105 active:scale-95 shadow-lg",
|
||||
s.color
|
||||
)}
|
||||
title={s.actionLabel}
|
||||
>
|
||||
<s.icon className="w-3.5 h-3.5" />
|
||||
<span>{s.actionLabel}</span>
|
||||
</Link>
|
||||
|
||||
{match.status === 'CONVOCACAO' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
copyMatchLink(match)
|
||||
}}
|
||||
className="p-1.5 text-primary hover:bg-primary/10 rounded transition-colors"
|
||||
className="p-2 text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors border border-transparent hover:border-primary/20"
|
||||
title="Copiar Link de Confirmação"
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<ChevronRight className="w-4 h-4 text-muted/40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -478,6 +554,17 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/dashboard/matches/new?id=${selectedMatch.id}`}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-xl border text-[10px] font-black uppercase tracking-widest transition-all hover:scale-105 active:scale-95 shadow-md",
|
||||
getStatusInfo(selectedMatch.status).color
|
||||
)}
|
||||
title={getStatusInfo(selectedMatch.status).actionLabel}
|
||||
>
|
||||
{React.createElement(getStatusInfo(selectedMatch.status).icon, { className: "w-4 h-4" })}
|
||||
<span>{getStatusInfo(selectedMatch.status).actionLabel}</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDeleteMatch(selectedMatch.id)}
|
||||
className="p-2.5 text-muted hover:text-red-500 transition-colors rounded-lg"
|
||||
@@ -495,7 +582,7 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto custom-scrollbar">
|
||||
{selectedMatch.status === 'SCHEDULED' ? (
|
||||
{selectedMatch.status === 'CONVOCACAO' ? (
|
||||
<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">
|
||||
@@ -631,6 +718,17 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedMatch.status === ('SORTEIO' as any) && (
|
||||
<div className="pt-4">
|
||||
<Link
|
||||
href={`/dashboard/matches/new?id=${selectedMatch.id}`}
|
||||
className="ui-button w-full h-12 text-sm font-bold bg-primary text-background shadow-lg shadow-primary/20"
|
||||
>
|
||||
<Zap className="w-4 h-4 mr-2" /> Iniciar Gamificação & Votação
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
199
src/components/MatchPodium.tsx
Normal file
199
src/components/MatchPodium.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Trophy, Medal, Star, Crown, Zap, Shield, Sparkles } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface PlayerResult {
|
||||
player: {
|
||||
id: string
|
||||
name: string
|
||||
number?: number | string
|
||||
position?: string
|
||||
level?: number
|
||||
}
|
||||
craque: number
|
||||
pereba: number
|
||||
fairPlay: number
|
||||
}
|
||||
|
||||
interface MatchPodiumProps {
|
||||
results: PlayerResult[]
|
||||
context?: 'dashboard' | 'public'
|
||||
}
|
||||
|
||||
export function MatchPodium({ results, context = 'public' }: MatchPodiumProps) {
|
||||
// Calculando saldo e ordenando (caso não venha ordenado)
|
||||
// Critério: (Craque - Pereba) DESC, depois Craque DESC, depois FairPlay DESC
|
||||
const sortedResults = [...results].sort((a, b) => {
|
||||
const scoreA = a.craque - a.pereba
|
||||
const scoreB = b.craque - b.pereba
|
||||
if (scoreB !== scoreA) return scoreB - scoreA
|
||||
if (b.craque !== a.craque) return b.craque - a.craque
|
||||
return b.fairPlay - a.fairPlay
|
||||
})
|
||||
|
||||
const top3 = sortedResults.slice(0, 3)
|
||||
// Reorganizar para ordem visual: 2º, 1º, 3º
|
||||
const podiumOrder = [
|
||||
top3[1], // 2nd Place (Left)
|
||||
top3[0], // 1st Place (Center)
|
||||
top3[2] // 3rd Place (Right)
|
||||
].filter(Boolean) // Remove undefined if less than 3 players
|
||||
|
||||
const getInitials = (name: string) => name.split(' ').slice(0, 2).map(n => n[0]).join('').toUpperCase()
|
||||
|
||||
const PodiumItem = ({ result, place, index }: { result: PlayerResult, place: 1 | 2 | 3, index: number }) => {
|
||||
if (!result) return <div className="w-full" />
|
||||
|
||||
const isWinner = place === 1
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
delay: index * 0.2 + 0.5,
|
||||
type: "spring",
|
||||
stiffness: 100
|
||||
}}
|
||||
className={clsx(
|
||||
"flex flex-col items-center relative z-10",
|
||||
isWinner ? "-mt-6 mb-6 md:-mt-10 md:mb-10 order-1 md:order-2 w-full md:w-1/3" : "mt-0 order-2 md:order-none w-1/2 md:w-1/4",
|
||||
place === 3 && "order-3"
|
||||
)}
|
||||
>
|
||||
{/* Crown for Winner */}
|
||||
{isWinner && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0, rotate: -45 }}
|
||||
animate={{ opacity: 1, scale: 1, rotate: 0 }}
|
||||
transition={{ delay: 1.2, type: "spring" }}
|
||||
className="mb-2 md:mb-4 relative"
|
||||
>
|
||||
<Crown className="w-8 h-8 md:w-12 md:h-12 text-yellow-400 fill-yellow-400 drop-shadow-[0_0_15px_rgba(250,204,21,0.6)]" />
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute -inset-10 bg-gradient-to-t from-yellow-500/20 to-transparent blur-xl rounded-full"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Avatar / Card */}
|
||||
<div className={clsx(
|
||||
"relative rounded-2xl md:rounded-3xl border flex flex-col items-center justify-center shadow-2xl backdrop-blur-md transition-all",
|
||||
isWinner
|
||||
? "w-28 h-28 md:w-48 md:h-48 bg-gradient-to-b from-yellow-500/10 to-transparent border-yellow-500/30"
|
||||
: place === 2
|
||||
? "w-20 h-20 md:w-36 md:h-36 bg-gradient-to-b from-slate-300/10 to-transparent border-slate-300/20"
|
||||
: "w-20 h-20 md:w-36 md:h-36 bg-gradient-to-b from-orange-700/10 to-transparent border-orange-700/20"
|
||||
)}>
|
||||
{isWinner && <div className="absolute inset-0 bg-yellow-400/5 blur-2xl rounded-full animate-pulse" />}
|
||||
|
||||
<div className="text-2xl md:text-5xl font-black italic relative z-10">
|
||||
<span className={clsx(
|
||||
"drop-shadow-lg",
|
||||
isWinner ? "text-yellow-400" : place === 2 ? "text-slate-300" : "text-orange-400"
|
||||
)}>
|
||||
{result.player.number || getInitials(result.player.name)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Badge Place */}
|
||||
<div className={clsx(
|
||||
"absolute -bottom-3 px-3 py-1 md:px-4 md:py-1.5 rounded-full border text-[8px] md:text-xs font-black uppercase tracking-widest shadow-lg flex items-center gap-1 md:gap-2 whitespace-nowrap",
|
||||
isWinner
|
||||
? "bg-yellow-500 text-background border-yellow-400"
|
||||
: place === 2
|
||||
? "bg-slate-300 text-background border-slate-200"
|
||||
: "bg-orange-600 text-white border-orange-500"
|
||||
)}>
|
||||
{place}º LUGAR
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name & Stats */}
|
||||
<div className="text-center mt-4 md:mt-8 space-y-1 md:space-y-2 w-full px-1">
|
||||
<h3 className={clsx(
|
||||
"font-black uppercase italic tracking-tighter truncate w-full",
|
||||
isWinner ? "text-lg md:text-4xl text-white" : "text-xs md:text-xl text-muted"
|
||||
)}>
|
||||
{result.player.name}
|
||||
{isWinner && <Sparkles className="inline-block w-3 h-3 md:w-6 md:h-6 text-yellow-400 ml-1 md:ml-2 animate-bounce" />}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 md:gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-[8px] md:text-[10px] uppercase font-bold text-muted">Saldo</span>
|
||||
<span className={clsx("text-sm md:text-2xl font-black", (result.craque - result.pereba) > 0 ? "text-emerald-500" : "text-red-500")}>
|
||||
{result.craque - result.pereba}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-px h-6 md:h-8 bg-white/10" />
|
||||
<div className="flex items-center gap-1 md:gap-2 text-[8px] md:text-xs">
|
||||
<div className="flex flex-col items-center">
|
||||
<Star className="w-2.5 h-2.5 md:w-4 md:h-4 text-emerald-500 mb-0.5 md:mb-1" />
|
||||
<span className="font-bold">{result.craque}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<Zap className="w-2.5 h-2.5 md:w-4 md:h-4 text-red-500 mb-0.5 md:mb-1" />
|
||||
<span className="font-bold">{result.pereba}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-12">
|
||||
{/* TOP 3 PODIUM */}
|
||||
<div className="flex flex-wrap items-end justify-center gap-4 md:gap-8 pt-10 min-h-[300px] md:min-h-[400px]">
|
||||
{/* 2nd Place */}
|
||||
{podiumOrder[0] && <PodiumItem result={podiumOrder[0]} place={2} index={1} />}
|
||||
|
||||
{/* 1st Place */}
|
||||
{podiumOrder[1] && <PodiumItem result={podiumOrder[1]} place={1} index={0} />}
|
||||
|
||||
{/* 3rd Place */}
|
||||
{podiumOrder[2] && <PodiumItem result={podiumOrder[2]} place={3} index={2} />}
|
||||
</div>
|
||||
|
||||
{/* OTHER PLAYERS LIST */}
|
||||
{sortedResults.length > 3 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 2 }}
|
||||
className="max-w-3xl mx-auto pt-8 border-t border-white/5"
|
||||
>
|
||||
<h4 className="text-center text-xs font-black uppercase tracking-[0.3em] text-muted mb-6">Demais Classificados</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{sortedResults.slice(3).map((res, i) => (
|
||||
<div key={res.player.id} className="flex items-center justify-between p-4 bg-white/5 rounded-xl border border-white/5 hover:bg-white/10 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs font-black text-muted w-6">#{i + 4}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-black/20 flex items-center justify-center font-bold text-[10px]">
|
||||
{res.player.number || getInitials(res.player.name)}
|
||||
</div>
|
||||
<span className="text-xs font-bold uppercase">{res.player.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<span className={clsx("text-sm font-black", (res.craque - res.pereba) > 0 ? "text-emerald-500" : "text-white/50")}>
|
||||
{res.craque - res.pereba} pts
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
301
src/components/MatchScheduler.tsx
Normal file
301
src/components/MatchScheduler.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react'
|
||||
import { Calendar, MapPin, ArrowRight, Trophy, Repeat, Hash, ChevronRight } from 'lucide-react'
|
||||
import { createScheduledMatch } from '@/actions/match'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import type { Arena } from '@prisma/client'
|
||||
import { DateTimePicker } from '@/components/DateTimePicker'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface MatchSchedulerProps {
|
||||
arenas: Arena[]
|
||||
}
|
||||
|
||||
export function MatchScheduler({ arenas }: MatchSchedulerProps) {
|
||||
const router = useRouter()
|
||||
const [date, setDate] = useState('')
|
||||
const [location, setLocation] = useState('')
|
||||
const [selectedArenaId, setSelectedArenaId] = useState('')
|
||||
const [maxPlayers, setMaxPlayers] = useState('24')
|
||||
const [isRecurring, setIsRecurring] = useState(false)
|
||||
const [recurrenceInterval, setRecurrenceInterval] = useState<'WEEKLY' | 'MONTHLY' | 'YEARLY'>('WEEKLY')
|
||||
const [recurrenceEndDate, setRecurrenceEndDate] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const previewDates = useMemo(() => {
|
||||
if (!date || !isRecurring) return []
|
||||
|
||||
const dates: Date[] = []
|
||||
const startDate = new Date(date)
|
||||
let currentDate = new Date(startDate)
|
||||
|
||||
// Increment based on interval
|
||||
const advanceDate = (d: Date) => {
|
||||
const next = new Date(d)
|
||||
if (recurrenceInterval === 'WEEKLY') next.setDate(next.getDate() + 7)
|
||||
else if (recurrenceInterval === 'MONTHLY') next.setMonth(next.getMonth() + 1)
|
||||
else if (recurrenceInterval === 'YEARLY') next.setFullYear(next.getFullYear() + 1)
|
||||
return next
|
||||
}
|
||||
|
||||
currentDate = advanceDate(currentDate)
|
||||
|
||||
let endDate: Date
|
||||
if (recurrenceEndDate) {
|
||||
endDate = new Date(`${recurrenceEndDate}T23:59:59`)
|
||||
} else {
|
||||
// Preview next 4 occurrences
|
||||
let previewEnd = new Date(startDate)
|
||||
for (let i = 0; i < 4; i++) previewEnd = advanceDate(previewEnd)
|
||||
endDate = previewEnd
|
||||
}
|
||||
|
||||
while (currentDate <= endDate) {
|
||||
dates.push(new Date(currentDate))
|
||||
currentDate = advanceDate(currentDate)
|
||||
if (dates.length > 10) break
|
||||
}
|
||||
|
||||
return dates
|
||||
}, [date, isRecurring, recurrenceInterval, recurrenceEndDate])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!date) return
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await createScheduledMatch(
|
||||
'',
|
||||
date,
|
||||
location,
|
||||
parseInt(maxPlayers) || 0,
|
||||
isRecurring,
|
||||
recurrenceInterval,
|
||||
recurrenceEndDate || undefined,
|
||||
selectedArenaId
|
||||
)
|
||||
router.push('/dashboard/matches')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
alert('Erro ao agendar evento')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const intervals = [
|
||||
{ id: 'WEEKLY', label: 'Semanal', desc: 'Toda semana' },
|
||||
{ id: 'MONTHLY', label: 'Mensal', desc: 'Todo mês' },
|
||||
{ id: 'YEARLY', label: 'Anual', desc: 'Todo ano' },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto pb-20 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<header className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-12">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-2 text-primary font-black uppercase tracking-widest text-[10px] mb-2 px-3 py-1 bg-primary/10 rounded-full border border-primary/20">
|
||||
<Calendar className="w-3 h-3" />
|
||||
Agendamento
|
||||
</div>
|
||||
<h1 className="text-4xl font-black tracking-tighter uppercase leading-none">
|
||||
Criar <span className="text-primary text-outline-sm">Novo Evento</span>
|
||||
</h1>
|
||||
<p className="text-muted text-sm font-medium">Configure as datas e crie links de confirmação automáticos.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8">
|
||||
<form onSubmit={handleSubmit} className="lg:col-span-3 space-y-6">
|
||||
<section className="ui-card p-6 space-y-6 bg-surface-raised/30 border-border/40">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center border border-primary/20">
|
||||
<Trophy className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-sm font-black uppercase tracking-widest">Detalhes do Evento</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative z-[110]">
|
||||
<DateTimePicker
|
||||
label="Data e Horário de Início"
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ui-form-field">
|
||||
<label className="text-label ml-1">Arena ou Local</label>
|
||||
<div className="space-y-3">
|
||||
<div className="relative group">
|
||||
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted group-focus-within:text-primary transition-colors z-10" />
|
||||
<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 pl-10 h-12 bg-surface text-sm appearance-none"
|
||||
>
|
||||
<option value="">Selecione uma Arena Salva...</option>
|
||||
{arenas.map(a => (
|
||||
<option key={a.id} value={a.id}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronRight className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted rotate-90 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<input
|
||||
required={!selectedArenaId}
|
||||
type="text"
|
||||
placeholder={selectedArenaId ? "Complemento do local (opcional)" : "Ou digite um local personalizado..."}
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
className="ui-input w-full h-12 bg-surface/50 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ui-form-field">
|
||||
<label className="text-label ml-1">Capacidade</label>
|
||||
<div className="relative group">
|
||||
<Hash 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
|
||||
type="number"
|
||||
placeholder="Ex: 24 (Deixe vazio para ilimitado)"
|
||||
value={maxPlayers}
|
||||
onChange={(e) => setMaxPlayers(e.target.value)}
|
||||
className="ui-input w-full pl-10 h-12 bg-surface text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ui-card p-6 space-y-6 bg-surface-raised/30 border-border/40 relative overflow-visible">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-500/10 flex items-center justify-center border border-purple-500/20 text-purple-500">
|
||||
<Repeat className="w-4 h-4" />
|
||||
</div>
|
||||
<h2 className="text-sm font-black uppercase tracking-widest">Recorrência</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRecurring(!isRecurring)}
|
||||
className={clsx(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ring-offset-2 ring-primary/20",
|
||||
isRecurring ? "bg-primary" : "bg-zinc-800"
|
||||
)}
|
||||
>
|
||||
<span className={clsx(
|
||||
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200",
|
||||
isRecurring ? "translate-x-6" : "translate-x-1"
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isRecurring && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="space-y-6 pt-2"
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{intervals.map((int) => (
|
||||
<button
|
||||
key={int.id}
|
||||
type="button"
|
||||
onClick={() => setRecurrenceInterval(int.id as any)}
|
||||
className={clsx(
|
||||
"p-4 rounded-xl border flex flex-col items-center gap-1 transition-all",
|
||||
recurrenceInterval === int.id
|
||||
? "bg-primary/10 border-primary text-primary shadow-lg shadow-primary/5"
|
||||
: "bg-surface border-border hover:border-zinc-700 text-muted"
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">{int.label}</span>
|
||||
<span className="text-[9px] opacity-60 font-medium">{int.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 relative z-[100]">
|
||||
<DateTimePicker
|
||||
label="Data Limite"
|
||||
value={recurrenceEndDate}
|
||||
onChange={setRecurrenceEndDate}
|
||||
mode="date"
|
||||
placeholder="Selecione a data limite"
|
||||
/>
|
||||
<p className="text-[10px] text-muted mt-2 px-1">
|
||||
Deixe em branco para repetir indefinidamente.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !date}
|
||||
className="ui-button w-full h-14 text-sm font-black uppercase tracking-[0.2em] shadow-xl shadow-primary/20 relative group overflow-hidden"
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||
{isSubmitting ? 'Agendando...' : 'Confirmar Agendamento'}
|
||||
{!isSubmitting && <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<aside className="lg:col-span-2 space-y-6">
|
||||
<div className="ui-card p-6 border-emerald-500/20 bg-emerald-500/5 sticky top-24">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/20 flex items-center justify-center text-emerald-500">
|
||||
<Trophy className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="font-bold text-sm uppercase tracking-tight">Próximos Passos</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ title: 'Link Gerado', desc: 'Será criada uma página de confirmação para compartilhar com a galera.' },
|
||||
{ title: 'Gestão Automática', desc: 'O sistema controla quem confirmou e quem ainda está pendente.' },
|
||||
].map((step, i) => (
|
||||
<div key={i} className="flex gap-4">
|
||||
<div className="w-6 h-6 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-[10px] font-black text-emerald-500 shrink-0">
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] font-black uppercase tracking-widest text-emerald-500/80">{step.title}</p>
|
||||
<p className="text-[11px] text-muted leading-relaxed">{step.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isRecurring && previewDates.length > 0 && (
|
||||
<div className="mt-8 pt-8 border-t border-emerald-500/10">
|
||||
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-muted mb-4">Prévia da Agenda:</h4>
|
||||
<div className="space-y-2">
|
||||
{previewDates.map((d, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-[11px] py-1 border-b border-white/5 last:border-0">
|
||||
<span className="text-zinc-400 font-medium">#{i + 2}</span>
|
||||
<span className="font-black text-white">{d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'long' })}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
'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 { Plus, Trash2, UserPlus, Star, Search, Filter, MoreHorizontal, User, Shield, Target, Zap, ChevronDown, LayoutGrid, List, ChevronRight, Check, X, AlertCircle, Pencil } from 'lucide-react'
|
||||
import { addPlayer, deletePlayer, deletePlayers, updatePlayer } from '@/actions/player'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { clsx } from 'clsx'
|
||||
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
|
||||
@@ -16,8 +16,9 @@ export function PlayersList({ group }: { group: any }) {
|
||||
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 [isSaving, setIsSaving] = useState(false)
|
||||
const [isFormOpen, setIsFormOpen] = useState(false)
|
||||
const [editingPlayer, setEditingPlayer] = useState<any | null>(null)
|
||||
|
||||
// Pagination & Selection
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
@@ -40,27 +41,53 @@ export function PlayersList({ group }: { group: any }) {
|
||||
description: ''
|
||||
})
|
||||
|
||||
const handleAddPlayer = async (e: React.FormEvent) => {
|
||||
const handleSavePlayer = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
if (!newPlayerName) return
|
||||
|
||||
setIsAdding(true)
|
||||
setIsSaving(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)
|
||||
|
||||
if (editingPlayer) {
|
||||
await updatePlayer(editingPlayer.id, {
|
||||
name: newPlayerName,
|
||||
level,
|
||||
number: playerNumber,
|
||||
position
|
||||
})
|
||||
} else {
|
||||
await addPlayer(group.id, newPlayerName, level, playerNumber, position)
|
||||
}
|
||||
|
||||
closeForm()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setIsAdding(false)
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const closeForm = () => {
|
||||
setNewPlayerName('')
|
||||
setLevel(3)
|
||||
setNumber('')
|
||||
setPosition('MEI')
|
||||
setEditingPlayer(null)
|
||||
setIsFormOpen(false)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const openEditForm = (player: any) => {
|
||||
setEditingPlayer(player)
|
||||
setNewPlayerName(player.name)
|
||||
setLevel(player.level)
|
||||
setNumber(player.number?.toString() || '')
|
||||
setPosition(player.position as any)
|
||||
setIsFormOpen(true)
|
||||
}
|
||||
|
||||
const filteredPlayers = useMemo(() => {
|
||||
return group.players.filter((p: any) => {
|
||||
const matchesSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
@@ -174,7 +201,6 @@ export function PlayersList({ group }: { group: any }) {
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
setIsFormOpen(true)
|
||||
}}
|
||||
className="ui-button w-full sm:w-auto shadow-lg shadow-primary/20"
|
||||
@@ -193,7 +219,7 @@ export function PlayersList({ group }: { group: any }) {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsFormOpen(false)}
|
||||
onClick={closeForm}
|
||||
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
@@ -205,22 +231,26 @@ export function PlayersList({ group }: { group: any }) {
|
||||
<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" />
|
||||
{editingPlayer ? <Pencil className="w-6 h-6 text-primary" /> : <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>
|
||||
<h3 className="text-lg font-bold tracking-tight">
|
||||
{editingPlayer ? 'Editar Atleta' : 'Novo Atleta'}
|
||||
</h3>
|
||||
<p className="text-xs text-muted font-medium uppercase tracking-wider">
|
||||
{editingPlayer ? 'Atualizar informações' : 'Adicionar ao elenco'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsFormOpen(false)}
|
||||
onClick={closeForm}
|
||||
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">
|
||||
<form onSubmit={handleSavePlayer} 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" />
|
||||
@@ -337,17 +367,17 @@ export function PlayersList({ group }: { group: any }) {
|
||||
<div className="pt-4 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFormOpen(false)}
|
||||
onClick={closeForm}
|
||||
className="ui-button-ghost flex-1 h-12"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isAdding || !newPlayerName}
|
||||
disabled={isSaving || !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'}
|
||||
{isSaving ? <div className="w-5 h-5 border-2 border-background/30 border-t-background rounded-full animate-spin" /> : (editingPlayer ? 'Salvar Alterações' : 'Confirmar Cadastro')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -538,16 +568,28 @@ export function PlayersList({ group }: { group: any }) {
|
||||
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 className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
openEditForm(p)
|
||||
}}
|
||||
className="p-2 text-muted hover:text-primary hover:bg-primary/10 rounded-xl transition-all opacity-0 lg:group-hover:opacity-100"
|
||||
title="Editar Atleta"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface SettingsFormProps {
|
||||
initialData: {
|
||||
@@ -186,6 +187,7 @@ export function SettingsForm({ initialData }: SettingsFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { MapPin, Palette, Briefcase } from 'lucide-react'
|
||||
import { MapPin, Palette, Briefcase, Shirt, Zap } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
interface SettingsTabsProps {
|
||||
branding: React.ReactNode
|
||||
teams: React.ReactNode
|
||||
arenas: React.ReactNode
|
||||
sponsors: React.ReactNode
|
||||
voting: React.ReactNode
|
||||
}
|
||||
|
||||
export function SettingsTabs({ branding, arenas, sponsors }: SettingsTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<'branding' | 'arenas' | 'sponsors'>('branding')
|
||||
export function SettingsTabs({ branding, teams, arenas, sponsors, voting }: SettingsTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<'branding' | 'teams' | 'arenas' | 'sponsors' | 'voting'>('branding')
|
||||
|
||||
const tabs = [
|
||||
{ id: 'branding', label: 'Identidade Visual', icon: Palette },
|
||||
{ id: 'voting', label: 'Votação & Resenha', icon: Zap },
|
||||
{ id: 'teams', label: 'Times', icon: Shirt },
|
||||
{ id: 'arenas', label: 'Locais & Arenas', icon: MapPin },
|
||||
{ id: 'sponsors', label: 'Patrocínios', icon: Briefcase },
|
||||
] 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">
|
||||
<div className="flex p-1 bg-surface-raised rounded-xl border border-border w-full sm:w-fit overflow-x-auto">
|
||||
{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",
|
||||
"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 whitespace-nowrap",
|
||||
activeTab === tab.id
|
||||
? "bg-primary text-background shadow-lg shadow-emerald-500/10"
|
||||
: "text-muted hover:text-foreground hover:bg-white/5"
|
||||
@@ -53,6 +57,28 @@ export function SettingsTabs({ branding, arenas, sponsors }: SettingsTabsProps)
|
||||
{branding}
|
||||
</motion.div>
|
||||
)}
|
||||
{activeTab === 'voting' && (
|
||||
<motion.div
|
||||
key="voting"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{voting}
|
||||
</motion.div>
|
||||
)}
|
||||
{activeTab === 'teams' && (
|
||||
<motion.div
|
||||
key="teams"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{teams}
|
||||
</motion.div>
|
||||
)}
|
||||
{activeTab === 'arenas' && (
|
||||
<motion.div
|
||||
key="arenas"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { createSponsor, deleteSponsor } from '@/actions/sponsor'
|
||||
import { Briefcase, Plus, Trash2, Loader2, Image as ImageIcon } from 'lucide-react'
|
||||
import { createSponsor, deleteSponsor, updateSponsor } from '@/actions/sponsor'
|
||||
import { Briefcase, Plus, Trash2, Loader2, Image as ImageIcon, Pencil, X } from 'lucide-react'
|
||||
import type { Sponsor } from '@prisma/client'
|
||||
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
|
||||
|
||||
@@ -14,6 +14,7 @@ interface SponsorsManagerProps {
|
||||
export function SponsorsManager({ groupId, sponsors }: SponsorsManagerProps) {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [filePreview, setFilePreview] = useState<string | null>(null)
|
||||
const [editingSponsor, setEditingSponsor] = useState<Sponsor | null>(null)
|
||||
const [deleteModal, setDeleteModal] = useState<{
|
||||
isOpen: boolean
|
||||
sponsorId: string | null
|
||||
@@ -48,6 +49,19 @@ export function SponsorsManager({ groupId, sponsors }: SponsorsManagerProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = (sponsor: Sponsor) => {
|
||||
setEditingSponsor(sponsor)
|
||||
setFilePreview(sponsor.logoUrl || null)
|
||||
document.getElementById('sponsor-form')?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingSponsor(null)
|
||||
setFilePreview(null)
|
||||
const form = document.getElementById('sponsor-form') as HTMLFormElement
|
||||
form?.reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-card p-8 space-y-8">
|
||||
<header>
|
||||
@@ -76,14 +90,23 @@ export function SponsorsManager({ groupId, sponsors }: SponsorsManagerProps) {
|
||||
<p className="text-[10px] text-muted font-medium mt-0.5">Patrocinador Ativo</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(sponsor.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 patrocinador"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleEdit(sponsor)}
|
||||
className="p-2 text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||
title="Editar patrocinador"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(sponsor.id)}
|
||||
disabled={isPending}
|
||||
className="p-2 text-muted hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
title="Excluir patrocinador"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -97,22 +120,41 @@ export function SponsorsManager({ groupId, sponsors }: SponsorsManagerProps) {
|
||||
|
||||
<form action={(formData) => {
|
||||
const name = formData.get('name') as string
|
||||
const logoFile = formData.get('logo') as File
|
||||
if (!name) return
|
||||
|
||||
startTransition(async () => {
|
||||
await createSponsor(formData)
|
||||
const form = document.getElementById('sponsor-form') as HTMLFormElement
|
||||
form?.reset()
|
||||
setFilePreview(null)
|
||||
if (editingSponsor) {
|
||||
await updateSponsor(editingSponsor.id, formData)
|
||||
} else {
|
||||
await createSponsor(formData)
|
||||
}
|
||||
cancelEdit()
|
||||
})
|
||||
}} id="sponsor-form" className="pt-8 mt-8 border-t border-border">
|
||||
}} id="sponsor-form" className="pt-8 mt-8 border-t border-border space-y-4">
|
||||
<input type="hidden" name="groupId" value={groupId} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-bold uppercase tracking-widest text-primary">
|
||||
{editingSponsor ? 'Editando Patrocinador' : 'Adicionar Novo Patrocinador'}
|
||||
</h4>
|
||||
{editingSponsor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelEdit}
|
||||
className="text-[10px] font-bold uppercase text-muted hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<X className="w-3 h-3" /> Cancelar Edição
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 items-end">
|
||||
<div className="ui-form-field md:col-span-5">
|
||||
<label className="text-label ml-1">Nome da Empresa</label>
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={editingSponsor?.name || ''}
|
||||
key={editingSponsor?.id || 'new'}
|
||||
placeholder="Ex: Pizzaria do Vale"
|
||||
className="ui-input w-full"
|
||||
required
|
||||
@@ -162,9 +204,9 @@ export function SponsorsManager({ groupId, sponsors }: SponsorsManagerProps) {
|
||||
{isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
editingSponsor ? <Pencil className="w-4 h-4 mr-2" /> : <Plus className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Adicionar
|
||||
{editingSponsor ? 'Salvar' : 'Adicionar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
262
src/components/TeamsManager.tsx
Normal file
262
src/components/TeamsManager.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { createTeamConfig, deleteTeamConfig, updateTeamConfig } from '@/actions/team-config'
|
||||
import { Shirt, Plus, Trash2, Loader2, Image as ImageIcon, Pencil, X } from 'lucide-react'
|
||||
// @ts-ignore
|
||||
import type { TeamConfig } from '@prisma/client'
|
||||
import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal'
|
||||
|
||||
interface TeamsManagerProps {
|
||||
groupId: string
|
||||
teams: TeamConfig[]
|
||||
}
|
||||
|
||||
export function TeamsManager({ groupId, teams }: TeamsManagerProps) {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [filePreview, setFilePreview] = useState<string | null>(null)
|
||||
const [selectedColor, setSelectedColor] = useState('#10b981')
|
||||
const [editingTeam, setEditingTeam] = useState<TeamConfig | null>(null)
|
||||
const [deleteModal, setDeleteModal] = useState<{
|
||||
isOpen: boolean
|
||||
teamId: string | null
|
||||
isDeleting: boolean
|
||||
}>({
|
||||
isOpen: false,
|
||||
teamId: null,
|
||||
isDeleting: false
|
||||
})
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setDeleteModal({
|
||||
isOpen: true,
|
||||
teamId: id,
|
||||
isDeleting: false
|
||||
})
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteModal.teamId) return
|
||||
|
||||
setDeleteModal(prev => ({ ...prev, isDeleting: true }))
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteTeamConfig(deleteModal.teamId!)
|
||||
setDeleteModal({ isOpen: false, teamId: null, isDeleting: false })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
alert('Erro ao excluir time.')
|
||||
setDeleteModal(prev => ({ ...prev, isDeleting: false }))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = (team: TeamConfig) => {
|
||||
setEditingTeam(team)
|
||||
setSelectedColor(team.color)
|
||||
setFilePreview(team.shirtUrl || null)
|
||||
// Scroll to form
|
||||
document.getElementById('team-form')?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingTeam(null)
|
||||
setSelectedColor('#10b981')
|
||||
setFilePreview(null)
|
||||
const form = document.getElementById('team-form') as HTMLFormElement
|
||||
form?.reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-card p-8 space-y-8">
|
||||
<header>
|
||||
<h3 className="font-semibold text-lg flex items-center gap-2">
|
||||
<Shirt className="w-5 h-5 text-primary" />
|
||||
Times Padrão
|
||||
</h3>
|
||||
<p className="text-muted text-sm">
|
||||
Configure os nomes, cores e camisas dos times do seu futebol.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{teams.map((team) => (
|
||||
<div key={team.id} className="relative group p-6 rounded-2xl border border-border bg-surface-raised/50 hover:border-primary/50 transition-all duration-300">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div
|
||||
className="w-20 h-20 rounded-full flex items-center justify-center border-4 relative overflow-hidden transition-transform group-hover:scale-105"
|
||||
style={{
|
||||
backgroundColor: team.color + '20',
|
||||
borderColor: team.color
|
||||
}}
|
||||
>
|
||||
{team.shirtUrl ? (
|
||||
<img src={team.shirtUrl} alt={team.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Shirt className="w-10 h-10 transition-colors" style={{ color: team.color }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-bold text-foreground uppercase text-sm tracking-widest">{team.name}</p>
|
||||
<div className="flex items-center justify-center gap-2 mt-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: team.color }} />
|
||||
<span className="text-[10px] text-muted font-mono uppercase">{team.color}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-4 right-4 flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleEdit(team)}
|
||||
className="p-2 text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||
title="Editar time"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(team.id)}
|
||||
disabled={isPending}
|
||||
className="p-2 text-muted hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
title="Excluir time"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{teams.length === 0 && (
|
||||
<div className="col-span-full text-center py-12 px-4 border border-dashed border-border rounded-2xl bg-surface/50">
|
||||
<Shirt className="w-12 h-12 text-muted mx-auto mb-4 opacity-30" />
|
||||
<p className="text-muted text-sm uppercase font-bold tracking-widest">Nenhum time configurado</p>
|
||||
<p className="text-xs text-muted/60 mt-2">Adicione os times que costumam jogar na sua pelada.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form action={(formData) => {
|
||||
const name = formData.get('name') as string
|
||||
if (!name) return
|
||||
|
||||
startTransition(async () => {
|
||||
if (editingTeam) {
|
||||
await updateTeamConfig(editingTeam.id, formData)
|
||||
} else {
|
||||
await createTeamConfig(formData)
|
||||
}
|
||||
cancelEdit()
|
||||
})
|
||||
}} id="team-form" className="pt-8 mt-8 border-t border-border space-y-6">
|
||||
<input type="hidden" name="groupId" value={groupId} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-bold uppercase tracking-widest text-primary">
|
||||
{editingTeam ? 'Editando Time' : 'Adicionar Novo Time'}
|
||||
</h4>
|
||||
{editingTeam && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelEdit}
|
||||
className="text-[10px] font-bold uppercase text-muted hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<X className="w-3 h-3" /> Cancelar Edição
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 items-end">
|
||||
<div className="ui-form-field md:col-span-4">
|
||||
<label className="text-label ml-1">Nome do Time</label>
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={editingTeam?.name || ''}
|
||||
key={editingTeam?.id || 'new'}
|
||||
placeholder="Ex: Time Branco"
|
||||
className="ui-input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ui-form-field md:col-span-3">
|
||||
<label className="text-label ml-1">Cor do Time</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
name="color"
|
||||
value={selectedColor}
|
||||
onChange={(e) => setSelectedColor(e.target.value)}
|
||||
className="w-12 h-[42px] p-1 bg-surface-raised border border-border rounded-lg cursor-pointer"
|
||||
/>
|
||||
<div className="flex-1 px-3 h-[42px] flex items-center bg-surface border border-border rounded-lg text-xs font-mono text-muted uppercase">
|
||||
{selectedColor}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ui-form-field md:col-span-3">
|
||||
<label className="text-label ml-1">Camisa (Opcional)</label>
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="file"
|
||||
name="shirt"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
id="team-shirt-upload"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => setFilePreview(reader.result as string)
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="team-shirt-upload"
|
||||
className="ui-input w-full flex items-center gap-3 cursor-pointer group-hover:border-primary/50 transition-all bg-surface"
|
||||
>
|
||||
<div className="w-8 h-8 rounded bg-surface-raised flex items-center justify-center border border-white/5 overflow-hidden">
|
||||
{filePreview ? (
|
||||
<img src={filePreview} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<ImageIcon className="w-4 h-4 text-muted" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted truncate">
|
||||
{filePreview ? 'Alterar imagem' : 'Selecionar camisa...'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="ui-button h-[42px] w-full"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{editingTeam ? <Pencil className="w-4 h-4 mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
|
||||
{editingTeam ? 'Salvar' : 'Adicionar'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DeleteConfirmationModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={() => setDeleteModal({ isOpen: false, teamId: null, isDeleting: false })}
|
||||
onConfirm={confirmDelete}
|
||||
isDeleting={deleteModal.isDeleting}
|
||||
title="Excluir Time?"
|
||||
description="Tem certeza que deseja remover este time das configurações? Isso não afetará jogos passados."
|
||||
confirmText="Sim, remover"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
467
src/components/VotingFlow.tsx
Normal file
467
src/components/VotingFlow.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Star, Shield, Zap, CheckCircle2, User, Users, Trophy, Check, ArrowRight, ArrowLeft, Search, X } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { submitReviews } from '@/actions/match'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface VotingFlowProps {
|
||||
match: any
|
||||
allPlayers: any[]
|
||||
initialVoters: any[]
|
||||
}
|
||||
|
||||
function CountdownTimer({ endTime }: { endTime: Date }) {
|
||||
const [timeLeft, setTimeLeft] = useState<{ h: number, m: number, s: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const now = new Date().getTime()
|
||||
const distance = endTime.getTime() - now
|
||||
|
||||
if (distance < 0) {
|
||||
clearInterval(interval)
|
||||
setTimeLeft(null)
|
||||
window.location.reload() // Refresh to show expired screen
|
||||
return
|
||||
}
|
||||
|
||||
setTimeLeft({
|
||||
h: Math.floor(distance / (1000 * 60 * 60)),
|
||||
m: Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)),
|
||||
s: Math.floor((distance % (1000 * 60)) / 1000)
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [endTime])
|
||||
|
||||
if (!timeLeft) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 bg-orange-500/10 border border-orange-500/20 rounded-full">
|
||||
<Zap className="w-3 h-3 text-orange-500 animate-pulse" />
|
||||
<span className="text-[9px] font-black uppercase tracking-widest text-orange-500">
|
||||
A votação fecha em: {String(timeLeft.h).padStart(2, '0')}:{String(timeLeft.m).padStart(2, '0')}:{String(timeLeft.s).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function VotingFlow({ match, allPlayers, initialVoters }: VotingFlowProps) {
|
||||
const [step, setStep] = useState<'identity' | 'voting' | 'success'>('identity')
|
||||
const [selectedReviewerId, setSelectedReviewerId] = useState('')
|
||||
const [currentPlayerIndex, setCurrentPlayerIndex] = useState(0)
|
||||
const [votes, setVotes] = useState<Record<string, string>>({})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [voters, setVoters] = useState(initialVoters)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const filteredPlayers = allPlayers.filter(p => p.id !== selectedReviewerId)
|
||||
const currentPlayer = filteredPlayers[currentPlayerIndex]
|
||||
const progress = (Object.keys(votes).length / filteredPlayers.length) * 100
|
||||
|
||||
const handleSelectIdentity = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setSelectedReviewerId(e.target.value)
|
||||
}
|
||||
|
||||
const startVoting = () => {
|
||||
if (selectedReviewerId) {
|
||||
if (filteredPlayers.length === 0) {
|
||||
// If no one to vote for (e.g. only 1 player total), skip to success
|
||||
finishVoting()
|
||||
return
|
||||
}
|
||||
setStep('voting')
|
||||
}
|
||||
}
|
||||
|
||||
const handleVote = (type: string) => {
|
||||
if (!currentPlayer) return
|
||||
|
||||
setVotes(prev => ({ ...prev, [currentPlayer.id]: type }))
|
||||
|
||||
if (currentPlayerIndex < filteredPlayers.length - 1) {
|
||||
setCurrentPlayerIndex(prev => prev + 1)
|
||||
} else {
|
||||
// Last player voted, automatically finish or show finish button?
|
||||
// Let's keep it on the last player so they can review if needed
|
||||
}
|
||||
}
|
||||
|
||||
const finishVoting = async () => {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const reviewsArray = Object.entries(votes).map(([playerId, type]) => ({
|
||||
playerId,
|
||||
type
|
||||
}))
|
||||
|
||||
await submitReviews(match.id, reviewsArray, selectedReviewerId)
|
||||
|
||||
// Add current player to voters list visually
|
||||
const reviewer = allPlayers.find(p => p.id === selectedReviewerId)
|
||||
if (reviewer && !voters.some(v => v.id === reviewer.id)) {
|
||||
setVoters(prev => [...prev, reviewer])
|
||||
}
|
||||
|
||||
setStep('success')
|
||||
} catch (error) {
|
||||
console.error('Error submitting votes:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
if (currentPlayerIndex > 0) {
|
||||
setCurrentPlayerIndex(prev => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const skipPlayer = () => {
|
||||
if (currentPlayerIndex < filteredPlayers.length - 1) {
|
||||
setCurrentPlayerIndex(prev => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 'identity') {
|
||||
const sortedPlayers = [...allPlayers].sort((a, b) => a.name.localeCompare(b.name))
|
||||
const filteredSearchPlayers = sortedPlayers.filter(p =>
|
||||
p.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-8 py-8 md:py-12 px-4">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="flex justify-center mb-4">
|
||||
<CountdownTimer endTime={new Date(new Date(match.actualEndTime || match.date).getTime() + (match.votingDuration || 72) * 60 * 60 * 1000)} />
|
||||
</div>
|
||||
<h2 className="text-4xl md:text-5xl font-black uppercase italic tracking-tighter leading-none">
|
||||
Quem está <br /><span className="text-primary italic">na resenha?</span>
|
||||
</h2>
|
||||
<p className="text-muted text-[10px] font-bold uppercase tracking-[0.4em]">Selecione seu nome para começar</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Search Bar */}
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-5 flex items-center pointer-events-none">
|
||||
<Search className="w-5 h-5 text-muted group-focus-within:text-primary transition-colors" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="PROCURAR MEU NOME..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full h-16 bg-zinc-900/60 border border-white/5 rounded-3xl pl-14 pr-14 text-sm font-black uppercase tracking-widest focus:border-primary/50 focus:ring-0 transition-all outline-none backdrop-blur-xl"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute inset-y-0 right-5 flex items-center text-muted hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Players Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredSearchPlayers.map((p) => {
|
||||
const isSelected = selectedReviewerId === p.id
|
||||
const hasAlreadyVoted = voters.some(v => v.id === p.id)
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={p.id}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
onClick={() => !hasAlreadyVoted && setSelectedReviewerId(p.id)}
|
||||
disabled={hasAlreadyVoted}
|
||||
className={clsx(
|
||||
"flex items-center gap-4 p-4 rounded-2xl border transition-all text-left relative overflow-hidden group",
|
||||
isSelected
|
||||
? "bg-primary border-primary text-black shadow-[0_0_20px_rgba(16,185,129,0.2)]"
|
||||
: hasAlreadyVoted
|
||||
? "bg-zinc-900 border-white/5 opacity-50 grayscale cursor-not-allowed"
|
||||
: "bg-white/5 border-white/5 text-white hover:border-white/20 hover:bg-white/[0.08]"
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center shrink-0 border transition-colors",
|
||||
isSelected ? "bg-black/10 border-black/10" : "bg-black/40 border-white/5"
|
||||
)}>
|
||||
{isSelected ? <Check className="w-5 h-5" /> : hasAlreadyVoted ? <CheckCircle2 className="w-5 h-5 text-muted" /> : <User className="w-5 h-5 text-muted" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[11px] font-black uppercase tracking-tight truncate">{p.name}</p>
|
||||
<p className={clsx(
|
||||
"text-[8px] font-bold uppercase tracking-widest",
|
||||
isSelected ? "text-black/60" : "text-muted"
|
||||
)}>{p.position || 'Jogador'}</p>
|
||||
</div>
|
||||
|
||||
{hasAlreadyVoted && (
|
||||
<div className="absolute top-2 right-2 px-2 py-0.5 bg-black/40 rounded text-[7px] font-black uppercase tracking-widest text-muted border border-white/5">
|
||||
JÁ VOTOU
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection Glow */}
|
||||
{isSelected && (
|
||||
<motion.div
|
||||
layoutId="selected-glow"
|
||||
className="absolute inset-0 bg-white/10 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
|
||||
{filteredSearchPlayers.length === 0 && (
|
||||
<div className="col-span-full py-12 text-center space-y-3 opacity-40">
|
||||
<Search className="w-10 h-10 mx-auto text-muted" />
|
||||
<p className="text-[10px] font-black uppercase tracking-widest">Nenhum jogador encontrado</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={startVoting}
|
||||
disabled={!selectedReviewerId}
|
||||
className="ui-button w-full h-16 bg-white text-black font-black uppercase tracking-[0.4em] text-xs shadow-[0_0_50px_rgba(255,255,255,0.1)] disabled:opacity-20 hover:scale-[1.02] transition-all flex items-center justify-center gap-3 rounded-3xl"
|
||||
>
|
||||
INICIAR RESENHA <ArrowRight className="w-4 h-4 ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === 'voting') {
|
||||
if (!currentPlayer) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-12 text-center">
|
||||
<p className="text-muted text-sm pb-4">Carregando jogador ou nenhum jogador para votar...</p>
|
||||
<button onClick={() => setStep('identity')} className="ui-button-ghost">Voltar</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6 md:space-y-8 py-4 md:py-8 animate-in fade-in duration-700 px-4 md:px-0">
|
||||
{/* Progress Bar */}
|
||||
<div className="space-y-3 px-2">
|
||||
<div className="flex items-center justify-between text-[10px] font-black uppercase tracking-widest text-muted">
|
||||
<span>Resenha</span>
|
||||
<span>{currentPlayerIndex + 1} / {filteredPlayers.length}</span>
|
||||
</div>
|
||||
<div className="h-1.5 md:h-2 w-full bg-white/5 rounded-full overflow-hidden border border-white/5">
|
||||
<motion.div
|
||||
className="h-full bg-primary shadow-[0_0_15px_#10b981]"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${((currentPlayerIndex + 1) / filteredPlayers.length) * 100}%` }}
|
||||
transition={{ type: 'spring', stiffness: 50 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Player Card Container */}
|
||||
<div className="relative min-h-[450px] md:min-h-[500px] flex items-center justify-center">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentPlayer.id}
|
||||
initial={{ opacity: 0, x: 50, scale: 0.95 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: -50, scale: 0.95 }}
|
||||
transition={{ duration: 0.4, ease: "circOut" }}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="ui-card p-8 md:p-12 bg-surface border-white/10 rounded-[2.5rem] md:rounded-[3rem] shadow-2xl relative overflow-hidden group text-center space-y-8 md:space-y-10">
|
||||
{/* Decorator Background */}
|
||||
<div className="absolute top-0 inset-x-0 h-40 bg-gradient-to-b from-primary/5 to-transparent pointer-events-none" />
|
||||
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="w-20 h-20 md:w-24 md:h-24 rounded-2xl md:rounded-[2rem] bg-zinc-950 border border-white/10 mx-auto flex items-center justify-center relative overflow-hidden">
|
||||
<span className="text-xl md:text-2xl font-black italic relative z-10">{currentPlayer.number || '??'}</span>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent" />
|
||||
<div className="absolute -bottom-2 -right-2 w-10 md:w-12 h-10 md:h-12 bg-primary/10 blur-xl rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<h3 className="text-2xl md:text-3xl font-black uppercase italic tracking-tighter truncate px-2">{currentPlayer.name}</h3>
|
||||
<p className="text-[10px] md:text-[12px] font-black uppercase tracking-[0.4em] md:tracking-[0.5em] text-primary">{currentPlayer.position}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voting Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 md:gap-4 h-auto md:h-48">
|
||||
<button
|
||||
onClick={() => handleVote('CRAQUE')}
|
||||
className={clsx(
|
||||
"flex md:flex-col items-center justify-center gap-4 py-4 md:py-0 rounded-2xl md:rounded-[2.5rem] border transition-all group/btn",
|
||||
votes[currentPlayer.id] === 'CRAQUE' ? "bg-primary border-primary text-black shadow-lg shadow-primary/20" : "bg-black/40 border-white/5 text-muted hover:border-primary/50"
|
||||
)}
|
||||
>
|
||||
<div className={clsx("w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl flex items-center justify-center transition-transform md:group-hover/btn:scale-110", votes[currentPlayer.id] === 'CRAQUE' ? "bg-black/10" : "bg-primary/10")}>
|
||||
<Star className={clsx("w-5 h-5 md:w-8 md:h-8", votes[currentPlayer.id] === 'CRAQUE' ? "fill-black" : "text-primary")} />
|
||||
</div>
|
||||
<span className="text-[10px] md:text-[10px] font-black uppercase tracking-[0.3em]">Craque</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleVote('FAIR_PLAY')}
|
||||
className={clsx(
|
||||
"flex md:flex-col items-center justify-center gap-4 py-4 md:py-0 rounded-2xl md:rounded-[2.5rem] border transition-all group/btn",
|
||||
votes[currentPlayer.id] === 'FAIR_PLAY' ? "bg-blue-500 border-blue-500 text-white shadow-lg shadow-blue-500/20" : "bg-black/40 border-white/5 text-muted hover:border-blue-500/50"
|
||||
)}
|
||||
>
|
||||
<div className={clsx("w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl flex items-center justify-center transition-transform md:group-hover/btn:scale-110", votes[currentPlayer.id] === 'FAIR_PLAY' ? "bg-white/10" : "bg-blue-500/10")}>
|
||||
<Shield className="w-5 h-5 md:w-8 md:h-8" />
|
||||
</div>
|
||||
<span className="text-[10px] md:text-[10px] font-black uppercase tracking-[0.3em]">Equilibrado</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleVote('PEREBA')}
|
||||
className={clsx(
|
||||
"flex md:flex-col items-center justify-center gap-4 py-4 md:py-0 rounded-2xl md:rounded-[2.5rem] border transition-all group/btn",
|
||||
votes[currentPlayer.id] === 'PEREBA' ? "bg-red-500 border-red-500 text-white shadow-lg shadow-red-500/20" : "bg-black/40 border-white/5 text-muted hover:border-red-500/50"
|
||||
)}
|
||||
>
|
||||
<div className={clsx("w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl flex items-center justify-center transition-transform md:group-hover/btn:scale-110", votes[currentPlayer.id] === 'PEREBA' ? "bg-white/10" : "bg-red-500/10")}>
|
||||
<Zap className="w-5 h-5 md:w-8 md:h-8" />
|
||||
</div>
|
||||
<span className="text-[10px] md:text-[10px] font-black uppercase tracking-[0.3em]">Pereba</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Navigation Controls */}
|
||||
<div className="flex items-center justify-between px-2 md:px-6 pb-8 md:pb-0">
|
||||
<button
|
||||
onClick={goBack}
|
||||
disabled={currentPlayerIndex === 0}
|
||||
className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-muted hover:text-white disabled:opacity-0 transition-all p-2"
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" /> <span className="hidden sm:inline">Anterior</span>
|
||||
</button>
|
||||
|
||||
{currentPlayerIndex === filteredPlayers.length - 1 && votes[currentPlayer.id] ? (
|
||||
<button
|
||||
onClick={finishVoting}
|
||||
disabled={isSubmitting}
|
||||
className="ui-button h-12 md:h-14 px-8 md:px-12 bg-white text-black text-[10px] font-black uppercase tracking-[0.3em] shadow-2xl hover:scale-105 transition-all flex items-center gap-2 md:gap-3"
|
||||
>
|
||||
{isSubmitting ? 'Salvando...' : 'Finalizar'} <Check className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={skipPlayer}
|
||||
className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-muted hover:text-white transition-all p-2"
|
||||
>
|
||||
<span className="hidden sm:inline">Pular</span> <ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (step === 'success') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] gap-8 md:gap-12 py-8 md:py-0 px-4 animate-in fade-in zoom-in duration-1000">
|
||||
<div className="ui-card p-10 md:p-16 text-center space-y-6 md:space-y-8 max-w-md bg-black/40 backdrop-blur-3xl border-primary/20 shadow-[0_0_100px_rgba(16,185,129,0.1)] rounded-[2.50rem] md:rounded-[3rem] relative overflow-hidden w-full">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-primary blur-3xl opacity-20 scale-150" />
|
||||
<div className="w-20 h-20 md:w-24 md:h-24 bg-primary text-background rounded-[1.5rem] md:rounded-3xl flex items-center justify-center mx-auto relative z-10 shadow-[0_0_30px_rgba(16,185,129,0.4)]">
|
||||
<CheckCircle2 className="w-10 h-10 md:w-12 md:h-12" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl md:text-4xl font-black uppercase italic tracking-tighter leading-none">Voto <br />Contabilizado!</h1>
|
||||
<p className="text-muted text-[10px] font-bold uppercase tracking-[0.4em] leading-relaxed px-4">
|
||||
Sua opinião é o que faz o TemFut real. <br />Obrigado por fortalecer a resenha!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 md:pt-8 border-t border-white/5 opacity-50 flex items-center justify-center gap-3">
|
||||
<Trophy className="w-4 h-4 text-primary" />
|
||||
<span className="text-[9px] font-black uppercase tracking-[0.5em]">TemFut Gamification Engine</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voter Status */}
|
||||
<div className="max-w-2xl w-full">
|
||||
<div className="ui-card p-6 md:p-8 bg-zinc-900/40 border-white/5 rounded-[2rem] md:rounded-[2.5rem] backdrop-blur-md">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between mb-8 pb-4 border-b border-white/5 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-sm font-black uppercase italic tracking-widest text-white/80">Monitor da Resenha</h3>
|
||||
</div>
|
||||
<div className="px-4 py-1.5 bg-primary/10 border border-primary/20 rounded-full">
|
||||
<span className="text-[10px] font-black text-primary tracking-widest">{voters.length} / {allPlayers.length} VOTARAM</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 md:gap-3">
|
||||
{allPlayers.map((p: any) => {
|
||||
const hasVoted = voters.some((v: any) => v.id === p.id)
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className={clsx(
|
||||
"p-2 md:p-3 rounded-xl border transition-all flex items-center gap-2 md:gap-3",
|
||||
hasVoted
|
||||
? "bg-primary/5 border-primary/20 text-primary"
|
||||
: "bg-white/5 border-white/10 text-muted opacity-30"
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
"w-1.5 h-1.5 md:w-2 md:h-2 rounded-full",
|
||||
hasVoted ? "bg-primary shadow-[0_0_8px_#10b981]" : "bg-zinc-800"
|
||||
)} />
|
||||
<span className="text-[9px] md:text-[10px] font-black uppercase tracking-tight truncate flex-1">{p.name}</span>
|
||||
{hasVoted && <Check className="w-3 h-3" />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
131
src/components/VotingSettings.tsx
Normal file
131
src/components/VotingSettings.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { updateGroupSettings } from '@/app/actions'
|
||||
import { Save, Loader2, AlertCircle, CheckCircle, Zap } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface VotingSettingsProps {
|
||||
votingEnabled: boolean
|
||||
}
|
||||
|
||||
export function VotingSettings({ votingEnabled: initialVotingState }: VotingSettingsProps) {
|
||||
const [votingEnabled, setVotingEnabled] = useState(initialVotingState)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSuccessMsg(null)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('votingEnabled', votingEnabled ? 'true' : 'false')
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await updateGroupSettings(formData)
|
||||
if (res.success) {
|
||||
setSuccessMsg('Configurações de votação salvas!')
|
||||
setTimeout(() => {
|
||||
setSuccessMsg(null)
|
||||
router.refresh()
|
||||
}, 2000)
|
||||
} else {
|
||||
setError(res.error || 'Erro ao salvar.')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError('Erro inesperado.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 animate-in fade-in duration-500 max-w-2xl">
|
||||
<div className="ui-card p-8 space-y-8">
|
||||
<div className="flex items-start gap-6">
|
||||
<div className={clsx(
|
||||
"p-4 rounded-2xl border transition-all",
|
||||
votingEnabled ? "bg-primary/10 border-primary/20 text-primary" : "bg-surface-raised border-border text-muted"
|
||||
)}>
|
||||
<Zap className="w-8 h-8" />
|
||||
</div>
|
||||
<div className="space-y-2 flex-1">
|
||||
<h3 className="text-xl font-bold tracking-tight">Sistema de Resenha (Gamificação)</h3>
|
||||
<p className="text-muted text-sm leading-relaxed">
|
||||
Permite que os jogadores votem no "Craque", "Pereba" e "Equilibrado" após cada partida.
|
||||
Isso gera um ranking divertido e engajamento no grupo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-surface-raised/30 rounded-2xl border border-white/5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold uppercase text-sm tracking-wider">Habilitar Votação</span>
|
||||
<div
|
||||
onClick={() => setVotingEnabled(!votingEnabled)}
|
||||
className={clsx(
|
||||
"w-14 h-7 rounded-full relative cursor-pointer transition-all",
|
||||
votingEnabled ? "bg-primary shadow-[0_0_15px_rgba(16,185,129,0.3)]" : "bg-zinc-800"
|
||||
)}
|
||||
>
|
||||
<div className={clsx("absolute top-1 w-5 h-5 bg-white rounded-full transition-all shadow-sm", votingEnabled ? "left-8" : "left-1")} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted font-mono">
|
||||
{votingEnabled
|
||||
? "STATUS: O sistema de votação aparecerá automaticamente ao encerrar as partidas."
|
||||
: "STATUS: O sistema está desativado. Nenhuma opção de voto será mostrada."
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-white/5">
|
||||
<p className="text-[10px] text-muted uppercase tracking-widest font-bold text-center">
|
||||
Regras Atuais: Craque (+1), Equilibrado (0), Pereba (-1)
|
||||
</p>
|
||||
</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>
|
||||
)}
|
||||
{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>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="ui-button w-full sm:w-auto h-12 px-8 shadow-xl shadow-primary/10 text-sm font-bold uppercase tracking-widest"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Salvando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Salvar Preferências
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user