From 4e6926f7a60746f91305fdb5f8f249172a57b16c Mon Sep 17 00:00:00 2001 From: Erik Silva Date: Wed, 4 Feb 2026 19:38:51 -0300 Subject: [PATCH] feat: adiciona horario e icone de relogio na pagina de confirmacao --- .agent/rules/regras.md | 5 + next-env.d.ts | 2 +- package-lock.json | 11 + package.json | 1 + prisma/schema.prisma | 80 +- src/actions/arena.ts | 22 + src/actions/finance.ts | 64 ++ src/actions/match.ts | 274 +++++- src/actions/sponsor.ts | 18 + src/actions/team-config.ts | 65 ++ .../(dashboard)/dashboard/financial/page.tsx | 13 +- .../dashboard/matches/new/page.tsx | 4 +- .../dashboard/matches/schedule/page.tsx | 327 +------ src/app/(dashboard)/dashboard/page.tsx | 2 +- .../(dashboard)/dashboard/settings/page.tsx | 7 + src/app/actions.ts | 30 +- src/app/api/admin/groups/[id]/route.ts | 4 +- src/app/globals.css | 1 + src/app/match/[id]/confirmacao/page.tsx | 42 +- src/app/match/[id]/vote/page.tsx | 179 ++++ src/app/match/[id]/vote/success/page.tsx | 76 ++ src/components/ArenasManager.tsx | 77 +- src/components/CreateFinanceEventModal.tsx | 18 +- src/components/CreateTransactionModal.tsx | 214 +++++ src/components/DateRangePicker.tsx | 280 ++++++ src/components/DateTimePicker.tsx | 25 +- src/components/FinancialDashboard.tsx | 870 +++++++++++++++--- src/components/MatchFlow.tsx | 841 +++++++++++++---- src/components/MatchHistory.tsx | 176 +++- src/components/MatchPodium.tsx | 199 ++++ src/components/MatchScheduler.tsx | 301 ++++++ src/components/PlayersList.tsx | 106 ++- src/components/SettingsForm.tsx | 2 + src/components/SettingsTabs.tsx | 36 +- src/components/SponsorsManager.tsx | 78 +- src/components/TeamsManager.tsx | 262 ++++++ src/components/VotingFlow.tsx | 467 ++++++++++ src/components/VotingSettings.tsx | 131 +++ src/utils/MatchCardCanvas.ts | 235 +++-- 39 files changed, 4743 insertions(+), 802 deletions(-) create mode 100644 .agent/rules/regras.md create mode 100644 src/actions/team-config.ts create mode 100644 src/app/match/[id]/vote/page.tsx create mode 100644 src/app/match/[id]/vote/success/page.tsx create mode 100644 src/components/CreateTransactionModal.tsx create mode 100644 src/components/DateRangePicker.tsx create mode 100644 src/components/MatchPodium.tsx create mode 100644 src/components/MatchScheduler.tsx create mode 100644 src/components/TeamsManager.tsx create mode 100644 src/components/VotingFlow.tsx create mode 100644 src/components/VotingSettings.tsx diff --git a/.agent/rules/regras.md b/.agent/rules/regras.md new file mode 100644 index 0000000..6560104 --- /dev/null +++ b/.agent/rules/regras.md @@ -0,0 +1,5 @@ +--- +trigger: always_on +--- + +suba o container apos uma atualizacao \ No newline at end of file diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package-lock.json b/package-lock.json index ccd9265..c295b90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "bcryptjs": "^3.0.3", "clsx": "^2.1.1", "cookies-next": "^6.1.1", + "date-fns": "^4.1.0", "framer-motion": "^12.26.2", "lucide-react": "^0.562.0", "minio": "^8.0.6", @@ -3194,6 +3195,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index 40c9668..a0781eb 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "bcryptjs": "^3.0.3", "clsx": "^2.1.1", "cookies-next": "^6.1.1", + "date-fns": "^4.1.0", "framer-motion": "^12.26.2", "lucide-react": "^0.562.0", "minio": "^8.0.6", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2bc42d0..607e9b3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -41,10 +41,13 @@ model Group { sponsors Sponsor[] arenas Arena[] financialEvents FinancialEvent[] + transactions Transaction[] pixKey String? pixName String? status GroupStatus @default(ACTIVE) showTotalInPublic Boolean @default(true) + votingEnabled Boolean @default(true) + teamConfigs TeamConfig[] } enum GroupStatus { @@ -71,6 +74,9 @@ model Player { teams TeamPlayer[] attendances Attendance[] payments Payment[] + reviews Review[] + matchEvents MatchEvent[] + transactions Transaction[] @@unique([number, groupId]) } @@ -95,7 +101,7 @@ model Match { arena Arena? @relation(fields: [arenaId], references: [id]) maxPlayers Int? drawSeed String? - status MatchStatus @default(SCHEDULED) + status MatchStatus @default(CONVOCACAO) groupId String createdAt DateTime @default(now()) @@ -105,15 +111,36 @@ model Match { isRecurring Boolean @default(false) recurrenceInterval String? // 'WEEKLY' recurrenceEndDate DateTime? + + duration Int? @default(60) // Duração em minutos + actualStartTime DateTime? + actualEndTime DateTime? + enableVoting Boolean @default(true) + votingDuration Int @default(72) // Duração da votação em horas (24, 48, 72) + gamificationType String @default("PADRAO") // PADRAO, PAREDAO, OSCAR + events MatchEvent[] + reviews Review[] } model Team { id String @id @default(cuid()) name String color String + shirtUrl String? matchId String match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) players TeamPlayer[] + matchEvents MatchEvent[] +} + +model TeamConfig { + id String @id @default(cuid()) + name String + color String @default("#000000") + shirtUrl String? + groupId String + group Group @relation(fields: [groupId], references: [id]) + createdAt DateTime @default(now()) } model TeamPlayer { @@ -126,8 +153,11 @@ model TeamPlayer { enum MatchStatus { SCHEDULED + CONVOCACAO + SORTEIO + LIVE IN_PROGRESS - COMPLETED + ENCERRAMENTO CANCELED } @@ -207,3 +237,49 @@ enum PaymentStatus { PAID WAIVED } + +model MatchEvent { + id String @id @default(cuid()) + matchId String + match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) + type String // GOAL, CARD_YELLOW, CARD_RED, etc. + playerId String? + player Player? @relation(fields: [playerId], references: [id]) + teamId String? + team Team? @relation(fields: [teamId], references: [id]) + minute Int? + createdAt DateTime @default(now()) +} + +model Review { + id String @id @default(cuid()) + matchId String + match Match @relation(fields: [matchId], references: [id], onDelete: Cascade) + playerId String // Atleta que está sendo avaliado + player Player @relation(fields: [playerId], references: [id]) + reviewerId String? // Opcional + type String // STAR (Craque), PEREBA, FAIR_PLAY, etc. + createdAt DateTime @default(now()) +} + +model Transaction { + id String @id @default(cuid()) + description String + amount Float + type TransactionType @default(INCOME) + category String? // 'Mensalidade', 'Avulso', 'Sobra', 'Material', 'Quadra', etc. + date DateTime @default(now()) + + groupId String + group Group @relation(fields: [groupId], references: [id]) + + playerId String? // Opcional: para vincular a um atleta específico (ex: avulso) + player Player? @relation(fields: [playerId], references: [id]) + + createdAt DateTime @default(now()) +} + +enum TransactionType { + INCOME // Entrada + EXPENSE // Saída +} diff --git a/src/actions/arena.ts b/src/actions/arena.ts index 069367c..3aad8ca 100644 --- a/src/actions/arena.ts +++ b/src/actions/arena.ts @@ -65,3 +65,25 @@ export async function deleteArena(id: string) { return { success: false, error: 'Erro ao deletar arena' } } } + +export async function updateArena(id: string, formData: FormData) { + const group = await getActiveGroup() + if (!group) return { success: false, error: 'Unauthorized' } + + const name = formData.get('name') as string + const address = formData.get('address') as string + + try { + await prisma.arena.update({ + where: { id, groupId: group.id }, + data: { name, address } + }) + + revalidatePath('/dashboard/settings') + revalidatePath('/dashboard/matches') + return { success: true } + } catch (error) { + console.error('Error updating arena:', error) + return { success: false, error: 'Erro ao atualizar arena' } + } +} diff --git a/src/actions/finance.ts b/src/actions/finance.ts index 742eaa4..e58d236 100644 --- a/src/actions/finance.ts +++ b/src/actions/finance.ts @@ -260,3 +260,67 @@ export async function toggleEventPrivacy(eventId: string, showTotal: boolean) { revalidatePath('/dashboard/financial') return { success: true } } +// --- Transactions (Generic Cash Flow) --- + +export async function createTransaction(data: { + description: string + amount: number + type: 'INCOME' | 'EXPENSE' + category?: string + playerId?: string + date?: string +}) { + const group = await getActiveGroup() + if (!group) return { success: false, error: 'Unauthorized' } + + try { + const transaction = await (prisma as any).transaction.create({ + data: { + description: data.description, + amount: data.amount, + type: data.type as any, + category: data.category, + playerId: data.playerId || null, + date: data.date ? new Date(data.date) : new Date(), + groupId: group.id + } + }) + + revalidatePath('/dashboard/financial') + return { success: true, transaction } + } catch (error) { + console.error('Error creating transaction:', error) + return { success: false, error: 'Erro ao criar transação' } + } +} + +export async function getTransactions() { + const group = await getActiveGroup() + if (!group) return [] + + return await (prisma as any).transaction.findMany({ + where: { groupId: group.id }, + include: { + player: true + }, + orderBy: { date: 'desc' } + }) +} + +export async function deleteTransactions(ids: string[]) { + const group = await getActiveGroup() + if (!group) return { success: false, error: 'Unauthorized' } + + try { + await (prisma as any).transaction.deleteMany({ + where: { + id: { in: ids }, + groupId: group.id + } + }) + revalidatePath('/dashboard/financial') + return { success: true } + } catch (error) { + return { success: false, error: 'Erro ao deletar transações' } + } +} diff --git a/src/actions/match.ts b/src/actions/match.ts index a844400..edb8754 100644 --- a/src/actions/match.ts +++ b/src/actions/match.ts @@ -9,17 +9,27 @@ export async function createMatch( groupId: string, date: string, teamsData: any[], - status: MatchStatus = 'IN_PROGRESS', + status: MatchStatus = 'SORTEIO' as any, // Default for immediate matches location?: string, maxPlayers?: number, drawSeed?: string, - arenaId?: string + arenaId?: string, + enableVoting: boolean = true, + votingDuration: number = 72, + gamificationType: string = 'PADRAO', + duration: number = 60 ) { const match = await prisma.match.create({ data: { date: new Date(date), groupId: groupId, - status: status, + status: status as any, + // @ts-ignore + enableVoting: enableVoting as any, + // @ts-ignore + votingDuration: votingDuration, + // @ts-ignore + gamificationType: gamificationType, // @ts-ignore location: location, arenaId: arenaId || null, @@ -27,10 +37,13 @@ export async function createMatch( maxPlayers: maxPlayers, // @ts-ignore drawSeed: drawSeed, + // @ts-ignore + duration: duration, teams: { create: teamsData.map(team => ({ name: team.name, color: team.color, + shirtUrl: team.shirtUrl, players: { create: team.players.map((p: any) => ({ playerId: p.id @@ -38,6 +51,80 @@ export async function createMatch( } })) } + }, + include: { + teams: { + include: { + players: { + include: { + player: true + } + } + } + } + } + }) + + revalidatePath('/dashboard/matches') + return match +} + +export async function updateMatchWithTeams( + matchId: string, + teamsData: any[], + status: MatchStatus = 'SORTEIO' as any, + drawSeed?: string, + enableVoting: boolean = true, + votingDuration: number = 72, + gamificationType: string = 'PADRAO', + duration: number = 60 +) { + // Delete existing teams if any (idempotency) + await prisma.teamPlayer.deleteMany({ + where: { team: { matchId } } + }) + await prisma.team.deleteMany({ + where: { matchId } + }) + + const match = await (prisma.match as any).update({ + where: { id: matchId }, + data: { + status: status as any, + actualEndTime: (status as any) === 'IN_PROGRESS' ? new Date() : undefined, + // @ts-ignore + drawSeed, + // @ts-ignore + enableVoting: enableVoting as any, + // @ts-ignore + votingDuration: votingDuration, + // @ts-ignore + gamificationType: gamificationType, + // @ts-ignore + duration: duration, + teams: { + create: teamsData.map(team => ({ + name: team.name, + color: team.color, + shirtUrl: team.shirtUrl, + players: { + create: team.players.map((p: any) => ({ + playerId: p.id + })) + } + })) + } + }, + include: { + teams: { + include: { + players: { + include: { + player: true + } + } + } + } } }) @@ -104,7 +191,7 @@ export async function createScheduledMatch( arenaId: validArenaId, // @ts-ignore maxPlayers, - status: 'SCHEDULED' as MatchStatus, + status: 'CONVOCACAO' as any, // @ts-ignore isRecurring, // @ts-ignore @@ -139,17 +226,21 @@ export async function createScheduledMatch( } export async function updateMatchStatus(matchId: string, status: MatchStatus) { - const match = await prisma.match.update({ + const data: any = { status } + + if ((status as any) === 'SORTEIO') { + data.actualStartTime = new Date() + } else if ((status as any) === 'ENCERRAMENTO') { + data.actualEndTime = new Date() + } + + const match = await (prisma.match as any).update({ where: { id: matchId }, - data: { status } + data }) // If match is completed and was recurring, create the next one ONLY if it doesn't exist yet - // This handles the "infinite" case by extending the chain as matches are played - // If match is completed and was recurring, create the next one ONLY if it doesn't exist yet - // This handles the "infinite" case by extending the chain as matches are played - // @ts-ignore - if (status === 'COMPLETED' && match.isRecurring) { + if ((status as any) === 'ENCERRAMENTO' && (match as any).isRecurring) { const nextDate = new Date(match.date) // @ts-ignore const interval = match.recurrenceInterval @@ -176,7 +267,7 @@ export async function updateMatchStatus(matchId: string, status: MatchStatus) { const existingNextMatch = await prisma.match.findFirst({ where: { groupId: match.groupId, - status: 'SCHEDULED', + status: 'CONVOCACAO' as any, date: { gte: new Date(nextDate.getTime() - 24 * 60 * 60 * 1000), lte: new Date(nextDate.getTime() + 24 * 60 * 60 * 1000) @@ -192,7 +283,7 @@ export async function updateMatchStatus(matchId: string, status: MatchStatus) { location: match.location, arenaId: match.arenaId, maxPlayers: match.maxPlayers, - status: 'SCHEDULED', + status: 'CONVOCACAO' as any, // @ts-ignore isRecurring: true, // @ts-ignore @@ -262,10 +353,10 @@ export async function getPublicScheduledMatches(slug: string) { if (!group) return { group: null, matches: [] } // Fetch matches that are SCHEDULED and date is in the future or today - const matches = await prisma.match.findMany({ + const matches = await (prisma.match as any).findMany({ where: { groupId: group.id, - status: 'SCHEDULED', + status: 'CONVOCACAO' as any, date: { gte: new Date(new Date().setHours(0, 0, 0, 0)) } @@ -288,3 +379,156 @@ export async function getPublicScheduledMatches(slug: string) { return { group, matches } } + +export async function logMatchEvent( + matchId: string, + type: string, + teamId?: string, + playerId?: string +) { + const event = await (prisma as any).matchEvent.create({ + data: { + matchId, + type, + teamId, + playerId + } + }) + return event +} + +export async function submitReviews(matchId: string, reviews: { playerId: string, type: string }[], reviewerId?: string) { + // Check 3 day limit + const match = await prisma.match.findUnique({ + where: { id: matchId } + }) + + if (!match) throw new Error('Match not found') + + const matchDate = (match as any).actualEndTime || (match as any).date + const now = new Date() + const matchTime = new Date(matchDate).getTime() + const nowTime = now.getTime() + const hoursSinceMatch = (nowTime - matchTime) / (1000 * 60 * 60) + const limitHours = (match as any).votingDuration || 72 + + // Only block if NOT admin AND (match in past AND exceeds limit AND finalized) + // We allow voting on IN_PROGRESS matches regardless of time to favor testing and late starts + if (reviewerId !== 'ADMIN' && (match as any).status === 'ENCERRAMENTO') { + if (hoursSinceMatch > limitHours) { + throw new Error(`O prazo para votação expirou (limite de ${limitHours} horas).`) + } + } + + // delete previous reviews for this match by THIS reviewer if identified + // If anonymous (reviewerId not provided), we still identify by reviewerId: null + await (prisma as any).review.deleteMany({ + where: { + matchId, + reviewerId: reviewerId || null + } + }) + + const created = await (prisma as any).review.createMany({ + data: reviews.map(r => ({ + matchId, + playerId: r.playerId, + type: r.type, + reviewerId: reviewerId || null + })) + }) + + // Auto-finalize if all players have voted + if (reviewerId && reviewerId !== 'ADMIN') { + const matchData = await prisma.match.findUnique({ + where: { id: matchId }, + include: { + teams: { + include: { + players: true + } + }, + reviews: { + select: { reviewerId: true } + } + } + }) + + if (matchData && (matchData as any).status === 'IN_PROGRESS') { + const totalPlayersCount = matchData.teams.reduce((acc, team) => acc + team.players.length, 0) + const uniqueVotersCount = new Set((matchData as any).reviews.map((r: any) => r.reviewerId).filter(Boolean)).size + + if (uniqueVotersCount >= totalPlayersCount) { + await prisma.match.update({ + where: { id: matchId }, + data: { status: 'ENCERRAMENTO' as any } + }) + } + } + } + + return created +} + +export async function getMatchForVoting(matchId: string) { + const match = await prisma.match.findUnique({ + where: { id: matchId }, + include: { + group: true, + teams: { + include: { + players: { + include: { + player: true + } + } + } + } + } + }) + + if (!match) return null + + // Check if voting is enabled + if (!(match as any).enableVoting) return null + + return match as any +} + +export async function getGamificationResults(matchId: string) { + const reviews = await prisma.review.findMany({ + where: { matchId }, + include: { + player: true + } + }) + + const results: Record = {} + const votedPlayerIds = new Set() + + reviews.forEach(review => { + // Collect results per athlete + if (!results[review.playerId]) { + results[review.playerId] = { craque: 0, pereba: 0, fairPlay: 0, player: review.player } + } + + if (review.type === 'CRAQUE') results[review.playerId].craque++ + else if (review.type === 'PEREBA') results[review.playerId].pereba++ + else if (review.type === 'FAIR_PLAY') results[review.playerId].fairPlay++ + + // Track who voted + if (review.reviewerId && review.reviewerId !== 'ADMIN') { + votedPlayerIds.add(review.reviewerId) + } + }) + + // Fetch winners names if needed or just return IDs + const votedPlayers = await prisma.player.findMany({ + where: { id: { in: Array.from(votedPlayerIds) } } + }) + + return { + results: Object.values(results).sort((a, b) => (b.craque - b.pereba) - (a.craque - a.pereba)), + votedPlayers: votedPlayers + } +} diff --git a/src/actions/sponsor.ts b/src/actions/sponsor.ts index 6b75b55..c3be5fc 100644 --- a/src/actions/sponsor.ts +++ b/src/actions/sponsor.ts @@ -39,3 +39,21 @@ export async function deleteSponsor(id: string) { }) revalidatePath('/dashboard/settings') } + +export async function updateSponsor(id: string, formData: FormData) { + const name = formData.get('name') as string + const logoFile = formData.get('logo') as File + + let data: any = { name } + + if (logoFile && logoFile.size > 0) { + data.logoUrl = await uploadFile(logoFile) + } + + const sponsor = await prisma.sponsor.update({ + where: { id }, + data + }) + revalidatePath('/dashboard/settings') + return sponsor +} diff --git a/src/actions/team-config.ts b/src/actions/team-config.ts new file mode 100644 index 0000000..b4f70f0 --- /dev/null +++ b/src/actions/team-config.ts @@ -0,0 +1,65 @@ +'use server' + +import { prisma } from '@/lib/prisma' +import { revalidatePath } from 'next/cache' +import { uploadFile } from '@/lib/upload' + +export async function getTeamConfigs(groupId: string) { + // @ts-ignore + return await prisma.teamConfig.findMany({ + where: { groupId }, + orderBy: { createdAt: 'asc' } + }) +} + +export async function createTeamConfig(formData: FormData) { + const groupId = formData.get('groupId') as string + const name = formData.get('name') as string + const color = formData.get('color') as string + const shirtFile = formData.get('shirt') as File + + let shirtUrl = null + if (shirtFile && shirtFile.size > 0) { + shirtUrl = await uploadFile(shirtFile) + } + + // @ts-ignore + const config = await prisma.teamConfig.create({ + data: { + name, + color, + shirtUrl, + groupId + } + }) + revalidatePath('/dashboard/settings') + return config +} + +export async function updateTeamConfig(id: string, formData: FormData) { + const name = formData.get('name') as string + const color = formData.get('color') as string + const shirtFile = formData.get('shirt') as File + + let data: any = { name, color } + + if (shirtFile && shirtFile.size > 0) { + data.shirtUrl = await uploadFile(shirtFile) + } + + // @ts-ignore + const config = await prisma.teamConfig.update({ + where: { id }, + data + }) + revalidatePath('/dashboard/settings') + return config +} + +export async function deleteTeamConfig(id: string) { + // @ts-ignore + await prisma.teamConfig.delete({ + where: { id } + }) + revalidatePath('/dashboard/settings') +} diff --git a/src/app/(dashboard)/dashboard/financial/page.tsx b/src/app/(dashboard)/dashboard/financial/page.tsx index 749f42b..4d0b263 100644 --- a/src/app/(dashboard)/dashboard/financial/page.tsx +++ b/src/app/(dashboard)/dashboard/financial/page.tsx @@ -1,14 +1,14 @@ import { getActiveGroup } from '@/lib/auth' -import { getFinancialEvents } from '@/actions/finance' +import { getFinancialEvents, getTransactions } from '@/actions/finance' import { FinancialDashboard } from '@/components/FinancialDashboard' export default async function FinancialPage() { const group = await getActiveGroup() if (!group) return null - // We fetch events and players - // getActiveGroup already includes players, so we can use that list for selection + // We fetch events, transactions and players const events = await getFinancialEvents() + const transactions = await getTransactions() return (
@@ -19,7 +19,12 @@ export default async function FinancialPage() {
- + ) } diff --git a/src/app/(dashboard)/dashboard/matches/new/page.tsx b/src/app/(dashboard)/dashboard/matches/new/page.tsx index 387827f..d9b8581 100644 --- a/src/app/(dashboard)/dashboard/matches/new/page.tsx +++ b/src/app/(dashboard)/dashboard/matches/new/page.tsx @@ -4,11 +4,13 @@ import Link from 'next/link' import { ChevronLeft } from 'lucide-react' import { getArenas } from '@/actions/arena' import { getSponsors } from '@/actions/sponsor' +import { getTeamConfigs } from '@/actions/team-config' export default async function NewMatchPage() { const group = await getActiveGroup() const arenas = await getArenas() const sponsors = await getSponsors(group?.id || '') + const teamConfigs = await getTeamConfigs(group?.id || '') return (
@@ -25,7 +27,7 @@ export default async function NewMatchPage() {
- + ) } diff --git a/src/app/(dashboard)/dashboard/matches/schedule/page.tsx b/src/app/(dashboard)/dashboard/matches/schedule/page.tsx index 8362ae4..7c3d542 100644 --- a/src/app/(dashboard)/dashboard/matches/schedule/page.tsx +++ b/src/app/(dashboard)/dashboard/matches/schedule/page.tsx @@ -1,321 +1,24 @@ -'use client' - -import React, { useState, useMemo } from 'react' -import { Calendar, MapPin, Users, ArrowRight, Trophy, Repeat, Hash, ChevronRight } from 'lucide-react' -import { createScheduledMatch } from '@/actions/match' -import { useRouter } from 'next/navigation' import { getArenas } from '@/actions/arena' -import type { Arena } from '@prisma/client' -import { DateTimePicker } from '@/components/DateTimePicker' -import { motion, AnimatePresence } from 'framer-motion' -import { clsx } from 'clsx' +import { MatchScheduler } from '@/components/MatchScheduler' +import Link from 'next/link' +import { ChevronLeft } from 'lucide-react' -export default function ScheduleMatchPage() { - const router = useRouter() - const [date, setDate] = useState('') - const [location, setLocation] = useState('') - const [selectedArenaId, setSelectedArenaId] = useState('') - const [arenas, setArenas] = 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) - - React.useEffect(() => { - getArenas().then(setArenas) - }, []) - - 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 +export default async function SchedulePage() { + const arenas = await getArenas() return ( -
-
-
-
- - Novo Agendamento -
-

- Agendar Evento -

-

Crie um link de confirmação e organize sua pelada.

-
+
+
+ + + + Voltar para Partidas
-
-
-
-
-
- -
-

Detalhes Básicos

-
- - - -
- -
-
- - - -
- - setLocation(e.target.value)} - className="ui-input w-full h-12 bg-surface/50 text-sm" - /> -
-
- -
- -
- - setMaxPlayers(e.target.value)} - className="ui-input w-full pl-10 h-12 bg-surface text-sm" - /> -
-
-
- -
-
-
-
- -
-

Recorrência

-
- - -
- - - {isRecurring && ( - -
- {intervals.map((int) => ( - - ))} -
- -
- -

- Deixe em branco para repetir indefinidamente (será criado à medida que as peladas forem finalizadas). -

-
-
- )} -
- - {!isRecurring && ( -

- Este evento será único. Ative a recorrência para criar peladas fixas no calendário. -

- )} -
- -
- - -
-
- - -
+
) } diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 165e73d..8a5a241 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -70,7 +70,7 @@ export default async function DashboardPage() {

Pelada de {new Date(match.date).toLocaleDateString('pt-BR', { weekday: 'long' })}

- {match.status === 'SCHEDULED' + {match.status === 'CONVOCACAO' ? `${match.attendances.filter((a: any) => a.status === 'CONFIRMED').length} confirmados` : `${match.teams.length} times sorteados`}

diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index eadd47d..36769f0 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -2,10 +2,14 @@ import { getActiveGroup } from '@/lib/auth' import { SettingsForm } from '@/components/SettingsForm' import { getArenas } from '@/actions/arena' import { getSponsors } from '@/actions/sponsor' +import { getTeamConfigs } from '@/actions/team-config' import { ArenasManager } from '@/components/ArenasManager' import { SponsorsManager } from '@/components/SponsorsManager' +import { TeamsManager } from '@/components/TeamsManager' import { SettingsTabs } from '@/components/SettingsTabs' +import { VotingSettings } from '@/components/VotingSettings' + export default async function SettingsPage() { const group = await getActiveGroup() @@ -13,6 +17,7 @@ export default async function SettingsPage() { const arenas = await getArenas() const sponsors = await getSponsors(group.id) + const teams = await getTeamConfigs(group.id) return (
@@ -35,6 +40,8 @@ export default async function SettingsPage() { }} /> } + voting={} + teams={} arenas={} sponsors={} /> diff --git a/src/app/actions.ts b/src/app/actions.ts index db0d334..e6c1380 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -13,19 +13,19 @@ export async function updateGroupSettings(formData: FormData) { const group = await getActiveGroup() if (!group) throw new Error('Unauthorized') - const name = formData.get('name') as string - // Slug is immutable intentionally - const primaryColor = formData.get('primaryColor') as string - const secondaryColor = formData.get('secondaryColor') as string - const pixKey = formData.get('pixKey') as string - const pixName = formData.get('pixName') as string + const dataToUpdate: any = {} + + if (formData.has('name')) dataToUpdate.name = formData.get('name') as string + if (formData.has('primaryColor')) dataToUpdate.primaryColor = formData.get('primaryColor') as string + if (formData.has('secondaryColor')) dataToUpdate.secondaryColor = formData.get('secondaryColor') as string + if (formData.has('pixKey')) dataToUpdate.pixKey = formData.get('pixKey') as string + if (formData.has('pixName')) dataToUpdate.pixName = formData.get('pixName') as string + if (formData.has('votingEnabled')) dataToUpdate.votingEnabled = formData.get('votingEnabled') === 'true' + const logoFile = formData.get('logo') as File | null - - let logoUrl = group.logoUrl - if (logoFile && logoFile.size > 0 && logoFile.name !== 'undefined') { try { - logoUrl = await uploadFile(logoFile) + dataToUpdate.logoUrl = await uploadFile(logoFile) } catch (error) { console.error("Upload failed", error) } @@ -34,15 +34,7 @@ export async function updateGroupSettings(formData: FormData) { try { await prisma.group.update({ where: { id: group.id }, - data: { - name, - // Slug NOT updated - primaryColor, - secondaryColor, - pixKey, - pixName, - logoUrl, - }, + data: dataToUpdate, }) revalidatePath('/', 'layout') return { success: true, slug: group.slug } diff --git a/src/app/api/admin/groups/[id]/route.ts b/src/app/api/admin/groups/[id]/route.ts index 73eddef..88393b4 100644 --- a/src/app/api/admin/groups/[id]/route.ts +++ b/src/app/api/admin/groups/[id]/route.ts @@ -19,7 +19,7 @@ async function isAdmin() { export async function DELETE( request: Request, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { if (!(await isAdmin())) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -78,7 +78,7 @@ export async function DELETE( export async function PATCH( request: Request, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { if (!(await isAdmin())) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) diff --git a/src/app/globals.css b/src/app/globals.css index 4fa19f8..1a363f7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -11,6 +11,7 @@ --color-primary: var(--primary-color); --color-secondary: var(--secondary-color); + --color-secondary-foreground: color-mix(in srgb, var(--secondary-color), white 80%); /* Emerald 500 */ --color-primary-soft: color-mix(in srgb, var(--primary-color), transparent 90%); diff --git a/src/app/match/[id]/confirmacao/page.tsx b/src/app/match/[id]/confirmacao/page.tsx index b0ea439..cd4db5a 100644 --- a/src/app/match/[id]/confirmacao/page.tsx +++ b/src/app/match/[id]/confirmacao/page.tsx @@ -1,7 +1,7 @@ 'use client' import React, { useState, useEffect, useMemo } from 'react' -import { Trophy, Calendar, MapPin, Users, Check, X, Star, Shuffle, ArrowRight, Search, Shield, Target, Zap, ChevronRight, User } from 'lucide-react' +import { Trophy, Calendar, MapPin, Users, Check, X, Star, Shuffle, ArrowRight, Search, Shield, Target, Zap, ChevronRight, User, Clock } from 'lucide-react' import { getMatchWithAttendance, confirmAttendance, cancelAttendance } from '@/actions/attendance' import { motion, AnimatePresence } from 'framer-motion' import { clsx } from 'clsx' @@ -24,6 +24,13 @@ export default function ConfirmationPage() { const loadMatch = async () => { const data = await getMatchWithAttendance(id) + + // Auto-redirect to voting if enabled and active/ended + if (data && data.enableVoting && (data.status === 'IN_PROGRESS' || data.status === 'ENCERRAMENTO')) { + window.location.href = `/match/${id}/vote` + return + } + setMatch(data) setLoading(false) } @@ -113,6 +120,11 @@ export default function ConfirmationPage() { {new Date(match.date).toLocaleDateString('pt-BR', { day: 'numeric', month: 'short' })}
+
+ + {new Date(match.date).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })} +
+
{match.location && (
@@ -156,11 +168,11 @@ export default function ConfirmationPage() { {/* Draw Results (If done) */} - {(match.status === 'IN_PROGRESS' || match.status === 'COMPLETED') && ( + {(match.status === 'SORTEIO' || match.status === 'ENCERRAMENTO' || match.status === 'IN_PROGRESS') && (

- Escalacões Geradas + {match.status === 'SORTEIO' ? 'Times Definidos' : match.status === 'IN_PROGRESS' ? 'Resenha em Andamento' : 'Partida Encerrada'}

SEED: {match.drawSeed || 'TRANS-1'} @@ -193,11 +205,33 @@ export default function ConfirmationPage() {
))}
+ + {match.enableVoting && ( + + +
)} {/* Selection Box */} - {match.status === 'SCHEDULED' && ( + {match.status === 'CONVOCACAO' && (

diff --git a/src/app/match/[id]/vote/page.tsx b/src/app/match/[id]/vote/page.tsx new file mode 100644 index 0000000..eeb4a00 --- /dev/null +++ b/src/app/match/[id]/vote/page.tsx @@ -0,0 +1,179 @@ +import { getMatchForVoting, submitReviews, getGamificationResults } from '@/actions/match' +import { Star, Shield, Trophy, Zap, CheckCircle2, User, Users, RefreshCw, Check, AlertCircle } from 'lucide-react' +import { clsx } from 'clsx' +import { redirect } from 'next/navigation' +import { VotingFlow } from '@/components/VotingFlow' +import { MatchPodium } from '@/components/MatchPodium' +import { motion } from 'framer-motion' + +interface VotePageProps { + params: Promise<{ id: string }> +} + +export default async function PublicVotePage({ params }: VotePageProps) { + const { id: matchId } = await params + const match = await getMatchForVoting(matchId) + const resultsResponse = await getGamificationResults(matchId) + const voters = resultsResponse.votedPlayers + + if (!match) { + return ( +
+
+
+ +
+
+

Link Inválido

+

+ Esta partida não existe ou a votação foi desativada pelo organizador. +

+
+ + Voltar ao Início + +
+
+ ) + } + + // Identify who already voted for this match to show in a "voted" list + // This is optional but nice for the requirement + const allPlayers = match.teams.flatMap((t: any) => t.players.map((tp: any) => tp.player)) + + // Check if voting expired or match finalized + const matchDate = match.actualEndTime || match.date + const now = new Date() + const matchTime = new Date(matchDate).getTime() + const hoursSinceMatch = (now.getTime() - matchTime) / (1000 * 60 * 60) + + // Only expired if time passed is POSITIVE and greater than duration + // If hoursSinceMatch is negative, it means the match is in the future, so NOT expired. + const isExpired = (hoursSinceMatch > 0 && hoursSinceMatch > (match.votingDuration || 72)) || match.status === 'ENCERRAMENTO' + + if (isExpired) { + // If we have results, show the Podium! + if (resultsResponse && resultsResponse.results && resultsResponse.results.length > 0) { + return ( +
+
+
+

Resultado Final

+
+ Votação Encerrada +
+
+
+ +
+
+
+ +
+

+ Os Destaques
Da Rodada +

+

Confira quem mandou bem na resenha

+
+ + + + +
+
+ ) + } + + return ( +
+
+
+ +
+
+

Votação Encerrada

+

+ {match.status === 'ENCERRAMENTO' + ? "O organizador encerrou a resenha e os resultados já foram consolidados." + : "O prazo para avaliar esta partida expirou."} +

+
+
+
+ ) + } + + async function handleVoteAction(formData: FormData) { + 'use server' + const reviews: any[] = [] + const matchId = formData.get('matchId') as string + const reviewerId = formData.get('reviewerId') as string + + if (!reviewerId) { + // This should ideally be handled by client validation but as a fallback: + return; + } + + for (const [key, value] of formData.entries()) { + if (key.startsWith('review-')) { + const playerId = key.replace('review-', '') + if (value) { + reviews.push({ playerId, type: value }) + } + } + } + + if (reviews.length > 0) { + await submitReviews(matchId, reviews, reviewerId) + } + + redirect(`/match/${matchId}/vote/success`) + } + + return ( +
+ {/* Header / Brand */} +
+
+
+
+ {match.group.logoUrl ? ( + {match.group.name} + ) : ( + {match.group.name.slice(0, 2).toUpperCase()} + )} +
+
+

{match.group.name}

+

Live Review Engine

+
+
+
+ Pixel Perfect V3.0 +
+
+
+ +
+ +
+ +
+
+ + TemFut Gamification Engine v3.0 +
+
+
+ ) +} diff --git a/src/app/match/[id]/vote/success/page.tsx b/src/app/match/[id]/vote/success/page.tsx new file mode 100644 index 0000000..74ce295 --- /dev/null +++ b/src/app/match/[id]/vote/success/page.tsx @@ -0,0 +1,76 @@ +import { Trophy, CheckCircle2, Users, Check } from 'lucide-react' +import { getGamificationResults, getMatchForVoting } from '@/actions/match' +import { clsx } from 'clsx' + +interface SuccessPageProps { + params: Promise<{ id: string }> +} + +export default async function VoteSuccessPage({ params }: SuccessPageProps) { + const { id: matchId } = await params + const results = await getGamificationResults(matchId) + const match = await getMatchForVoting(matchId) + + const voters = results.votedPlayers + const allPlayers = match?.teams.flatMap((t: any) => t.players.map((tp: any) => tp.player)) || [] + + return ( +
+
+
+
+
+ +
+
+ +
+

Voto
Contabilizado!

+

+ Sua opinião é o que faz o TemFut real.
Obrigado por fortalecer a resenha! +

+
+ +
+ + TemFut Gamification Engine +
+
+ + {/* Voter Status */} +
+
+
+
+ +

Quem já votou

+
+
+ {voters.length} / {allPlayers.length} +
+
+ +
+ {allPlayers.map((p: any) => { + const hasVoted = voters.some((v: any) => v.id === p.id) + return ( +
+ {p.name} + {hasVoted && } +
+ ) + })} +
+
+
+
+ ) +} diff --git a/src/components/ArenasManager.tsx b/src/components/ArenasManager.tsx index 58808f1..76c5562 100644 --- a/src/components/ArenasManager.tsx +++ b/src/components/ArenasManager.tsx @@ -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(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 (
@@ -70,14 +82,23 @@ export function ArenasManager({ arenas }: ArenasManagerProps) { {arena.address &&

{arena.address}

}
- +
+ + +

))} @@ -91,16 +112,36 @@ export function ArenasManager({ arenas }: ArenasManagerProps) {
{ 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"> +
+

+ {editingArena ? 'Editando Local' : 'Adicionar Novo Local'} +

+ {editingArena && ( + + )} +
+
Endereço (Opcional) @@ -117,14 +160,14 @@ export function ArenasManager({ arenas }: ArenasManagerProps) {
diff --git a/src/components/CreateFinanceEventModal.tsx b/src/components/CreateFinanceEventModal.tsx index f363dde..3f31c56 100644 --- a/src/components/CreateFinanceEventModal.tsx +++ b/src/components/CreateFinanceEventModal.tsx @@ -74,8 +74,8 @@ export function CreateFinanceEventModal({ isOpen, onClose, players }: CreateFina const selectNone = () => setSelectedPlayers([]) return ( -
-
+
+

Novo Evento Financeiro

Crie mensalidades, churrascos ou arrecadações.

@@ -119,12 +119,14 @@ export function CreateFinanceEventModal({ isOpen, onClose, players }: CreateFina />
- +
+ +
diff --git a/src/components/CreateTransactionModal.tsx b/src/components/CreateTransactionModal.tsx new file mode 100644 index 0000000..7ada01b --- /dev/null +++ b/src/components/CreateTransactionModal.tsx @@ -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 ( +
+
+
+

Nova Movimentação

+

Registre entradas ou saídas do caixa.

+
+ +
+ {/* Type Selector */} +
+ + +
+ +
+
+ +
+ + 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" + /> +
+
+ +
+
+ + 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" + /> +
+
+
+ +
+
+
+ +
+ +
+ {categories.map(cat => ( + + ))} + 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" + /> +
+
+ + {type === 'INCOME' && ( +
+ +
+ + +
+
+ )} +
+
+ +
+ + + +
+
+
+ ) +} diff --git a/src/components/DateRangePicker.tsx b/src/components/DateRangePicker.tsx new file mode 100644 index 0000000..9b2d0bc --- /dev/null +++ b/src/components/DateRangePicker.tsx @@ -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(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 ( +
+ {label && } +
+
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" : "" + )} + > +
+ + + {!mounted ? (placeholder || "Selecionar período") : formatDisplay()} + +
+ {(startDate || endDate) && ( + + )} +
+ + + {isOpen && ( + +
+
+
+

+ {months[viewDate.getMonth()]} {viewDate.getFullYear()} +

+
+
+ + +
+
+ +
+ {['D', 'S', 'T', 'Q', 'Q', 'S', 'S'].map((d, i) => ( +
+ {d} +
+ ))} +
+ +
+ {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 ( + + ) + })} +
+ +
+ + +
+
+
+ )} +
+
+
+ ) +} diff --git a/src/components/DateTimePicker.tsx b/src/components/DateTimePicker.tsx index 8ff1622..eb77c20 100644 --- a/src/components/DateTimePicker.tsx +++ b/src/components/DateTimePicker.tsx @@ -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 ( -
+
{label && }
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 diff --git a/src/components/FinancialDashboard.tsx b/src/components/FinancialDashboard.tsx index 44c14ea..9fd14fd 100644 --- a/src/components/FinancialDashboard.tsx +++ b/src/components/FinancialDashboard.tsx @@ -4,23 +4,28 @@ import React, { useState, useMemo } from 'react' import { Plus, Wallet, TrendingUp, Calendar, AlertCircle, ChevronRight, Check, X, Search, Filter, LayoutGrid, List, Trash2, MoreHorizontal, Share2, Copy, Settings, Eye, EyeOff } from 'lucide-react' import { CreateFinanceEventModal } from '@/components/CreateFinanceEventModal' import { FinancialSettingsModal } from '@/components/FinancialSettingsModal' -import { markPaymentAsPaid, markPaymentAsPending, deleteFinancialEvents, toggleEventPrivacy } from '@/actions/finance' +import { markPaymentAsPaid, markPaymentAsPending, deleteFinancialEvents, toggleEventPrivacy, deleteTransactions } from '@/actions/finance' import { useRouter } from 'next/navigation' import Link from 'next/link' import { motion, AnimatePresence } from 'framer-motion' import { clsx } from 'clsx' import { DeleteConfirmationModal } from '@/components/DeleteConfirmationModal' +import { CreateTransactionModal } from '@/components/CreateTransactionModal' +import { ArrowUpCircle, ArrowDownCircle, History, Receipt, PieChart, BarChart3, TrendingDown, Star, Activity, Sparkles } from 'lucide-react' +import { DateRangePicker } from '@/components/DateRangePicker' interface FinancialPageProps { events: any[] + transactions: any[] players: any[] group: any } -export function FinancialDashboard({ events, players, group }: FinancialPageProps) { +export function FinancialDashboard({ events, transactions, players, group }: FinancialPageProps) { const router = useRouter() const [mounted, setMounted] = React.useState(false) const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) + const [isCreateTransactionModalOpen, setIsCreateTransactionModalOpen] = useState(false) const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false) React.useEffect(() => { @@ -42,23 +47,30 @@ export function FinancialDashboard({ events, players, group }: FinancialPageProp const [privacyPendingId, setPrivacyPendingId] = useState(null) // Filter & View State + const [mainTab, setMainTab] = useState<'EVENTS' | 'TRANSACTIONS' | 'REPORTS'>('EVENTS') const [searchQuery, setSearchQuery] = useState('') - const [activeTab, setActiveTab] = useState<'ALL' | 'MONTHLY_FEE' | 'EXTRA_EVENT'>('ALL') + const [activeTab, setActiveTab] = useState<'ALL' | 'MONTHLY_FEE' | 'EXTRA_EVENT' | 'INCOME' | 'EXPENSE'>('ALL') const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') + const [startDate, setStartDate] = useState('') + const [endDate, setEndDate] = useState('') + const [filterCategory, setFilterCategory] = useState('ALL') - // Confirmation Modal State const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean isDeleting: boolean title: string description: string + confirmText: string }>({ isOpen: false, isDeleting: false, title: '', - description: '' + description: '', + confirmText: 'Sim, excluir' }) + const [isFilterModalOpen, setIsFilterModalOpen] = useState(false) + // Selection const [selectedIds, setSelectedIds] = useState>(new Set()) @@ -75,18 +87,113 @@ export function FinancialDashboard({ events, players, group }: FinancialPageProp return events.filter(e => { const matchesSearch = e.title.toLowerCase().includes(searchQuery.toLowerCase()) const matchesTab = activeTab === 'ALL' || e.type === activeTab - return matchesSearch && matchesTab - }) - }, [events, searchQuery, activeTab]) - const totalStats = events.reduce((acc, event) => { + const eventDate = new Date(e.dueDate) + const matchesStartDate = !startDate || eventDate >= new Date(startDate) + const matchesEndDate = !endDate || eventDate <= new Date(endDate + 'T23:59:59.999Z') + + return matchesSearch && matchesTab && matchesStartDate && matchesEndDate + }) + }, [events, searchQuery, activeTab, startDate, endDate]) + + const combinedTransactions = useMemo(() => { + const transList = transactions.map(t => ({ + ...t, + origin: 'TRANSACTION' as const + })) + + const paymentList = events.flatMap(event => + event.payments + .filter((p: any) => p.status === 'PAID') + .map((p: any) => ({ + id: `payment-${p.id}`, + description: `PAGAMENTO: ${event.title}`, + amount: p.amount || event.pricePerPerson || (event.totalAmount / (event.payments.length || 1)), + type: 'INCOME' as const, + category: event.type === 'MONTHLY_FEE' ? 'Mensalidade' : 'Evento Extra', + date: p.paidAt || p.createdAt || event.dueDate, + player: p.player, + origin: 'EVENT_PAYMENT' as const + })) + ) + + return [...transList, ...paymentList].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + }, [transactions, events]) + + const filteredTransactions = useMemo(() => { + return combinedTransactions.filter(t => { + const matchesSearch = t.description.toLowerCase().includes(searchQuery.toLowerCase()) || + t.category?.toLowerCase().includes(searchQuery.toLowerCase()) + const matchesTab = activeTab === 'ALL' || t.type === activeTab + + const transDate = new Date(t.date) + const matchesStartDate = !startDate || transDate >= new Date(startDate) + const matchesEndDate = !endDate || transDate <= new Date(endDate + 'T23:59:59.999Z') + const matchesCat = filterCategory === 'ALL' || t.category === filterCategory + + return matchesSearch && matchesTab && matchesStartDate && matchesEndDate && matchesCat + }) + }, [combinedTransactions, searchQuery, activeTab, startDate, endDate, filterCategory]) + + const totalStats = useMemo(() => { + const eventsPaid = events.reduce((acc, event) => acc + event.stats.totalPaid, 0) + const eventsExpected = events.reduce((acc, event) => acc + event.stats.totalExpected, 0) + + const transIncome = transactions.filter(t => t.type === 'INCOME').reduce((acc, t) => acc + t.amount, 0) + const transExpense = transactions.filter(t => t.type === 'EXPENSE').reduce((acc, t) => acc + t.amount, 0) + + // Smart Report Logic + const playerStats = players.map(player => { + const playerPaidEvents = events.reduce((acc, event) => { + const payment = event.payments.find((p: any) => p.playerId === player.id && p.status === 'PAID') + return acc + (payment ? (event.pricePerPerson || (event.totalAmount / (event.payments.length || 1))) : 0) + }, 0) + + const playerPaidTransactions = transactions + .filter(t => t.playerId === player.id && t.type === 'INCOME') + .reduce((acc, t) => acc + t.amount, 0) + + return { + ...player, + totalPaid: playerPaidEvents + playerPaidTransactions + } + }).sort((a, b) => b.totalPaid - a.totalPaid) + + const catStats = transactions + .filter(t => t.type === 'EXPENSE') + .reduce((acc: any, t) => { + acc[t.category || 'Outros'] = (acc[t.category || 'Outros'] || 0) + t.amount + return acc + }, {}) + return { - expected: acc.expected + event.stats.totalExpected, - paid: acc.paid + event.stats.totalPaid + expected: eventsExpected, + paid: eventsPaid, + transIncome, + transExpense, + balance: eventsPaid + transIncome - transExpense, + topPayer: playerStats[0], + allPlayerStats: playerStats, + categoryExpenses: Object.entries(catStats) + .map(([name, value]) => ({ name, value: value as number })) + .sort((a, b) => b.value - a.value) } - }, { expected: 0, paid: 0 }) + }, [events, transactions, players]) + + const groupedTransactions = useMemo(() => { + const groups: Record = {} + filteredTransactions.forEach(t => { + const d = new Date(t.date) + // Fix timezone offset for grouping + const dateStr = new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().split('T')[0] + if (!groups[dateStr]) groups[dateStr] = [] + groups[dateStr].push(t) + }) + return Object.entries(groups).sort((a, b) => b[0].localeCompare(a[0])) + }, [filteredTransactions]) const totalPending = totalStats.expected - totalStats.paid + const categories = Array.from(new Set(transactions.map(t => t.category).filter(Boolean))) const toggleSelection = (id: string) => { const newSelected = new Set(selectedIds) @@ -96,34 +203,82 @@ export function FinancialDashboard({ events, players, group }: FinancialPageProp } const toggleSelectAll = () => { - if (selectedIds.size === filteredEvents.length) { - setSelectedIds(new Set()) + const selectableIds = mainTab === 'EVENTS' + ? filteredEvents.map(e => e.id) + : filteredTransactions.map(t => t.id) + + const allSelected = selectableIds.length > 0 && selectableIds.every(id => selectedIds.has(id)) + + if (allSelected) { + const newSelected = new Set(selectedIds) + selectableIds.forEach(id => newSelected.delete(id)) + setSelectedIds(newSelected) } else { - const newSelected = new Set() - filteredEvents.forEach(e => newSelected.add(e.id)) + const newSelected = new Set(selectedIds) + selectableIds.forEach(id => newSelected.add(id)) setSelectedIds(newSelected) } } const handleDeleteSelected = () => { + const ids = Array.from(selectedIds) + const realCount = ids.filter(id => !id.startsWith('payment-')).length + const synthesizedCount = ids.length - realCount + + if (mainTab === 'TRANSACTIONS' && realCount === 0 && synthesizedCount > 0) { + setDeleteModal({ + isOpen: true, + isDeleting: false, + title: 'Registros Automáticos', + description: `Você selecionou ${synthesizedCount} ${synthesizedCount === 1 ? 'registro que é' : 'registros que são'} gerados automaticamente por eventos (como mensalidades). Para excluir ou alterar estes valores, você deve ir na aba "Eventos" e gerenciar o pagamento dentro do evento correspondente.`, + confirmText: 'Entendi' + }) + return + } + setDeleteModal({ isOpen: true, isDeleting: false, - title: `Excluir ${selectedIds.size} eventos?`, - description: 'Você tem certeza que deseja excluir os eventos financeiros selecionados? Todo o histórico de pagamentos destes eventos será apagado para sempre.' + title: mainTab === 'EVENTS' + ? `Excluir ${ids.length} ${ids.length === 1 ? 'Evento' : 'Eventos'}?` + : `Excluir ${realCount} ${realCount === 1 ? 'Registro' : 'Registros'}?`, + description: mainTab === 'EVENTS' + ? 'Você tem certeza que deseja excluir os eventos financeiros selecionados? Todo o histórico de pagamentos destes eventos será apagado para sempre.' + : synthesizedCount > 0 + ? `Atenção: Apenas ${realCount} movimentações manuais serão excluídas. Os ${synthesizedCount} registros automáticos de eventos não podem ser apagados por aqui e serão mantidos.` + : 'Você tem certeza que deseja excluir estas movimentações do caixa? Esta ação não pode ser desfeita.', + confirmText: 'Sim, excluir agora' }) } const confirmDelete = async () => { + // If it's just an informative modal (no real items to delete in transactions) + if (mainTab === 'TRANSACTIONS' && Array.from(selectedIds).filter(id => !id.startsWith('payment-')).length === 0) { + setDeleteModal(prev => ({ ...prev, isOpen: false })) + setSelectedIds(new Set()) + return + } + setDeleteModal(prev => ({ ...prev, isDeleting: true })) try { - await deleteFinancialEvents(Array.from(selectedIds)) + const idsToDelete = Array.from(selectedIds) + + if (mainTab === 'EVENTS') { + await deleteFinancialEvents(idsToDelete) + } else { + // Only delete real transactions, ignore synthesized event payments + const realTransactionIds = idsToDelete.filter(id => !id.startsWith('payment-')) + if (realTransactionIds.length > 0) { + await deleteTransactions(realTransactionIds) + } + } + setSelectedIds(new Set()) setDeleteModal(prev => ({ ...prev, isOpen: false })) router.refresh() } catch (error) { console.error(error) - alert('Erro ao excluir eventos.') + alert('Erro ao excluir registros.') } finally { setDeleteModal(prev => ({ ...prev, isDeleting: false })) } @@ -132,15 +287,28 @@ export function FinancialDashboard({ events, players, group }: FinancialPageProp return (
{/* Stats Overview */} -
+
- +
-

Arrecadado

-

R$ {totalStats.paid.toFixed(2)}

+

Saldo em Caixa

+

R$ {totalStats.balance.toFixed(2)}

+

Total acumulado

+
+
+
+ +
+
+
+ +
+
+

Entradas (Mesas/Extras)

+

R$ {(totalStats.paid + totalStats.transIncome).toFixed(2)}

@@ -148,11 +316,11 @@ export function FinancialDashboard({ events, players, group }: FinancialPageProp
- +
-

Pendente

-

R$ {totalPending.toFixed(2)}

+

Saídas / Despesas

+

R$ {totalStats.transExpense.toFixed(2)}

@@ -160,33 +328,65 @@ export function FinancialDashboard({ events, players, group }: FinancialPageProp
-
-

Gestão Financeira

-

Controle de mensalidades e eventos extras.

+
+ + +
- {selectedIds.size > 0 ? ( -
- {selectedIds.size} selecionados + {selectedIds.size > 0 && ( +
+
+ + {selectedIds.size} {selectedIds.size === 1 ? 'SELECIONADO' : 'SELECIONADOS'} + +
- ) : ( + )} + {selectedIds.size === 0 && ( <> -
- - setSearchQuery(e.target.value)} - placeholder="Buscar eventos..." - className="ui-input w-full pl-10 h-10 bg-surface-raised border-border/50 text-sm" - /> -
+ {mainTab !== 'REPORTS' && ( +
+ + setSearchQuery(e.target.value)} + placeholder={mainTab === 'EVENTS' ? "Buscar eventos..." : "Buscar fluxo..."} + className="ui-input w-full pl-10 h-10 bg-surface-raised border-border/50 text-sm" + /> +
+ )}
- + {mainTab === 'EVENTS' && ( + + )} + + {mainTab !== 'REPORTS' && ( + + )}
)}
+
-
- {[ +
+ {mainTab === 'EVENTS' ? ( + [ { id: 'ALL', label: 'Todos' }, { id: 'MONTHLY_FEE', label: 'Mensalidades' }, { id: 'EXTRA_EVENT', label: 'Eventos Extras' } @@ -241,44 +463,88 @@ export function FinancialDashboard({ events, players, group }: FinancialPageProp )} - ))} -
- {filteredEvents.length} REGISTROS + )) + ) : mainTab === 'TRANSACTIONS' ? ( + [ + { id: 'ALL', label: 'Histórico Completo' }, + { id: 'INCOME', label: 'Entradas' }, + { id: 'EXPENSE', label: 'Saídas' } + ].map((tab) => ( + + )) + ) : ( +
+ Resumo Inteligente
+ )} +
+ {mainTab === 'EVENTS' ? filteredEvents.length : mainTab === 'TRANSACTIONS' ? filteredTransactions.length : '1'} REGISTROS
+
-
- {filteredEvents.length > 0 && ( -
- -
- )} +
+ { + const selectableIds = mainTab === 'EVENTS' + ? filteredEvents.map(e => e.id) + : filteredTransactions.map(t => t.id) + return selectableIds.length > 0 && selectableIds.every(id => selectedIds.has(id)) + })() ? "text-primary" : "text-muted group-hover:text-foreground" + )}> + Selecionar Todos + + +
+ )} - - {filteredEvents.map((event) => { + + {mainTab === 'EVENTS' ? ( + filteredEvents.map((event) => { const percent = event.stats.totalExpected > 0 ? (event.stats.totalPaid / event.stats.totalExpected) * 100 : 0 const isExpanded = expandedEventId === event.id @@ -362,12 +628,15 @@ export function FinancialDashboard({ events, players, group }: FinancialPageProp className="border-t border-border bg-surface-raised/30 overflow-hidden" >
-
+
Compartilhar Relatório

Envie o status atual para o grupo do time.

-
+
-
+

Privacidade

@@ -443,23 +715,26 @@ export function FinancialDashboard({ events, players, group }: FinancialPageProp

Gerenciar Pagamentos

Ver Página Pública
-
+
{event.payments.map((payment: any) => (
{payment.status === 'PAID' ? : payment.player?.name.substring(0, 1).toUpperCase()}
- + {payment.player?.name}
@@ -485,29 +760,412 @@ export function FinancialDashboard({ events, players, group }: FinancialPageProp ) - })} - + }) + ) : mainTab === 'TRANSACTIONS' ? ( +
+ {groupedTransactions.map(([date, group]) => ( +
+ {/* Date Header */} +
+
+ {new Date(date + 'T12:00:00').getDate()} +
+
+ + {new Date(date + 'T12:00:00').toLocaleDateString('pt-BR', { weekday: 'short', month: 'long', year: 'numeric' })} + +
+
- {filteredEvents.length === 0 && ( -
-
- -
-
-

Nenhum Evento Encontrado

-

Tente ajustar sua busca ou mudar os filtros.

-
+ {/* Group Items */} +
+ {group.map((t) => ( + toggleSelection(t.id)} + > +
+ {/* Selection Checkbox on far left */} +
+
+ {selectedIds.has(t.id) && } +
+
+ +
+ {t.type === 'INCOME' ? : } +
+ +
+
+ + {t.origin === 'EVENT_PAYMENT' ? 'Evento' : 'Manual'} + + {t.category && ( + {t.category} + )} +
+

{t.description}

+
+ {t.player ? ( +
+
+ {t.player.name.substring(0, 1)} +
+ {t.player.name} +
+ ) : ( +

Movimentação Administrativa

+ )} +
+
+ +
+
+ {t.type === 'INCOME' ? '+' : '-'} R$ {t.amount.toFixed(2)} +
+ Liquidado +
+
+
+ ))} +
+
+ ))}
+ ) : ( + <> + {/* Smart Report Cards */} + +
+
+ +
+
+

Craque do Bolso

+

Atleta que mais contribuiu

+
+
+ + {totalStats.topPayer ? ( +
+
+
+ {totalStats.topPayer.name.substring(0, 1)} +
+
+

{totalStats.topPayer.name}

+

Sócio Diamante

+
+
+
+

R$ {totalStats.topPayer.totalPaid.toFixed(2)}

+

Total Pago

+
+
+ ) : ( +

Nenhum dado disponível

+ )} + +
+
Top 3 Pagadores
+ {totalStats.allPlayerStats.slice(0, 3).map((p, i) => ( +
+ {i + 1}º {p.name} + R$ {p.totalPaid.toFixed(2)} +
+ ))} +
+
+ + +
+
+ +
+
+

Distribuição de Gastos

+

Onde o dinheiro está saindo

+
+
+ +
+ {totalStats.categoryExpenses.length > 0 ? ( + totalStats.categoryExpenses.map((cat) => { + const totalExp = totalStats.transExpense || 1 + const percent = (cat.value / totalExp) * 100 + return ( +
+
+ {cat.name} + R$ {cat.value.toFixed(2)} +
+
+ +
+
+ ) + }) + ) : ( +
+ Sem despesas registradas +
+ )} +
+
+ + +
+
+
+ +
+
+

Saúde Financeira

+

Taxa de inadimplência

+
+
+
+

+ {totalStats.expected > 0 ? (100 - (totalStats.paid / totalStats.expected * 100)).toFixed(1) : '0'}% +

+

Pendente

+
+
+ +
+
+ + Insight +
+

+ {totalPending > 0 + ? `Atenção capitão! Você ainda tem R$ ${totalPending.toFixed(2)} para receber dos eventos pendentes. Que tal cobrar a galera?` + : "Excelente! Todas as contas do grupo estão em dia e o caixa está saudável."} +

+
+
+ + +
+
+ +
+
+

Projeção Próximo Mês

+

Estimativa baseada no atual

+
+
+ +
+
+

Entrada Prevista

+

R$ {totalStats.expected.toFixed(2)}

+
+
+

Custo Médio

+

R$ {totalStats.transExpense.toFixed(2)}

+
+
+ +
+

+ * Projeção calculada com base na média dos últimos eventos e despesas fixas registradas no sistema. +

+
+
+ )} -
+ + + {((mainTab === 'EVENTS' && filteredEvents.length === 0) || (mainTab === 'TRANSACTIONS' && filteredTransactions.length === 0)) && ( +
+
+ +
+
+

Nenhum Registro Encontrado

+

Tente ajustar sua busca ou mudar os filtros.

+
+
+ )}
+ {/* Filter Modal */} + + {isFilterModalOpen && ( +
+ setIsFilterModalOpen(false)} + className="absolute inset-0 bg-black/80 backdrop-blur-sm" + /> + +
+
+
+ +
+

Filtros Avançados

+
+ +
+ +
+
+ + { setStartDate(s); setEndDate(e); }} + placeholder="Selecione o intervalo de datas" + /> +
+ +
+ +
+ {(mainTab === 'EVENTS' + ? [ + { id: 'ALL', label: 'Todos' }, + { id: 'MONTHLY_FEE', label: 'Mensalidades' }, + { id: 'EXTRA_EVENT', label: 'Extras' } + ] + : [ + { id: 'ALL', label: 'Todos' }, + { id: 'INCOME', label: 'Entradas' }, + { id: 'EXPENSE', label: 'Saídas' } + ] + ).map((tab) => ( + + ))} +
+
+ + {mainTab === 'TRANSACTIONS' && categories.length > 0 && ( +
+ +
+ + {categories.map((cat: string) => ( + + ))} +
+
+ )} +
+ +
+ + +
+
+
+ )} +
+ setIsCreateModalOpen(false)} players={players} /> + setIsCreateTransactionModalOpen(false)} + players={players} + /> + setIsSettingsModalOpen(false)} @@ -521,7 +1179,7 @@ export function FinancialDashboard({ events, players, group }: FinancialPageProp isDeleting={deleteModal.isDeleting} title={deleteModal.title} description={deleteModal.description} - confirmText="Sim, excluir agora" + confirmText={deleteModal.confirmText} />
) diff --git a/src/components/MatchFlow.tsx b/src/components/MatchFlow.tsx index c3893b3..18b7aa7 100644 --- a/src/components/MatchFlow.tsx +++ b/src/components/MatchFlow.tsx @@ -1,9 +1,9 @@ 'use client' import React, { useState, useEffect, useRef } from 'react' -import { motion } from 'framer-motion' -import { Shuffle, Download, Check, Star, RefreshCw, ChevronDown, Trophy, Zap, Shield } from 'lucide-react' -import { createMatch, updateMatchStatus } from '@/actions/match' +import { motion, AnimatePresence } from 'framer-motion' +import { Shuffle, Download, Check, Star, RefreshCw, ChevronDown, Trophy, Zap, Shield, Users, ArrowLeft, HelpCircle, X, Clock, Calendar, MapPin } from 'lucide-react' +import { createMatch, updateMatchWithTeams, updateMatchStatus, logMatchEvent, submitReviews, getGamificationResults } from '@/actions/match' import { getMatchWithAttendance } from '@/actions/attendance' import { renderMatchCard } from '@/utils/MatchCardCanvas' import { clsx } from 'clsx' @@ -16,6 +16,7 @@ interface MatchFlowProps { group: any arenas?: Arena[] sponsors?: any[] + teamConfigs?: any[] } const getInitials = (name: string) => { @@ -40,8 +41,15 @@ const seededRandom = (seed: string) => { } } +const STATUS_LABELS: Record = { + 'CONVOCACAO': 'Convocação Aberta', + 'SORTEIO': 'Times Definidos', + 'IN_PROGRESS': 'Resenha em Aberto', + 'ENCERRAMENTO': 'Partida Finalizada' +} + // Sub-component for individual Canvas Preview -const CanvasTeamPreview = ({ team, group, date, location, drawSeed, sponsors, options, onDownload }: any) => { +const CanvasTeamPreview = ({ team, group, date, location, drawSeed, sponsors, options }: any) => { const canvasRef = useRef(null); const [rendering, setRendering] = useState(true); @@ -57,6 +65,7 @@ const CanvasTeamPreview = ({ team, group, date, location, drawSeed, sponsors, op await renderMatchCard(canvasRef.current, { groupName: group.name, logoUrl: group.logoUrl, + shirtUrl: team.shirtUrl, teamName: team.name, teamColor: team.color, day: day, @@ -80,7 +89,7 @@ const CanvasTeamPreview = ({ team, group, date, location, drawSeed, sponsors, op {rendering && (
- Renderizando Pixel Perfect... + Renderizando Alta Definição...
)} BAIXAR CAPA HD @@ -112,7 +121,7 @@ const CanvasTeamPreview = ({ team, group, date, location, drawSeed, sponsors, op ); }; -export function MatchFlow({ group, arenas = [], sponsors = [] }: MatchFlowProps) { +export function MatchFlow({ group, arenas = [], sponsors = [], teamConfigs = [] }: MatchFlowProps) { const searchParams = useSearchParams() const scheduledMatchId = searchParams.get('id') @@ -128,11 +137,11 @@ export function MatchFlow({ group, arenas = [], sponsors = [] }: MatchFlowProps) const offset = now.getTimezoneOffset() * 60000 return new Date(now.getTime() - offset).toISOString().split('T')[0] }) + const [matchDuration, setMatchDuration] = useState(60) // Default duration const [isSaving, setIsSaving] = useState(false) const [drawSeed, setDrawSeed] = useState('') const [location, setLocation] = useState('') const [selectedArenaId, setSelectedArenaId] = useState('') - const [mounted, setMounted] = useState(false) const [cardOptions, setCardOptions] = useState({ showNumbers: true, showPositions: true, @@ -140,9 +149,26 @@ export function MatchFlow({ group, arenas = [], sponsors = [] }: MatchFlowProps) showSponsors: true, }) - useEffect(() => { - setMounted(true) - }, []) + // Gamification States (Step 3) + const [enableVoting, setEnableVoting] = useState(false) // Starts false, user decides in Step 3 + const [votingDuration, setVotingDuration] = useState(72) + const [matchStatus, setMatchStatus] = useState('CONVOCACAO') + const [reviews, setReviews] = useState>({}) // playerId -> reviewType + const [gamificationResults, setGamificationResults] = useState([]) + const [voters, setVoters] = useState([]) + const [gamificationType, setGamificationType] = useState(null) + const [alertModal, setAlertModal] = useState<{ + isOpen: boolean + title: string + description: string + onConfirm?: () => void + type: 'error' | 'warning' + }>({ + isOpen: false, + title: '', + description: '', + type: 'error' + }) useEffect(() => { if (scheduledMatchId) { @@ -163,12 +189,36 @@ export function MatchFlow({ group, arenas = [], sponsors = [] }: MatchFlowProps) setLocation(data.location || '') if (data.arenaId) setSelectedArenaId(data.arenaId) setActiveMatchId(data.id) + setMatchStatus(data.status) + if (data.duration) setMatchDuration(data.duration) + if (data.enableVoting) setEnableVoting(data.enableVoting) + + // If match already has teams (SORTEIO status or higher), load them and jump to step 2/3 + if (data.teams && data.teams.length > 0) { + setTeams(data.teams.map((t: any) => ({ + ...t, + players: t.players.map((tp: any) => tp.player) + }))) + + if (data.status === ('SORTEIO' as any)) { + setStep(2) + } else if (data.status === ('ENCERRAMENTO' as any) || data.status === ('IN_PROGRESS' as any)) { + setStep(3) + loadResults(data.id) + } + } } } catch (error) { console.error('Erro ao lidar com dados agendados:', error) } } + const loadResults = async (id: string) => { + const res = await getGamificationResults(id) + setGamificationResults(res.results) + setVoters(res.votedPlayers) + } + const generateNewSeed = () => { setDrawSeed(Math.random().toString(36).substring(2, 8).toUpperCase()) } @@ -179,17 +229,59 @@ export function MatchFlow({ group, arenas = [], sponsors = [] }: MatchFlowProps) ) } - const performDraw = () => { - const playersToDraw = group.players.filter((p: any) => selectedPlayers.includes(p.id)) - if (playersToDraw.length < teamCount) return + const performDraw = (bypassRemainder = false) => { + if (selectedPlayers.length === 0) { + setAlertModal({ + isOpen: true, + title: 'Nenhum Atleta Selecionado', + description: 'Ops! Você precisa marcar quem está presente na lista de chamada para poder realizar o sorteio.', + type: 'error' + }) + return + } + if (selectedPlayers.length < teamCount) { + setAlertModal({ + isOpen: true, + title: 'Atletas Insuficientes', + description: `Você selecionou ${selectedPlayers.length} atleta(s), mas definiu que quer ${teamCount} times. Selecione pelo menos 1 atleta por time.`, + type: 'error' + }) + return + } + + // Warning for remaining players (uneven division) + const remainder = selectedPlayers.length % teamCount + if (remainder !== 0 && !bypassRemainder) { + const minSize = Math.floor(selectedPlayers.length / teamCount) + const numTeamsWithExtra = remainder + const numTeamsWithMin = teamCount - remainder + + setAlertModal({ + isOpen: true, + title: 'Times Desiguais', + description: `O sorteio resultará em uma distribuição desigual: ${numTeamsWithExtra} time(s) com ${minSize + 1} atletas e ${numTeamsWithMin} time(s) com ${minSize} atletas. Deseja continuar?`, + type: 'warning', + onConfirm: () => { + setAlertModal(prev => ({ ...prev, isOpen: false })) + performDraw(true) + } + }) + return + } + + const playersToDraw = group.players.filter((p: any) => selectedPlayers.includes(p.id)) const random = seededRandom(drawSeed) let pool = [...playersToDraw] - const newTeams: any[] = Array.from({ length: teamCount }, (_, i) => ({ - name: `Time ${i + 1}`, - players: [], - color: TEAM_COLORS[i % TEAM_COLORS.length] - })) + const newTeams: any[] = Array.from({ length: teamCount }, (_, i) => { + const config = teamConfigs[i] + return { + name: config?.name || `Time ${i + 1}`, + players: [], + color: config?.color || TEAM_COLORS[i % TEAM_COLORS.length], + shirtUrl: config?.shirtUrl || null + } + }) if (drawMode === 'balanced') { pool.sort((a, b) => { @@ -198,12 +290,15 @@ export function MatchFlow({ group, arenas = [], sponsors = [] }: MatchFlowProps) }) pool.forEach((p) => { - const teamWithLowestQuality = newTeams.reduce((prev, curr) => { + const teamWithLowestCount = newTeams.reduce((prev, curr) => { + if (prev.players.length !== curr.players.length) { + return (prev.players.length < curr.players.length) ? prev : curr + } const prevQuality = prev.players.reduce((sum: number, player: any) => sum + player.level, 0) const currQuality = curr.players.reduce((sum: number, player: any) => sum + player.level, 0) return (prevQuality <= currQuality) ? prev : curr }) - teamWithLowestQuality.players.push(p) + teamWithLowestCount.players.push(p) }) } else { pool = pool.sort(() => random() - 0.5) @@ -214,11 +309,27 @@ export function MatchFlow({ group, arenas = [], sponsors = [] }: MatchFlowProps) setTeams(newTeams) } - const handleConfirm = async () => { + const handleConfirmDraw = async () => { setIsSaving(true) try { - const match = await createMatch(group.id, matchDate, teams, 'IN_PROGRESS', location, selectedPlayers.length, drawSeed, selectedArenaId) + let match + if (activeMatchId) { + // Update existing + match = await updateMatchWithTeams(activeMatchId, teams, 'SORTEIO' as any, drawSeed, enableVoting, votingDuration, 'PADRAO', matchDuration) + } else { + // Create new + // @ts-ignore + match = await createMatch(group.id, matchDate, teams, 'SORTEIO' as any, location, selectedPlayers.length, drawSeed, selectedArenaId, enableVoting, votingDuration, 'PADRAO', matchDuration) + } + setActiveMatchId(match.id) + setMatchStatus('SORTEIO') + if ((match as any).teams) { + setTeams((match as any).teams.map((t: any) => ({ + ...t, + players: t.players.map((tp: any) => tp.player) + }))) + } setStep(2) } catch (error) { console.error(error) @@ -227,12 +338,55 @@ export function MatchFlow({ group, arenas = [], sponsors = [] }: MatchFlowProps) } } - const handleFinish = async () => { + const handleConfirmMatch = async () => { if (!activeMatchId) return setIsSaving(true) try { - await updateMatchStatus(activeMatchId, 'COMPLETED') - window.location.href = '/dashboard/matches' + // Step 2 -> 3: Just confirm match and set to IN_PROGRESS (meaning it's happening or ready for reviews) + // We DO NOT enable voting here yet unless it was already enabled. User decides in Step 3. + await updateMatchStatus(activeMatchId, 'IN_PROGRESS' as any) + setMatchStatus('IN_PROGRESS') + setStep(3) + } catch (error) { + console.error(error) + } finally { + setIsSaving(false) + } + } + + const handleEnableVoting = async () => { + if (!activeMatchId) return + setIsSaving(true) + try { + // Update match to enable voting + await updateMatchWithTeams(activeMatchId, teams, 'IN_PROGRESS' as any, drawSeed, true, votingDuration, 'PADRAO', matchDuration) + setEnableVoting(true) + } catch (error) { + console.error(error) + } finally { + setIsSaving(false) + } + } + + const handleFinalizeMatch = async () => { + if (!activeMatchId) return + setIsSaving(true) + try { + // Admin reviews are submitted with a specific context + const reviewsArray = Object.entries(reviews).map(([playerId, type]) => ({ + playerId, + type, + reviewerId: 'ADMIN' + })) + + if (reviewsArray.length > 0) { + await submitReviews(activeMatchId, reviewsArray, 'ADMIN') + } + + // @ts-ignore + await updateMatchStatus(activeMatchId, 'ENCERRAMENTO') + setMatchStatus('ENCERRAMENTO') + await loadResults(activeMatchId) } catch (error) { console.error(error) } finally { @@ -243,13 +397,31 @@ export function MatchFlow({ group, arenas = [], sponsors = [] }: MatchFlowProps) return (
{/* Step Header */} -
-
-
-
+
+
+
+
= 1 ? "bg-primary shadow-[0_0_10px_#10b981]" : "bg-zinc-800")} /> +
= 2 ? "bg-primary shadow-[0_0_10px_#10b981]" : "bg-zinc-800")} /> +
= 3 ? "bg-primary shadow-[0_0_10px_#10b981]" : "bg-zinc-800")} /> +
-
- Processo de Escalação +
+
+ {STATUS_LABELS[matchStatus] || matchStatus} +
+
+ + {step === 1 && "1. Configuração & Sorteio"} + {step === 2 && "2. Capas & Confirmação"} + {step === 3 && "3. Resenha & Gamificação"} + +
@@ -257,14 +429,14 @@ export function MatchFlow({ group, arenas = [], sponsors = [] }: MatchFlowProps)
{/* LEFT: Setup de Campo */}
-
+

Setup de Campo

Sorteio Inteligente TemFut

-
+
@@ -288,175 +460,536 @@ export function MatchFlow({ group, arenas = [], sponsors = [] }: MatchFlowProps)
+
+ +
+ setMatchDuration(Number(e.target.value))} + className="ui-input w-full h-10 bg-zinc-950/50 border-white/10 px-4 rounded-xl text-sm font-bold" + min="10" + max="120" + step="5" + /> + +
+
+
{[2, 3, 4].map(n => ( - + ))}
- - + +
-
- {/* RIGHT: Lista de Chamada */} + {/* RIGHT: Lista de Chamada & Preview */}
-
-
-
-

Lista de Chamada

-

Confirme os atletas presentes

-
-
-
- {selectedPlayers.length} -

Atletas

-
- -
-
- -
- {group.players.map((p: any) => ( - + +
+
+ +
+ {teams.map((t, i) => ( +
+
+
+

{t.name}

+ {t.players.length} JOG +
+
+ {t.players.map((p: any) => ( +
+ {p.name} +
+ {p.position} +
+ {[...Array(p.level || 0)].map((_, s) => )} +
+
+
+ ))}
-
- {selectedPlayers.includes(p.id) && } - - ))} -
-
- - {/* PREVIEW DO SORTEIO SECTION */} - {teams.length > 0 && ( - -
-
-
-

Resultado Preview

+ ))}
- -
- -
- {teams.map((t, i) => ( -
-
-
-

{t.name}

-
-
- {t.players.map((p: any) => ( -
- {p.name} - {p.position} -
- ))} -
-
- ))}
+ ) : ( +
+
+
+

Lista de Chamada

+

Confirme os atletas presentes

+
+
+
+ {selectedPlayers.length} +

Atletas

+
+ +
+
+ +
+ {group.players.map((p: any) => ( + + ))} +
+
)}
- ) : ( - /* STEP 2: CANVAS PREMIUM COVERS */ -
-
-
-
-

Capas de Convocação

-

Canvas Engine v3.0 HD

-
+ ) : step === 2 ? ( + /* STEP 2: CANVAS ONLY & CONFIRMATION */ +
+ {/* Header Clean */} +
+
+

Capas de Convocação

+

Gere os cards e divulgue no grupo

+
- {/* Card Customization Toolbar */} -
+
+ + +
+
+ +
+ {/* LEFT: Controls */} +
+
+
+ +

Opções de Visualização

+
{[ - { key: 'showNumbers', label: 'Nº' }, - { key: 'showPositions', label: 'Pos' }, + { key: 'showNumbers', label: 'Números' }, + { key: 'showPositions', label: 'Posições' }, { key: 'showStars', label: 'Nível' }, - { key: 'showSponsors', label: 'Patr' }, + { key: 'showSponsors', label: 'Patrocínios' }, ].map((opt) => ( ))}
-
- - -
-
- -
- {teams.map((t, i) => ( - - ))} -
- -
-
- - Gerado via TemFut Pixel Perfect Engine + {/* CENTER: Canvas Grid */} +
+ {teams.map((t, i) => ( + + ))}
- )} + ) : ( + /* STEP 3: RESENHA & GAMIFICATION (MANAGEMENT) */ +
+
+
+ + {matchStatus === 'ENCERRAMENTO' ? 'Partida Finalizada' : 'Pré-Resenha / Pós-Jogo'} +
+

+ {matchStatus === 'ENCERRAMENTO' ? "Resumo da Partida" : (gamificationResults.length > 0 ? "Resultados da Resenha" : "Central da Partida")} +

+
+ + {/* Logic: If Voting NOT Enabled yet, show card to enable it (if Group allows) & Match NOT Ended */} + {!enableVoting && gamificationResults.length === 0 && matchStatus !== 'ENCERRAMENTO' && ( +
+
+ +
+
+

Gamificação da Partida

+

Aquele momento de decidir quem jogou muito e quem pipocou.

+
+ + {group.votingEnabled ? ( +
+ {/* STEP A: SELECT MODULE */} +
+ +
+ + + {/* Future Module Placeholder */} + +
+
+ + {/* STEP B: CONFIGURE (Only if type selected) */} + + {gamificationType === 'PADRAO' && ( + + +
+ {[24, 48, 72].map(h => ( + + ))} + setVotingDuration(Number(e.target.value))} + className={clsx( + "w-20 bg-transparent text-center text-[10px] font-black uppercase outline-none rounded-xl transition-all placeholder:text-zinc-700", + ![24, 48, 72].includes(votingDuration) ? "bg-foreground text-background" : "text-muted hover:text-white" + )} + /> +
+ + +
+ )} +
+
+ ) : ( + + )} + + +
+ )} + + {/* Logic: Voting Enabled & Results Empty -> Show Voting Link & Admin Interface */} + {enableVoting && gamificationResults.length === 0 && ( +
+
+
+
+ +
+
+

Votação Aberta!

+

+ Compartilhe com a galera para votarem. +

+
+
+
+ + +
+
+ + {/* Admin Manual Voting / Review */} +
+
+

Seus Votos (Admin)

+ + Votos computados: {voters.length} (Total) + +
+ +
+ {teams.flatMap(t => t.players).map((p: any) => ( +
+
+
+ {getInitials(p.name)} +
+ {p.name} +
+
+ + + +
+
+ ))} +
+ + +
+
+ )} + + {/* Results View */} + {gamificationResults.length > 0 && ( +
+
+ {gamificationResults.map((res, i) => ( +
+ {i === 0 &&
} +
+
+ {res.player.number || getInitials(res.player.name)} +
+
+

{res.player.name}

+

Saldo: {res.craque - res.pereba}

+
+
+
+
Craque {res.craque}
+
Equilibrado {res.fairPlay}
+
Pereba {res.pereba}
+
+
+ ))} +
+ +
+ +
+
+ )} + + {/* Fallback View: Skipped Voting / Match Ended with no results */} + {matchStatus === 'ENCERRAMENTO' && gamificationResults.length === 0 && ( +
+
+ +
+
+

Partida Finalizada

+

+ A partida foi encerrada com sucesso sem votação de gamificação. +

+
+ +
+ )} +
)} + + + {/* NEW ALERT MODAL */} + + {alertModal.isOpen && ( +
+ setAlertModal(prev => ({ ...prev, isOpen: false }))} + className="absolute inset-0 bg-black/80 backdrop-blur-md" + /> + +
+
+
+ +
+
+

{alertModal.title}

+

+ {alertModal.description} +

+
+
+
+ +
+ + {alertModal.type === 'warning' && ( + + )} +
+
+
+ )} +
) } diff --git a/src/components/MatchHistory.tsx b/src/components/MatchHistory.tsx index ab49a29..b536151 100644 --- a/src/components/MatchHistory.tsx +++ b/src/components/MatchHistory.tsx @@ -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' }: {

- {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`}

{match.isRecurring && ( @@ -367,18 +405,43 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: { )}
-
- {s.label} -
+
-
- - {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' && ( +
+ 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.actionLabel} + +
+ )} +
+
+
+ + + {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`} + +
+
+ {s.label} +
+
@@ -388,20 +451,33 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: { {/* Actions only in List Mode here, else in modal */} {viewMode === 'list' && ( -
- {match.status === 'SCHEDULED' && ( +
+ 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.actionLabel} + + + {match.status === 'CONVOCACAO' && ( )} - +
)}
@@ -478,6 +554,17 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {

+ + {React.createElement(getStatusInfo(selectedMatch.status).icon, { className: "w-4 h-4" })} + {getStatusInfo(selectedMatch.status).actionLabel} +
- {selectedMatch.status === 'SCHEDULED' ? ( + {selectedMatch.status === 'CONVOCACAO' ? (
@@ -631,6 +718,17 @@ export function MatchHistory({ matches, players = [], groupName = 'Pelada' }: {
))}
+ + {selectedMatch.status === ('SORTEIO' as any) && ( +
+ + Iniciar Gamificação & Votação + +
+ )}
)}
diff --git a/src/components/MatchPodium.tsx b/src/components/MatchPodium.tsx new file mode 100644 index 0000000..c5d6bda --- /dev/null +++ b/src/components/MatchPodium.tsx @@ -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
+ + const isWinner = place === 1 + return ( + + {/* Crown for Winner */} + {isWinner && ( + + + + + )} + + {/* Avatar / Card */} +
+ {isWinner &&
} + +
+ + {result.player.number || getInitials(result.player.name)} + +
+ + {/* Badge Place */} +
+ {place}º LUGAR +
+
+ + {/* Name & Stats */} +
+

+ {result.player.name} + {isWinner && } +

+ +
+
+ Saldo + 0 ? "text-emerald-500" : "text-red-500")}> + {result.craque - result.pereba} + +
+
+
+
+ + {result.craque} +
+
+ + {result.pereba} +
+
+
+
+ + ) + } + + return ( +
+ {/* TOP 3 PODIUM */} +
+ {/* 2nd Place */} + {podiumOrder[0] && } + + {/* 1st Place */} + {podiumOrder[1] && } + + {/* 3rd Place */} + {podiumOrder[2] && } +
+ + {/* OTHER PLAYERS LIST */} + {sortedResults.length > 3 && ( + +

Demais Classificados

+
+ {sortedResults.slice(3).map((res, i) => ( +
+
+ #{i + 4} +
+
+ {res.player.number || getInitials(res.player.name)} +
+ {res.player.name} +
+
+
+
+ 0 ? "text-emerald-500" : "text-white/50")}> + {res.craque - res.pereba} pts + +
+
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/src/components/MatchScheduler.tsx b/src/components/MatchScheduler.tsx new file mode 100644 index 0000000..e5c9b83 --- /dev/null +++ b/src/components/MatchScheduler.tsx @@ -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 ( +
+
+
+
+ + Agendamento +
+

+ Criar Novo Evento +

+

Configure as datas e crie links de confirmação automáticos.

+
+
+ +
+
+
+
+
+ +
+

Detalhes do Evento

+
+ +
+ +
+ +
+ +
+
+ + + +
+ + setLocation(e.target.value)} + className="ui-input w-full h-12 bg-surface/50 text-sm" + /> +
+
+ +
+ +
+ + setMaxPlayers(e.target.value)} + className="ui-input w-full pl-10 h-12 bg-surface text-sm" + /> +
+
+
+ +
+
+
+
+ +
+

Recorrência

+
+ + +
+ + + {isRecurring && ( + +
+ {intervals.map((int) => ( + + ))} +
+ +
+ +

+ Deixe em branco para repetir indefinidamente. +

+
+
+ )} +
+
+ + +
+ + +
+
+ ) +} diff --git a/src/components/PlayersList.tsx b/src/components/PlayersList.tsx index 2106471..efbc27b 100644 --- a/src/components/PlayersList.tsx +++ b/src/components/PlayersList.tsx @@ -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(null) - const [isAdding, setIsAdding] = useState(false) + const [isSaving, setIsSaving] = useState(false) const [isFormOpen, setIsFormOpen] = useState(false) + const [editingPlayer, setEditingPlayer] = useState(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 }) { >
-
+
@@ -337,17 +367,17 @@ export function PlayersList({ group }: { group: any }) {
@@ -538,16 +568,28 @@ export function PlayersList({ group }: { group: any }) { Status: {getLevelInfo(p.level).label}
)} - +
+ + +
))} diff --git a/src/components/SettingsForm.tsx b/src/components/SettingsForm.tsx index a3fa35b..90bc650 100644 --- a/src/components/SettingsForm.tsx +++ b/src/components/SettingsForm.tsx @@ -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) {
+
{/* Status Messages */} diff --git a/src/components/SettingsTabs.tsx b/src/components/SettingsTabs.tsx index 1e72899..6ef5775 100644 --- a/src/components/SettingsTabs.tsx +++ b/src/components/SettingsTabs.tsx @@ -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 (
-
+
{tabs.map((tab) => (
- +
+ + +
))} @@ -97,22 +120,41 @@ export function SponsorsManager({ groupId, sponsors }: SponsorsManagerProps) {
{ 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"> + +
+

+ {editingSponsor ? 'Editando Patrocinador' : 'Adicionar Novo Patrocinador'} +

+ {editingSponsor && ( + + )} +
+
) : ( - + editingSponsor ? : )} - Adicionar + {editingSponsor ? 'Salvar' : 'Adicionar'}
diff --git a/src/components/TeamsManager.tsx b/src/components/TeamsManager.tsx new file mode 100644 index 0000000..4413ab9 --- /dev/null +++ b/src/components/TeamsManager.tsx @@ -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(null) + const [selectedColor, setSelectedColor] = useState('#10b981') + const [editingTeam, setEditingTeam] = useState(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 ( +
+
+

+ + Times Padrão +

+

+ Configure os nomes, cores e camisas dos times do seu futebol. +

+
+ +
+ {teams.map((team) => ( +
+
+
+ {team.shirtUrl ? ( + {team.name} + ) : ( + + )} +
+
+

{team.name}

+
+
+ {team.color} +
+
+
+ +
+ + +
+
+ ))} + + {teams.length === 0 && ( +
+ +

Nenhum time configurado

+

Adicione os times que costumam jogar na sua pelada.

+
+ )} +
+ + { + 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"> + + +
+

+ {editingTeam ? 'Editando Time' : 'Adicionar Novo Time'} +

+ {editingTeam && ( + + )} +
+ +
+
+ + +
+ +
+ +
+ setSelectedColor(e.target.value)} + className="w-12 h-[42px] p-1 bg-surface-raised border border-border rounded-lg cursor-pointer" + /> +
+ {selectedColor} +
+
+
+ +
+ +
+ { + const file = e.target.files?.[0] + if (file) { + const reader = new FileReader() + reader.onloadend = () => setFilePreview(reader.result as string) + reader.readAsDataURL(file) + } + }} + /> + +
+
+ +
+ +
+
+ + + 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" + /> +
+ ) +} diff --git a/src/components/VotingFlow.tsx b/src/components/VotingFlow.tsx new file mode 100644 index 0000000..bc3fa79 --- /dev/null +++ b/src/components/VotingFlow.tsx @@ -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 ( +
+ + + A votação fecha em: {String(timeLeft.h).padStart(2, '0')}:{String(timeLeft.m).padStart(2, '0')}:{String(timeLeft.s).padStart(2, '0')} + +
+ ) +} + +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>({}) + 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) => { + 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 ( +
+
+
+ +
+

+ Quem está
na resenha? +

+

Selecione seu nome para começar

+
+ +
+ {/* Search Bar */} +
+
+ +
+ 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 && ( + + )} +
+ + {/* Players Grid */} +
+ + {filteredSearchPlayers.map((p) => { + const isSelected = selectedReviewerId === p.id + const hasAlreadyVoted = voters.some(v => v.id === p.id) + + return ( + !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]" + )} + > +
+ {isSelected ? : hasAlreadyVoted ? : } +
+
+

{p.name}

+

{p.position || 'Jogador'}

+
+ + {hasAlreadyVoted && ( +
+ JÁ VOTOU +
+ )} + + {/* Selection Glow */} + {isSelected && ( + + )} +
+ ) + })} +
+ + {filteredSearchPlayers.length === 0 && ( +
+ +

Nenhum jogador encontrado

+
+ )} +
+ + {/* Action Button */} +
+ +
+
+ + +
+ ) + } + + if (step === 'voting') { + if (!currentPlayer) { + return ( +
+

Carregando jogador ou nenhum jogador para votar...

+ +
+ ) + } + return ( +
+ {/* Progress Bar */} +
+
+ Resenha + {currentPlayerIndex + 1} / {filteredPlayers.length} +
+
+ +
+
+ + {/* Player Card Container */} +
+ + +
+ {/* Decorator Background */} +
+ +
+
+ {currentPlayer.number || '??'} +
+
+
+ +
+

{currentPlayer.name}

+

{currentPlayer.position}

+
+
+ + {/* Voting Actions */} +
+ + + + + +
+
+ + +
+ + {/* Navigation Controls */} +
+ + + {currentPlayerIndex === filteredPlayers.length - 1 && votes[currentPlayer.id] ? ( + + ) : ( + + )} +
+
+ ) + } + + if (step === 'success') { + return ( +
+
+
+
+
+ +
+
+ +
+

Voto
Contabilizado!

+

+ Sua opinião é o que faz o TemFut real.
Obrigado por fortalecer a resenha! +

+
+ +
+ + TemFut Gamification Engine +
+
+ + {/* Voter Status */} +
+
+
+
+ +

Monitor da Resenha

+
+
+ {voters.length} / {allPlayers.length} VOTARAM +
+
+ +
+ {allPlayers.map((p: any) => { + const hasVoted = voters.some((v: any) => v.id === p.id) + return ( +
+
+ {p.name} + {hasVoted && } +
+ ) + })} +
+
+
+
+ ) + } + + return null +} diff --git a/src/components/VotingSettings.tsx b/src/components/VotingSettings.tsx new file mode 100644 index 0000000..df9440b --- /dev/null +++ b/src/components/VotingSettings.tsx @@ -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(null) + const [successMsg, setSuccessMsg] = useState(null) + const router = useRouter() + + const handleSubmit = async (e: React.FormEvent) => { + 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 ( +
+
+
+
+ +
+
+

Sistema de Resenha (Gamificação)

+

+ Permite que os jogadores votem no "Craque", "Pereba" e "Equilibrado" após cada partida. + Isso gera um ranking divertido e engajamento no grupo. +

+
+
+ +
+
+ Habilitar Votação +
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" + )} + > +
+
+
+

+ {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." + } +

+
+ +
+

+ Regras Atuais: Craque (+1), Equilibrado (0), Pereba (-1) +

+
+
+ + + {error && ( + + +

{error}

+
+ )} + {successMsg && ( + + +

{successMsg}

+
+ )} +
+ +
+ +
+ + ) +} diff --git a/src/utils/MatchCardCanvas.ts b/src/utils/MatchCardCanvas.ts index c7a4114..fa4ddab 100644 --- a/src/utils/MatchCardCanvas.ts +++ b/src/utils/MatchCardCanvas.ts @@ -6,6 +6,7 @@ interface RenderData { groupName: string; logoUrl?: string; + shirtUrl?: string; teamName: string; teamColor: string; day: string; @@ -82,6 +83,33 @@ const removeWhiteBackground = (img: HTMLImageElement): HTMLCanvasElement => { return canvas; }; +// --- COLOR CONTRAST UTILS --- +const getLuminance = (hex: string) => { + const r = parseInt(hex.slice(1, 3), 16) / 255; + const g = parseInt(hex.slice(3, 5), 16) / 255; + const b = parseInt(hex.slice(5, 7), 16) / 255; + const a = [r, g, b].map(v => v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)); + return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; +}; + +const getAdaptiveColor = (teamColor: string) => { + const luminance = getLuminance(teamColor); + // If team color is too LIGHT (> 0.6 luminance) on a white background, DARKEN it + if (luminance > 0.6) { + let r = parseInt(teamColor.slice(1, 3), 16); + let g = parseInt(teamColor.slice(3, 5), 16); + let b = parseInt(teamColor.slice(5, 7), 16); + + // Darken the color significantly to be legible on white + r = Math.round(r * 0.7); + g = Math.round(g * 0.7); + b = Math.round(b * 0.7); + + return `rgb(${r}, ${g}, ${b})`; + } + return teamColor; +}; + // Global control for concurrency let lastRenderTimestamp: Record = {}; @@ -89,6 +117,13 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat const ctx = canvas.getContext('2d'); if (!ctx) return; + // WAIT FOR FONTS TO BE READY - Essential for accurate measureText and clean first-time renders + try { + await (document as any).fonts.ready; + } catch (e) { + console.warn('Fonts not ready or API not supported'); + } + const canvasId = canvas.id || 'default-canvas'; const currentRenderTime = Date.now(); lastRenderTimestamp[canvasId] = currentRenderTime; @@ -98,10 +133,26 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat canvas.width = W; canvas.height = H; - // --- 1. CLEAN DARK BACKGROUND --- - ctx.fillStyle = '#050505'; + // --- 1. PREMIUM WHITE THEME DEFAULT --- + // User requested White as the default for everything. + + // Explicitly reset properties that might leak from previous renders + ctx.shadowBlur = 0; + ctx.shadowColor = 'transparent'; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + (ctx as any).letterSpacing = '0px'; + ctx.globalAlpha = 1.0; + + ctx.fillStyle = '#f5f5f5'; ctx.fillRect(0, 0, W, H); + const baseTextColor = '#050505'; + const mutedTextColor = 'rgba(0,0,0,0.5)'; + const beamColor = 'rgba(0,0,0,0.04)'; + const accentLineColor = 'rgba(0,0,0,0.1)'; + const isWhiteCard = true; // Hardcoded default now + // --- 2. ASYNC ASSETS LOADING --- let logoImg: HTMLImageElement | null = null; let cleanedLogo: HTMLCanvasElement | HTMLImageElement | null = null; @@ -115,8 +166,19 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat if (lastRenderTimestamp[canvasId] !== currentRenderTime) return; - // --- JERSEY BACKGROUND ELEMENT (Using cleaned logo) --- - drawJersey(ctx, W - 450, H * 0.4, 800, data.teamColor, cleanedLogo); + // --- JERSEY BACKGROUND ELEMENT --- + let shirtImg: HTMLImageElement | null = null; + try { + if (data.shirtUrl) { + shirtImg = await loadImg(data.shirtUrl); + } + } catch (e) { } + + if (shirtImg) { + drawJerseyWithImage(ctx, W * 0.5, H * 0.5, 1200, data.teamColor, shirtImg); + } else { + drawJersey(ctx, W * 0.5, H * 0.5, 1200, data.teamColor, cleanedLogo); + } // --- 3. THE HEADER (STABLE) --- const margin = 80; @@ -138,20 +200,41 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat ctx.textAlign = 'left'; ctx.textBaseline = 'top'; - ctx.fillStyle = data.teamColor; - ctx.font = 'italic 900 80px Inter, sans-serif'; - ctx.fillText(data.teamName.toUpperCase(), textX, headerY + 10); + const teamLum = getLuminance(data.teamColor); + const adaptiveColor = getAdaptiveColor(data.teamColor); - ctx.fillStyle = '#ffffff'; + // DYNAMIC FONT SIZE FOR TEAM NAME + ctx.save(); + ctx.fillStyle = adaptiveColor; + + // SHADOW TREATMENT (CLEANER LOOK) + ctx.shadowColor = 'rgba(0,0,0,0.05)'; + ctx.shadowBlur = 4; + ctx.shadowOffsetY = 2; + + const maxTeamNameWidth = W - textX - margin; + let teamNameFontSize = 80; + ctx.font = `italic 900 ${teamNameFontSize}px Inter, sans-serif`; + while (ctx.measureText(data.teamName.toUpperCase()).width > maxTeamNameWidth && teamNameFontSize > 40) { + teamNameFontSize -= 2; + ctx.font = `italic 900 ${teamNameFontSize}px Inter, sans-serif`; + } + ctx.fillText(data.teamName.toUpperCase(), textX, headerY + 10); + ctx.restore(); + + ctx.fillStyle = baseTextColor; ctx.font = '900 32px Inter, sans-serif'; - ctx.letterSpacing = '8px'; + (ctx as any).letterSpacing = '8px'; ctx.fillText(data.groupName.toUpperCase(), textX, headerY + 95); - ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.fillStyle = mutedTextColor; ctx.font = '900 24px Inter, sans-serif'; - ctx.letterSpacing = '5px'; + (ctx as any).letterSpacing = '5px'; ctx.fillText(`${data.day} ${data.month.toUpperCase()} • ${data.time} • ${data.location.toUpperCase()}`, textX, headerY + 145); + // RESET letterSpacing after use + (ctx as any).letterSpacing = '0px'; + ctx.textBaseline = 'alphabetic'; // --- 4. THE LINEUP (ELITE INLINE) --- @@ -161,30 +244,47 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat ctx.textBaseline = 'middle'; // Thin accent line - ctx.strokeStyle = 'rgba(255,255,255,0.15)'; + ctx.strokeStyle = accentLineColor; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(margin, 385); ctx.lineTo(margin + 120, 385); ctx.stroke(); - ctx.fillStyle = '#fff'; + ctx.fillStyle = baseTextColor; ctx.font = 'italic 900 42px Inter, sans-serif'; ctx.letterSpacing = '18px'; ctx.fillText('ESCALAÇÃO', margin + 180, 385); // Team color accent dot - ctx.fillStyle = data.teamColor; + ctx.fillStyle = adaptiveColor; ctx.beginPath(); ctx.arc(margin + 155, 385, 6, 0, Math.PI * 2); ctx.fill(); ctx.restore(); let listY = 460; - const itemH = 130; - const spacing = 32; + const footerTop = H - 460; // Increased gap significantly for "respiro" + const availableSpace = footerTop - listY; + + // Dynamic height calculation + const playerCount = data.players.length; + let itemH = 130; + let spacing = 32; + + // Calculate total height needed with current settings + const totalExpectedH = playerCount * (itemH + spacing) - spacing; + + // If total height exceeds available space, shrink items and spacing + if (totalExpectedH > availableSpace) { + const ratio = availableSpace / totalExpectedH; + itemH = Math.max(75, Math.floor(itemH * ratio)); + spacing = Math.max(8, Math.floor(spacing * ratio)); + } + const rowSlant = 42; data.players.forEach((p, i) => { - if (listY > H - 350) return; + // Skip rendering if we run out of space (absolute safety) + if (listY + itemH > footerTop + 60) return; ctx.save(); ctx.translate(margin, listY); @@ -194,7 +294,7 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(cardW - rowSlant, 0); ctx.lineTo(cardW, itemH); ctx.lineTo(rowSlant, itemH); ctx.closePath(); - ctx.fillStyle = 'rgba(255, 255, 255, 0.035)'; + ctx.fillStyle = beamColor; ctx.fill(); // B. Slanted Color Accent @@ -205,7 +305,7 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat ctx.lineTo(cardW, itemH); ctx.lineTo(cardW - accentWidth, itemH); ctx.closePath(); - ctx.fillStyle = data.teamColor; + ctx.fillStyle = adaptiveColor; ctx.fill(); // --- INLINE FLOW --- @@ -214,31 +314,38 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat // 1. Number (Sleek) if (data.options.showNumbers) { + ctx.save(); ctx.textAlign = 'left'; - ctx.fillStyle = data.teamColor; - ctx.font = 'italic 900 64px Inter, sans-serif'; + ctx.fillStyle = adaptiveColor; + + const numFontSize = Math.max(40, Math.floor(64 * (itemH / 130))); + ctx.font = `italic 900 ${numFontSize}px Inter, sans-serif`; const n = String(p.number || (i + 1)).padStart(2, '0'); ctx.fillText(n, flowX, itemH / 2); - flowX += 130; + ctx.restore(); + flowX += Math.max(90, Math.floor(130 * (itemH / 130))); } // 2. Name const pName = formatName(p.name).toUpperCase(); ctx.textAlign = 'left'; - ctx.fillStyle = '#fff'; - ctx.font = 'italic 900 50px Inter, sans-serif'; + ctx.fillStyle = baseTextColor; + const nameFontSize = Math.max(30, Math.floor(50 * (itemH / 130))); + ctx.font = `italic 900 ${nameFontSize}px Inter, sans-serif`; ctx.fillText(pName, flowX, itemH / 2); const nW = ctx.measureText(pName).width; - flowX += nW + 50; + flowX += nW + 40; // 3. Level Stars (Actual Stars) if (data.options.showStars) { + const starSize = Math.max(8, Math.floor(12 * (itemH / 130))); + const starSpacing = Math.max(25, Math.floor(40 * (itemH / 130))); for (let s = 0; s < 5; s++) { - ctx.fillStyle = s < p.level ? data.teamColor : 'rgba(255,255,255,0.06)'; - drawStar(ctx, flowX + (s * 40), itemH / 2, 12, 5, 5); + ctx.fillStyle = s < p.level ? adaptiveColor : (isWhiteCard ? 'rgba(0,0,0,0.06)' : 'rgba(255,255,255,0.06)'); + drawStar(ctx, flowX + (s * starSpacing), itemH / 2, starSize, 5, starSize * 0.4); ctx.fill(); } - flowX += 200; + flowX += (starSpacing * 5); } // 4. Position Tag (Glass Tag) @@ -247,9 +354,10 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat const posC = getPosColor(p.position, 0.2); const borC = getPosColor(p.position, 0.8); - ctx.font = '900 20px Inter, sans-serif'; + const labelFontSize = Math.max(14, Math.floor(20 * (itemH / 130))); + ctx.font = `900 ${labelFontSize}px Inter, sans-serif`; const tw = ctx.measureText(posT).width + 30; - const th = 38; + const th = Math.floor(38 * (itemH / 130)); ctx.fillStyle = posC; drawRoundRect(ctx, flowX, (itemH / 2) - (th / 2), tw, th, 8); @@ -258,7 +366,7 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat ctx.lineWidth = 1; ctx.stroke(); - ctx.fillStyle = '#fff'; + ctx.fillStyle = isWhiteCard ? '#000' : '#fff'; ctx.textAlign = 'center'; ctx.fillText(posT, flowX + (tw / 2), itemH / 2 + 1); } @@ -269,12 +377,13 @@ export async function renderMatchCard(canvas: HTMLCanvasElement, data: RenderDat // --- 5. FOOTER (SPONSORS) --- if (data.options.showSponsors && data.sponsors && data.sponsors.length > 0) { - let footerY = H - 320; + let footerY = H - 280; // Moved slightly lower to give more room above ctx.textAlign = 'center'; - ctx.fillStyle = 'rgba(255,255,255,0.2)'; + ctx.fillStyle = mutedTextColor; ctx.font = '900 24px Inter, sans-serif'; - ctx.letterSpacing = '12px'; + (ctx as any).letterSpacing = '12px'; ctx.fillText('PATROCINADORES', W / 2, footerY); + (ctx as any).letterSpacing = '0px'; footerY += 80; const spW = 320; @@ -339,36 +448,58 @@ function drawJersey(ctx: CanvasRenderingContext2D, x: number, y: number, w: numb ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 14; - ctx.globalAlpha = 0.08; + ctx.globalAlpha = 0.08; // Increased just a bit for visibility - // LONG SLEEVE JERSEY PATH (Pro Fit) - ctx.moveTo(w * 0.25, 0); - ctx.lineTo(w * 0.75, 0); - ctx.lineTo(w * 1.3, w * 0.4); // Extended sleeve top right - ctx.lineTo(w * 1.1, w * 0.65); // Extended sleeve bottom right - ctx.lineTo(w * 0.85, w * 0.45); // Armpit right - ctx.lineTo(w * 0.85, h); - ctx.lineTo(w * 0.15, h); - ctx.lineTo(w * 0.15, w * 0.45); - ctx.lineTo(-w * 0.1, w * 0.65); // Extended sleeve bottom left - ctx.lineTo(-w * 0.3, w * 0.4); // Extended sleeve top left - ctx.lineTo(w * 0.25, 0); + // CENTERED LARGE JERSEY - Adjusting coordinates to center around (0,0) + ctx.setLineDash([20, 10]); + + const offsetW = w * 0.5; + const offsetH = h * 0.4; + + // LONG SLEEVE JERSEY PATH (Centered) + ctx.moveTo(w * 0.25 - offsetW, 0 - offsetH); + ctx.lineTo(w * 0.75 - offsetW, 0 - offsetH); + ctx.lineTo(w * 1.3 - offsetW, w * 0.4 - offsetH); + ctx.lineTo(w * 1.1 - offsetW, w * 0.65 - offsetH); + ctx.lineTo(w * 0.85 - offsetW, w * 0.45 - offsetH); + ctx.lineTo(w * 0.85 - offsetW, h - offsetH); + ctx.lineTo(w * 0.15 - offsetW, h - offsetH); + ctx.lineTo(w * 0.15 - offsetW, w * 0.45 - offsetH); + ctx.lineTo(-w * 0.1 - offsetW, w * 0.65 - offsetH); + ctx.lineTo(-w * 0.3 - offsetW, w * 0.4 - offsetH); + ctx.lineTo(w * 0.25 - offsetW, 0 - offsetH); ctx.stroke(); - // Neck detail + // Neck detail (Centered) ctx.beginPath(); - ctx.arc(w * 0.5, 0, w * 0.18, 0, Math.PI); + ctx.arc(w * 0.5 - offsetW, 0 - offsetH, w * 0.18, 0, Math.PI); ctx.stroke(); // THE LOGO ON THE JERSEY (Refined Watermark) if (logoImg) { ctx.save(); - ctx.globalAlpha = 0.04; - const iconSize = w * 0.35; - ctx.drawImage(logoImg, w * 0.5 - iconSize / 2, w * 0.25, iconSize, iconSize); + ctx.globalAlpha = 0.07; + const iconSize = w * 0.4; + ctx.drawImage(logoImg, w * 0.5 - offsetW - iconSize / 2, w * 0.3 - offsetH, iconSize, iconSize); ctx.restore(); } ctx.restore(); } + +function drawJerseyWithImage(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, color: string, shirtImg: HTMLImageElement) { + const h = w * 1.5; + ctx.save(); + ctx.translate(x, y); + ctx.rotate(15 * Math.PI / 180); + + // ULTRA LARGE TRANSLUCENT JERSEY + ctx.globalAlpha = 0.09; + + // Draw the shirt image centered + const iconSize = w * 1.25; // Even larger + ctx.drawImage(shirtImg, -iconSize / 2, -iconSize * 0.3, iconSize, iconSize); + + ctx.restore(); +}