Compare commits
13 Commits
b6962e1024
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 32bfbcadb1 | |||
| 88aa6d72b1 | |||
| 3a3dbe0ef1 | |||
| 79b41a3650 | |||
| f282775c9a | |||
| 739ffe510d | |||
| 8a264cd2b1 | |||
| 924c5c8420 | |||
| e6430e855b | |||
| 71ba829d6c | |||
| b54e2265d9 | |||
| 4438bc5a93 | |||
| 30d573d1f8 |
@@ -0,0 +1,252 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RacePlannerApi.Data;
|
||||
using RacePlannerApi.DTOs;
|
||||
using RacePlannerApi.Models;
|
||||
|
||||
namespace RacePlannerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class AnnouncementsController : ControllerBase
|
||||
{
|
||||
private readonly RacePlannerDbContext _context;
|
||||
|
||||
public AnnouncementsController(RacePlannerDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Organizer")]
|
||||
public async Task<ActionResult<AnnouncementDto>> CreateAnnouncement(CreateAnnouncementRequest request)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
// Verify event exists and user is organizer
|
||||
var eventEntity = await _context.Events
|
||||
.FirstOrDefaultAsync(e => e.Id == request.EventId);
|
||||
|
||||
if (eventEntity == null)
|
||||
{
|
||||
return NotFound(new { error = "Event not found" });
|
||||
}
|
||||
|
||||
if (eventEntity.OrganizerId != userId.Value)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var announcement = new Announcement
|
||||
{
|
||||
EventId = request.EventId,
|
||||
Title = request.Title,
|
||||
Content = request.Content,
|
||||
AuthorId = userId.Value,
|
||||
IsPublished = true
|
||||
};
|
||||
|
||||
_context.Announcements.Add(announcement);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Load related data
|
||||
await _context.Entry(announcement)
|
||||
.Reference(a => a.Event)
|
||||
.LoadAsync();
|
||||
await _context.Entry(announcement)
|
||||
.Reference(a => a.Author)
|
||||
.LoadAsync();
|
||||
|
||||
// TODO: Send notifications to registered participants (Task 7.7)
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetAnnouncement),
|
||||
new { id = announcement.Id },
|
||||
MapToAnnouncementDto(announcement));
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<AnnouncementDto>> GetAnnouncement(Guid id)
|
||||
{
|
||||
var announcement = await _context.Announcements
|
||||
.Include(a => a.Event)
|
||||
.Include(a => a.Author)
|
||||
.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
||||
if (announcement == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Only show published announcements to participants
|
||||
if (!announcement.IsPublished)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null || announcement.Event.OrganizerId != userId.Value)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(MapToAnnouncementDto(announcement));
|
||||
}
|
||||
|
||||
[HttpGet("event/{eventId}")]
|
||||
public async Task<ActionResult<IEnumerable<AnnouncementDto>>> GetEventAnnouncements(Guid eventId)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
var isOrganizer = false;
|
||||
|
||||
if (userId != null)
|
||||
{
|
||||
isOrganizer = await _context.Events
|
||||
.AnyAsync(e => e.Id == eventId && e.OrganizerId == userId.Value);
|
||||
}
|
||||
|
||||
var query = _context.Announcements
|
||||
.Include(a => a.Event)
|
||||
.Include(a => a.Author)
|
||||
.Where(a => a.EventId == eventId)
|
||||
.AsQueryable();
|
||||
|
||||
// Only show published announcements to non-organizers
|
||||
if (!isOrganizer)
|
||||
{
|
||||
query = query.Where(a => a.IsPublished);
|
||||
}
|
||||
|
||||
var announcements = await query
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(announcements.Select(MapToAnnouncementDto));
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
[Authorize(Roles = "Organizer")]
|
||||
public async Task<ActionResult<AnnouncementDto>> UpdateAnnouncement(Guid id, UpdateAnnouncementRequest request)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var announcement = await _context.Announcements
|
||||
.Include(a => a.Event)
|
||||
.Include(a => a.Author)
|
||||
.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
||||
if (announcement == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Only organizer can update
|
||||
if (announcement.Event.OrganizerId != userId.Value)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if (request.Title != null) announcement.Title = request.Title;
|
||||
if (request.Content != null) announcement.Content = request.Content;
|
||||
if (request.IsPublished.HasValue) announcement.IsPublished = request.IsPublished.Value;
|
||||
|
||||
announcement.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(MapToAnnouncementDto(announcement));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Organizer")]
|
||||
public async Task<IActionResult> DeleteAnnouncement(Guid id)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var announcement = await _context.Announcements
|
||||
.Include(a => a.Event)
|
||||
.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
||||
if (announcement == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Only organizer can delete
|
||||
if (announcement.Event.OrganizerId != userId.Value)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
_context.Announcements.Remove(announcement);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("my-announcements")]
|
||||
public async Task<ActionResult<IEnumerable<AnnouncementDto>>> GetMyAnnouncements()
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
// Get user registrations
|
||||
var userRegistrationEventIds = await _context.Registrations
|
||||
.Where(r => r.ParticipantId == userId.Value && r.Status != RegistrationStatus.Cancelled)
|
||||
.Select(r => r.EventId)
|
||||
.ToListAsync();
|
||||
|
||||
// Get announcements for those events
|
||||
var announcements = await _context.Announcements
|
||||
.Include(a => a.Event)
|
||||
.Include(a => a.Author)
|
||||
.Where(a => userRegistrationEventIds.Contains(a.EventId) && a.IsPublished)
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(announcements.Select(MapToAnnouncementDto));
|
||||
}
|
||||
|
||||
private Guid? GetCurrentUserId()
|
||||
{
|
||||
var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (Guid.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static AnnouncementDto MapToAnnouncementDto(Announcement announcement)
|
||||
{
|
||||
return new AnnouncementDto
|
||||
{
|
||||
Id = announcement.Id,
|
||||
EventId = announcement.EventId,
|
||||
EventName = announcement.Event?.Name ?? string.Empty,
|
||||
Title = announcement.Title,
|
||||
Content = announcement.Content,
|
||||
AuthorId = announcement.AuthorId,
|
||||
AuthorName = announcement.Author?.Name ?? string.Empty,
|
||||
CreatedAt = announcement.CreatedAt,
|
||||
UpdatedAt = announcement.UpdatedAt,
|
||||
IsPublished = announcement.IsPublished
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RacePlannerApi.Data;
|
||||
using RacePlannerApi.DTOs;
|
||||
using RacePlannerApi.Models;
|
||||
|
||||
namespace RacePlannerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class DashboardController : ControllerBase
|
||||
{
|
||||
private readonly RacePlannerDbContext _context;
|
||||
|
||||
public DashboardController(RacePlannerDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
[HttpGet("organizer")]
|
||||
[Authorize(Roles = "Organizer")]
|
||||
public async Task<ActionResult<OrganizerDashboardDto>> GetOrganizerDashboard()
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var events = await _context.Events
|
||||
.Include(e => e.Registrations)
|
||||
.ThenInclude(r => r.Payments)
|
||||
.Where(e => e.OrganizerId == userId.Value)
|
||||
.ToListAsync();
|
||||
|
||||
var totalEvents = events.Count;
|
||||
var publishedEvents = events.Count(e => e.Status == EventStatus.Published);
|
||||
var draftEvents = events.Count(e => e.Status == EventStatus.Draft);
|
||||
|
||||
var allRegistrations = events.SelectMany(e => e.Registrations).ToList();
|
||||
var totalRegistrations = allRegistrations.Count;
|
||||
var pendingRegistrations = allRegistrations.Count(r => r.Status == RegistrationStatus.Pending);
|
||||
var confirmedRegistrations = allRegistrations.Count(r => r.Status == RegistrationStatus.Confirmed);
|
||||
var cancelledRegistrations = allRegistrations.Count(r => r.Status == RegistrationStatus.Cancelled);
|
||||
|
||||
var totalRevenue = allRegistrations
|
||||
.SelectMany(r => r.Payments)
|
||||
.Sum(p => p.Amount);
|
||||
|
||||
var upcomingEvents = events
|
||||
.Where(e => e.EventDate >= DateTime.UtcNow && e.EventDate <= DateTime.UtcNow.AddDays(30))
|
||||
.OrderBy(e => e.EventDate)
|
||||
.Take(5)
|
||||
.Select(e => new EventSummaryDto
|
||||
{
|
||||
Id = e.Id,
|
||||
Name = e.Name,
|
||||
EventDate = e.EventDate,
|
||||
Location = e.Location,
|
||||
RegistrationCount = e.Registrations.Count(r => r.Status != RegistrationStatus.Cancelled),
|
||||
MaxParticipants = e.MaxParticipants
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var eventsNearCapacity = events
|
||||
.Where(e => e.MaxParticipants.HasValue && e.Status == EventStatus.Published)
|
||||
.Select(e => new EventCapacityDto
|
||||
{
|
||||
Id = e.Id,
|
||||
Name = e.Name,
|
||||
EventDate = e.EventDate,
|
||||
RegistrationCount = e.Registrations.Count(r => r.Status != RegistrationStatus.Cancelled),
|
||||
MaxParticipants = e.MaxParticipants
|
||||
})
|
||||
.Where(e => e.CapacityPercentage >= 80)
|
||||
.OrderByDescending(e => e.CapacityPercentage)
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
var dashboard = new OrganizerDashboardDto
|
||||
{
|
||||
TotalEvents = totalEvents,
|
||||
PublishedEvents = publishedEvents,
|
||||
DraftEvents = draftEvents,
|
||||
TotalRegistrations = totalRegistrations,
|
||||
PendingRegistrations = pendingRegistrations,
|
||||
ConfirmedRegistrations = confirmedRegistrations,
|
||||
CancelledRegistrations = cancelledRegistrations,
|
||||
TotalRevenue = totalRevenue,
|
||||
UpcomingEvents = upcomingEvents,
|
||||
EventsNearCapacity = eventsNearCapacity
|
||||
};
|
||||
|
||||
return Ok(dashboard);
|
||||
}
|
||||
|
||||
[HttpGet("participant")]
|
||||
public async Task<ActionResult<ParticipantDashboardDto>> GetParticipantDashboard()
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var registrations = await _context.Registrations
|
||||
.Include(r => r.Event)
|
||||
.Where(r => r.ParticipantId == userId.Value)
|
||||
.ToListAsync();
|
||||
|
||||
var totalRegistrations = registrations.Count;
|
||||
var upcomingEvents = registrations.Count(r => r.Event.EventDate >= DateTime.UtcNow && r.Status != RegistrationStatus.Cancelled);
|
||||
var completedEvents = registrations.Count(r => r.Event.EventDate < DateTime.UtcNow && r.Status == RegistrationStatus.Completed);
|
||||
var cancelledRegistrations = registrations.Count(r => r.Status == RegistrationStatus.Cancelled);
|
||||
|
||||
var registrationSummaries = registrations
|
||||
.OrderByDescending(r => r.Event.EventDate)
|
||||
.Take(10)
|
||||
.Select(r => new RegistrationSummaryDto
|
||||
{
|
||||
Id = r.Id,
|
||||
EventId = r.EventId,
|
||||
EventName = r.Event.Name,
|
||||
EventDate = r.Event.EventDate,
|
||||
Status = r.Status.ToString(),
|
||||
RegisteredAt = r.CreatedAt
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var upcomingEventList = registrations
|
||||
.Where(r => r.Event.EventDate >= DateTime.UtcNow && r.Status != RegistrationStatus.Cancelled)
|
||||
.OrderBy(r => r.Event.EventDate)
|
||||
.Take(5)
|
||||
.Select(r => new UpcomingEventDto
|
||||
{
|
||||
Id = r.EventId,
|
||||
Name = r.Event.Name,
|
||||
EventDate = r.Event.EventDate,
|
||||
Location = r.Event.Location
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var dashboard = new ParticipantDashboardDto
|
||||
{
|
||||
TotalRegistrations = totalRegistrations,
|
||||
UpcomingEvents = upcomingEvents,
|
||||
CompletedEvents = completedEvents,
|
||||
CancelledRegistrations = cancelledRegistrations,
|
||||
MyRegistrations = registrationSummaries,
|
||||
UpcomingEventList = upcomingEventList
|
||||
};
|
||||
|
||||
return Ok(dashboard);
|
||||
}
|
||||
|
||||
private Guid? GetCurrentUserId()
|
||||
{
|
||||
var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (Guid.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RacePlannerApi.Data;
|
||||
using RacePlannerApi.DTOs;
|
||||
using RacePlannerApi.Models;
|
||||
|
||||
namespace RacePlannerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class EventsController : ControllerBase
|
||||
{
|
||||
private readonly RacePlannerDbContext _context;
|
||||
|
||||
public EventsController(RacePlannerDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Organizer")]
|
||||
public async Task<ActionResult<EventDto>> CreateEvent(CreateEventRequest request)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var eventEntity = new Event
|
||||
{
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
EventDate = request.EventDate,
|
||||
Location = request.Location,
|
||||
Category = request.Category,
|
||||
Tags = request.Tags ?? new List<string>(),
|
||||
MaxParticipants = request.MaxParticipants,
|
||||
OrganizerId = userId.Value,
|
||||
Status = EventStatus.Draft
|
||||
};
|
||||
|
||||
_context.Events.Add(eventEntity);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Load organizer for response
|
||||
await _context.Entry(eventEntity).Reference(e => e.Organizer).LoadAsync();
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetEvent),
|
||||
new { id = eventEntity.Id },
|
||||
MapToEventDto(eventEntity));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<IEnumerable<EventDto>>> GetEvents(
|
||||
[FromQuery] EventFilterRequest? filter = null)
|
||||
{
|
||||
var query = _context.Events
|
||||
.Include(e => e.Organizer)
|
||||
.Include(e => e.Registrations)
|
||||
.AsQueryable();
|
||||
|
||||
// Apply filters
|
||||
if (filter?.Category != null)
|
||||
{
|
||||
query = query.Where(e => e.Category == filter.Category);
|
||||
}
|
||||
|
||||
if (filter?.Tags != null && filter.Tags.Any())
|
||||
{
|
||||
query = query.Where(e => e.Tags.Any(t => filter.Tags.Contains(t)));
|
||||
}
|
||||
|
||||
if (filter?.FromDate != null)
|
||||
{
|
||||
query = query.Where(e => e.EventDate >= filter.FromDate);
|
||||
}
|
||||
|
||||
if (filter?.ToDate != null)
|
||||
{
|
||||
query = query.Where(e => e.EventDate <= filter.ToDate);
|
||||
}
|
||||
|
||||
if (filter?.Status != null && Enum.TryParse<EventStatus>(filter.Status, out var status))
|
||||
{
|
||||
query = query.Where(e => e.Status == status);
|
||||
}
|
||||
|
||||
if (filter?.OrganizerId != null)
|
||||
{
|
||||
query = query.Where(e => e.OrganizerId == filter.OrganizerId);
|
||||
}
|
||||
|
||||
// Default to showing published events only for anonymous users
|
||||
if (!User.Identity?.IsAuthenticated ?? true)
|
||||
{
|
||||
query = query.Where(e => e.Status == EventStatus.Published);
|
||||
}
|
||||
|
||||
var events = await query
|
||||
.OrderBy(e => e.EventDate)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(events.Select(MapToEventDto));
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<EventDto>> GetEvent(Guid id)
|
||||
{
|
||||
var eventEntity = await _context.Events
|
||||
.Include(e => e.Organizer)
|
||||
.Include(e => e.Registrations)
|
||||
.FirstOrDefaultAsync(e => e.Id == id);
|
||||
|
||||
if (eventEntity == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Draft events only visible to organizers
|
||||
if (eventEntity.Status == EventStatus.Draft)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null || eventEntity.OrganizerId != userId)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(MapToEventDto(eventEntity));
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
[Authorize(Roles = "Organizer")]
|
||||
public async Task<ActionResult<EventDto>> UpdateEvent(Guid id, UpdateEventRequest request)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var eventEntity = await _context.Events
|
||||
.Include(e => e.Organizer)
|
||||
.FirstOrDefaultAsync(e => e.Id == id);
|
||||
|
||||
if (eventEntity == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Only organizer can update their event
|
||||
if (eventEntity.OrganizerId != userId)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if (request.Name != null) eventEntity.Name = request.Name;
|
||||
if (request.Description != null) eventEntity.Description = request.Description;
|
||||
if (request.EventDate != null) eventEntity.EventDate = request.EventDate.Value;
|
||||
if (request.Location != null) eventEntity.Location = request.Location;
|
||||
if (request.Status != null) eventEntity.Status = request.Status.Value;
|
||||
if (request.Category != null) eventEntity.Category = request.Category;
|
||||
if (request.Tags != null) eventEntity.Tags = request.Tags;
|
||||
if (request.MaxParticipants != null) eventEntity.MaxParticipants = request.MaxParticipants;
|
||||
|
||||
eventEntity.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(MapToEventDto(eventEntity));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Organizer")]
|
||||
public async Task<IActionResult> DeleteEvent(Guid id)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var eventEntity = await _context.Events.FindAsync(id);
|
||||
|
||||
if (eventEntity == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (eventEntity.OrganizerId != userId)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
_context.Events.Remove(eventEntity);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private Guid? GetCurrentUserId()
|
||||
{
|
||||
var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (Guid.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static EventDto MapToEventDto(Event eventEntity)
|
||||
{
|
||||
return new EventDto
|
||||
{
|
||||
Id = eventEntity.Id,
|
||||
Name = eventEntity.Name,
|
||||
Description = eventEntity.Description,
|
||||
EventDate = eventEntity.EventDate,
|
||||
Location = eventEntity.Location,
|
||||
Status = eventEntity.Status.ToString(),
|
||||
Category = eventEntity.Category,
|
||||
Tags = eventEntity.Tags,
|
||||
MaxParticipants = eventEntity.MaxParticipants,
|
||||
CurrentRegistrations = eventEntity.Registrations?.Count ?? 0,
|
||||
CreatedAt = eventEntity.CreatedAt,
|
||||
UpdatedAt = eventEntity.UpdatedAt,
|
||||
Organizer = new UserSummaryDto
|
||||
{
|
||||
Id = eventEntity.Organizer.Id,
|
||||
Name = eventEntity.Organizer.Name,
|
||||
Email = eventEntity.Organizer.Email
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RacePlannerApi.Data;
|
||||
using RacePlannerApi.DTOs;
|
||||
using RacePlannerApi.Models;
|
||||
|
||||
namespace RacePlannerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class PaymentsController : ControllerBase
|
||||
{
|
||||
private readonly RacePlannerDbContext _context;
|
||||
|
||||
public PaymentsController(RacePlannerDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Organizer")]
|
||||
public async Task<ActionResult<PaymentDto>> RecordPayment(CreatePaymentRequest request)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
// Load registration with event
|
||||
var registration = await _context.Registrations
|
||||
.Include(r => r.Event)
|
||||
.FirstOrDefaultAsync(r => r.Id == request.RegistrationId);
|
||||
|
||||
if (registration == null)
|
||||
{
|
||||
return NotFound(new { error = "Registration not found" });
|
||||
}
|
||||
|
||||
// Verify user is organizer of the event
|
||||
if (registration.Event.OrganizerId != userId.Value)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
// Check registration is not cancelled
|
||||
if (registration.Status == RegistrationStatus.Cancelled)
|
||||
{
|
||||
return BadRequest(new { error = "Cannot record payment for cancelled registration" });
|
||||
}
|
||||
|
||||
var payment = new Payment
|
||||
{
|
||||
RegistrationId = request.RegistrationId,
|
||||
Amount = request.Amount,
|
||||
Method = request.Method,
|
||||
TransactionId = request.TransactionId,
|
||||
Notes = request.Notes,
|
||||
PaymentDate = request.PaymentDate ?? DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.Payments.Add(payment);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Update registration status if fully paid (assuming a fixed fee amount would be known)
|
||||
// For now, we'll just record the payment
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetPayment),
|
||||
new { id = payment.Id },
|
||||
MapToPaymentDto(payment));
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
[Authorize(Roles = "Organizer")]
|
||||
public async Task<ActionResult<PaymentDto>> GetPayment(Guid id)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var payment = await _context.Payments
|
||||
.Include(p => p.Registration)
|
||||
.ThenInclude(r => r.Event)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
if (payment == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Verify user is organizer
|
||||
if (payment.Registration.Event.OrganizerId != userId.Value)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
return Ok(MapToPaymentDto(payment));
|
||||
}
|
||||
|
||||
[HttpGet("registration/{registrationId}")]
|
||||
[Authorize(Roles = "Organizer")]
|
||||
public async Task<ActionResult<IEnumerable<PaymentDto>>> GetRegistrationPayments(Guid registrationId)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
// Verify organizer has access to this registration
|
||||
var registration = await _context.Registrations
|
||||
.Include(r => r.Event)
|
||||
.FirstOrDefaultAsync(r => r.Id == registrationId);
|
||||
|
||||
if (registration == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (registration.Event.OrganizerId != userId.Value)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var payments = await _context.Payments
|
||||
.Where(p => p.RegistrationId == registrationId)
|
||||
.OrderByDescending(p => p.PaymentDate)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(payments.Select(MapToPaymentDto));
|
||||
}
|
||||
|
||||
[HttpGet("registration/{registrationId}/status")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<object>> GetPaymentStatus(Guid registrationId)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var registration = await _context.Registrations
|
||||
.Include(r => r.Event)
|
||||
.Include(r => r.Payments)
|
||||
.FirstOrDefaultAsync(r => r.Id == registrationId);
|
||||
|
||||
if (registration == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Participant can view their own, organizer can view any
|
||||
var isOrganizer = registration.Event.OrganizerId == userId.Value;
|
||||
|
||||
if (registration.ParticipantId != userId.Value && !isOrganizer)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var totalPaid = registration.Payments.Sum(p => p.Amount);
|
||||
var status = totalPaid > 0 ? "paid" : "unpaid";
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
RegistrationId = registrationId,
|
||||
TotalPaid = totalPaid,
|
||||
PaymentCount = registration.Payments.Count,
|
||||
Status = status,
|
||||
Payments = registration.Payments.Select(MapToPaymentDto)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("event/{eventId}/report")]
|
||||
[Authorize(Roles = "Organizer")]
|
||||
public async Task<ActionResult<PaymentReportDto>> GetPaymentReport(Guid eventId)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var eventEntity = await _context.Events
|
||||
.FirstOrDefaultAsync(e => e.Id == eventId && e.OrganizerId == userId.Value);
|
||||
|
||||
if (eventEntity == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var registrations = await _context.Registrations
|
||||
.Include(r => r.Participant)
|
||||
.Include(r => r.Payments)
|
||||
.Where(r => r.EventId == eventId && r.Status != RegistrationStatus.Cancelled)
|
||||
.ToListAsync();
|
||||
|
||||
var totalCollected = registrations.Sum(r => r.Payments.Sum(p => p.Amount));
|
||||
var totalRegistrations = registrations.Count;
|
||||
var paidRegistrations = registrations.Count(r => r.Payments.Any());
|
||||
var unpaidRegistrations = registrations.Count(r => !r.Payments.Any());
|
||||
|
||||
var report = new PaymentReportDto
|
||||
{
|
||||
EventId = eventId,
|
||||
EventName = eventEntity.Name,
|
||||
TotalCollected = totalCollected,
|
||||
TotalPending = 0, // Would require event fee structure
|
||||
TotalOutstanding = 0, // Would require event fee structure
|
||||
TotalRegistrations = totalRegistrations,
|
||||
PaidRegistrations = paidRegistrations,
|
||||
PartialRegistrations = 0, // Would require partial payment logic
|
||||
UnpaidRegistrations = unpaidRegistrations,
|
||||
Registrations = registrations.Select(r => new RegistrationPaymentDto
|
||||
{
|
||||
RegistrationId = r.Id,
|
||||
ParticipantName = r.Participant.Name,
|
||||
Status = r.Status.ToString(),
|
||||
TotalPaid = r.Payments.Sum(p => p.Amount),
|
||||
AmountDue = 0, // Would require event fee structure
|
||||
PaymentStatus = r.Payments.Any() ? "paid" : "unpaid",
|
||||
Payments = r.Payments.Select(MapToPaymentDto).ToList()
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
private Guid? GetCurrentUserId()
|
||||
{
|
||||
var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (Guid.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PaymentDto MapToPaymentDto(Payment payment)
|
||||
{
|
||||
return new PaymentDto
|
||||
{
|
||||
Id = payment.Id,
|
||||
RegistrationId = payment.RegistrationId,
|
||||
Amount = payment.Amount,
|
||||
Method = payment.Method.ToString(),
|
||||
TransactionId = payment.TransactionId,
|
||||
Notes = payment.Notes,
|
||||
PaymentDate = payment.PaymentDate,
|
||||
CreatedAt = payment.CreatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RacePlannerApi.Data;
|
||||
using RacePlannerApi.DTOs;
|
||||
using RacePlannerApi.Models;
|
||||
|
||||
namespace RacePlannerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class RegistrationsController : ControllerBase
|
||||
{
|
||||
private readonly RacePlannerDbContext _context;
|
||||
|
||||
public RegistrationsController(RacePlannerDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<RegistrationDto>> CreateRegistration(CreateRegistrationRequest request)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
// Check if event exists and is published
|
||||
var eventEntity = await _context.Events
|
||||
.FirstOrDefaultAsync(e => e.Id == request.EventId);
|
||||
|
||||
if (eventEntity == null)
|
||||
{
|
||||
return NotFound(new { error = "Event not found" });
|
||||
}
|
||||
|
||||
if (eventEntity.Status != EventStatus.Published)
|
||||
{
|
||||
return BadRequest(new { error = "Event is not open for registration" });
|
||||
}
|
||||
|
||||
// Check if already registered
|
||||
var existingRegistration = await _context.Registrations
|
||||
.FirstOrDefaultAsync(r => r.EventId == request.EventId && r.ParticipantId == userId.Value);
|
||||
|
||||
if (existingRegistration != null)
|
||||
{
|
||||
return Conflict(new { error = "Already registered for this event" });
|
||||
}
|
||||
|
||||
// Check if event is full
|
||||
if (eventEntity.MaxParticipants.HasValue)
|
||||
{
|
||||
var registrationCount = await _context.Registrations
|
||||
.CountAsync(r => r.EventId == request.EventId && r.Status != RegistrationStatus.Cancelled);
|
||||
|
||||
if (registrationCount >= eventEntity.MaxParticipants.Value)
|
||||
{
|
||||
return BadRequest(new { error = "Event is full" });
|
||||
}
|
||||
}
|
||||
|
||||
var registration = new Registration
|
||||
{
|
||||
EventId = request.EventId,
|
||||
ParticipantId = userId.Value,
|
||||
Status = RegistrationStatus.Pending,
|
||||
Category = request.Category,
|
||||
EmergencyContact = request.EmergencyContact
|
||||
};
|
||||
|
||||
_context.Registrations.Add(registration);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Load related data
|
||||
await _context.Entry(registration)
|
||||
.Reference(r => r.Event)
|
||||
.LoadAsync();
|
||||
await _context.Entry(registration)
|
||||
.Reference(r => r.Participant)
|
||||
.LoadAsync();
|
||||
await _context.Entry(registration)
|
||||
.Collection(r => r.Payments)
|
||||
.LoadAsync();
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetRegistration),
|
||||
new { id = registration.Id },
|
||||
MapToRegistrationDto(registration));
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<RegistrationDto>> GetRegistration(Guid id)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var registration = await _context.Registrations
|
||||
.Include(r => r.Event)
|
||||
.Include(r => r.Participant)
|
||||
.Include(r => r.Payments)
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
if (registration == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Only participant or event organizer can view
|
||||
var isOrganizer = await _context.Events
|
||||
.AnyAsync(e => e.Id == registration.EventId && e.OrganizerId == userId.Value);
|
||||
|
||||
if (registration.ParticipantId != userId.Value && !isOrganizer)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
return Ok(MapToRegistrationDto(registration));
|
||||
}
|
||||
|
||||
[HttpGet("my-registrations")]
|
||||
public async Task<ActionResult<IEnumerable<RegistrationDto>>> GetMyRegistrations()
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var registrations = await _context.Registrations
|
||||
.Include(r => r.Event)
|
||||
.Include(r => r.Participant)
|
||||
.Include(r => r.Payments)
|
||||
.Where(r => r.ParticipantId == userId.Value)
|
||||
.OrderBy(r => r.Event.EventDate)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(registrations.Select(MapToRegistrationDto));
|
||||
}
|
||||
|
||||
[HttpGet("event/{eventId}")]
|
||||
[Authorize(Roles = "Organizer")]
|
||||
public async Task<ActionResult<IEnumerable<RegistrationDto>>> GetEventRegistrations(Guid eventId)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
// Verify user is organizer of this event
|
||||
var eventEntity = await _context.Events
|
||||
.FirstOrDefaultAsync(e => e.Id == eventId && e.OrganizerId == userId.Value);
|
||||
|
||||
if (eventEntity == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var registrations = await _context.Registrations
|
||||
.Include(r => r.Event)
|
||||
.Include(r => r.Participant)
|
||||
.Include(r => r.Payments)
|
||||
.Where(r => r.EventId == eventId)
|
||||
.OrderBy(r => r.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(registrations.Select(MapToRegistrationDto));
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<RegistrationDto>> UpdateRegistration(Guid id, UpdateRegistrationRequest request)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var registration = await _context.Registrations
|
||||
.Include(r => r.Event)
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
if (registration == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Participants can only update their own registrations
|
||||
// Organizers can update any registration for their events
|
||||
var isOrganizer = registration.Event.OrganizerId == userId.Value;
|
||||
|
||||
if (registration.ParticipantId != userId.Value && !isOrganizer)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if (request.Status.HasValue && isOrganizer)
|
||||
{
|
||||
registration.Status = request.Status.Value;
|
||||
}
|
||||
|
||||
if (request.Category != null)
|
||||
{
|
||||
registration.Category = request.Category;
|
||||
}
|
||||
|
||||
if (request.EmergencyContact != null)
|
||||
{
|
||||
registration.EmergencyContact = request.EmergencyContact;
|
||||
}
|
||||
|
||||
registration.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Reload related data
|
||||
await _context.Entry(registration)
|
||||
.Reference(r => r.Participant)
|
||||
.LoadAsync();
|
||||
await _context.Entry(registration)
|
||||
.Collection(r => r.Payments)
|
||||
.LoadAsync();
|
||||
|
||||
return Ok(MapToRegistrationDto(registration));
|
||||
}
|
||||
|
||||
[HttpPost("{id}/cancel")]
|
||||
public async Task<ActionResult<RegistrationDto>> CancelRegistration(Guid id)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var registration = await _context.Registrations
|
||||
.Include(r => r.Event)
|
||||
.Include(r => r.Participant)
|
||||
.Include(r => r.Payments)
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
if (registration == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Only participant or event organizer can cancel
|
||||
var isOrganizer = registration.Event.OrganizerId == userId.Value;
|
||||
|
||||
if (registration.ParticipantId != userId.Value && !isOrganizer)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
registration.Status = RegistrationStatus.Cancelled;
|
||||
registration.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(MapToRegistrationDto(registration));
|
||||
}
|
||||
|
||||
private Guid? GetCurrentUserId()
|
||||
{
|
||||
var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (Guid.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static RegistrationDto MapToRegistrationDto(Registration registration)
|
||||
{
|
||||
var totalPaid = registration.Payments?.Sum(p => p.Amount) ?? 0;
|
||||
|
||||
return new RegistrationDto
|
||||
{
|
||||
Id = registration.Id,
|
||||
EventId = registration.EventId,
|
||||
EventName = registration.Event?.Name ?? string.Empty,
|
||||
EventDate = registration.Event?.EventDate ?? DateTime.MinValue,
|
||||
ParticipantId = registration.ParticipantId,
|
||||
ParticipantName = registration.Participant?.Name ?? string.Empty,
|
||||
ParticipantEmail = registration.Participant?.Email ?? string.Empty,
|
||||
Status = registration.Status.ToString(),
|
||||
Category = registration.Category,
|
||||
EmergencyContact = registration.EmergencyContact,
|
||||
CreatedAt = registration.CreatedAt,
|
||||
UpdatedAt = registration.UpdatedAt,
|
||||
TotalPaid = totalPaid,
|
||||
AmountDue = 0 // Will be calculated based on event fee if needed
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace RacePlannerApi.DTOs;
|
||||
|
||||
public class CreateAnnouncementRequest
|
||||
{
|
||||
[Required]
|
||||
public Guid EventId { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(200)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(5000)]
|
||||
public string Content { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class UpdateAnnouncementRequest
|
||||
{
|
||||
[MaxLength(200)]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[MaxLength(5000)]
|
||||
public string? Content { get; set; }
|
||||
|
||||
public bool? IsPublished { get; set; }
|
||||
}
|
||||
|
||||
public class AnnouncementDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid EventId { get; set; }
|
||||
public string EventName { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public Guid AuthorId { get; set; }
|
||||
public string AuthorName { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public bool IsPublished { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace RacePlannerApi.DTOs;
|
||||
|
||||
public class OrganizerDashboardDto
|
||||
{
|
||||
public int TotalEvents { get; set; }
|
||||
public int PublishedEvents { get; set; }
|
||||
public int DraftEvents { get; set; }
|
||||
public int TotalRegistrations { get; set; }
|
||||
public int PendingRegistrations { get; set; }
|
||||
public int ConfirmedRegistrations { get; set; }
|
||||
public int CancelledRegistrations { get; set; }
|
||||
public decimal TotalRevenue { get; set; }
|
||||
public List<EventSummaryDto> UpcomingEvents { get; set; } = new();
|
||||
public List<EventCapacityDto> EventsNearCapacity { get; set; } = new();
|
||||
}
|
||||
|
||||
public class EventSummaryDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public DateTime EventDate { get; set; }
|
||||
public string Location { get; set; } = string.Empty;
|
||||
public int RegistrationCount { get; set; }
|
||||
public int? MaxParticipants { get; set; }
|
||||
}
|
||||
|
||||
public class EventCapacityDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public DateTime EventDate { get; set; }
|
||||
public int RegistrationCount { get; set; }
|
||||
public int? MaxParticipants { get; set; }
|
||||
public double CapacityPercentage => MaxParticipants.HasValue && MaxParticipants.Value > 0
|
||||
? (double)RegistrationCount / MaxParticipants.Value * 100
|
||||
: 0;
|
||||
}
|
||||
|
||||
public class ParticipantDashboardDto
|
||||
{
|
||||
public int TotalRegistrations { get; set; }
|
||||
public int UpcomingEvents { get; set; }
|
||||
public int CompletedEvents { get; set; }
|
||||
public int CancelledRegistrations { get; set; }
|
||||
public List<RegistrationSummaryDto> MyRegistrations { get; set; } = new();
|
||||
public List<UpcomingEventDto> UpcomingEventList { get; set; } = new();
|
||||
}
|
||||
|
||||
public class RegistrationSummaryDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid EventId { get; set; }
|
||||
public string EventName { get; set; } = string.Empty;
|
||||
public DateTime EventDate { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public DateTime RegisteredAt { get; set; }
|
||||
}
|
||||
|
||||
public class UpcomingEventDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public DateTime EventDate { get; set; }
|
||||
public string Location { get; set; } = string.Empty;
|
||||
public int DaysUntil => (EventDate - DateTime.UtcNow).Days;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using RacePlannerApi.Models;
|
||||
|
||||
namespace RacePlannerApi.DTOs;
|
||||
|
||||
public class CreateEventRequest
|
||||
{
|
||||
[Required]
|
||||
[MaxLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public DateTime EventDate { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(500)]
|
||||
public string Location { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
public List<string> Tags { get; set; } = new();
|
||||
|
||||
public int? MaxParticipants { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateEventRequest
|
||||
{
|
||||
[MaxLength(200)]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
public DateTime? EventDate { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? Location { get; set; }
|
||||
|
||||
public EventStatus? Status { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
public List<string>? Tags { get; set; }
|
||||
|
||||
public int? MaxParticipants { get; set; }
|
||||
}
|
||||
|
||||
public class EventDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public DateTime EventDate { get; set; }
|
||||
public string Location { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public string? Category { get; set; }
|
||||
public List<string> Tags { get; set; } = new();
|
||||
public int? MaxParticipants { get; set; }
|
||||
public int CurrentRegistrations { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public UserSummaryDto Organizer { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class EventFilterRequest
|
||||
{
|
||||
public string? Category { get; set; }
|
||||
public List<string>? Tags { get; set; }
|
||||
public DateTime? FromDate { get; set; }
|
||||
public DateTime? ToDate { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public Guid? OrganizerId { get; set; }
|
||||
}
|
||||
|
||||
public class UserSummaryDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using RacePlannerApi.Models;
|
||||
|
||||
namespace RacePlannerApi.DTOs;
|
||||
|
||||
public class CreatePaymentRequest
|
||||
{
|
||||
[Required]
|
||||
public Guid RegistrationId { get; set; }
|
||||
|
||||
[Required]
|
||||
[Range(0.01, double.MaxValue)]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
[Required]
|
||||
public PaymentMethod Method { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? TransactionId { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public DateTime? PaymentDate { get; set; }
|
||||
}
|
||||
|
||||
public class PaymentDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid RegistrationId { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string Method { get; set; } = string.Empty;
|
||||
public string? TransactionId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime PaymentDate { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class PaymentReportDto
|
||||
{
|
||||
public Guid EventId { get; set; }
|
||||
public string EventName { get; set; } = string.Empty;
|
||||
public decimal TotalCollected { get; set; }
|
||||
public decimal TotalPending { get; set; }
|
||||
public decimal TotalOutstanding { get; set; }
|
||||
public int TotalRegistrations { get; set; }
|
||||
public int PaidRegistrations { get; set; }
|
||||
public int PartialRegistrations { get; set; }
|
||||
public int UnpaidRegistrations { get; set; }
|
||||
public List<RegistrationPaymentDto> Registrations { get; set; } = new();
|
||||
}
|
||||
|
||||
public class RegistrationPaymentDto
|
||||
{
|
||||
public Guid RegistrationId { get; set; }
|
||||
public string ParticipantName { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public decimal TotalPaid { get; set; }
|
||||
public decimal AmountDue { get; set; }
|
||||
public string PaymentStatus { get; set; } = string.Empty;
|
||||
public List<PaymentDto> Payments { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using RacePlannerApi.Models;
|
||||
|
||||
namespace RacePlannerApi.DTOs;
|
||||
|
||||
public class CreateRegistrationRequest
|
||||
{
|
||||
[Required]
|
||||
public Guid EventId { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? EmergencyContact { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateRegistrationRequest
|
||||
{
|
||||
public RegistrationStatus? Status { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? EmergencyContact { get; set; }
|
||||
}
|
||||
|
||||
public class RegistrationDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid EventId { get; set; }
|
||||
public string EventName { get; set; } = string.Empty;
|
||||
public DateTime EventDate { get; set; }
|
||||
public Guid ParticipantId { get; set; }
|
||||
public string ParticipantName { get; set; } = string.Empty;
|
||||
public string ParticipantEmail { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public string? Category { get; set; }
|
||||
public string? EmergencyContact { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public decimal TotalPaid { get; set; }
|
||||
public decimal AmountDue { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Jwt": {
|
||||
"Key": "${JWT_SECRET_KEY}",
|
||||
"Issuer": "RacePlannerApi",
|
||||
"Audience": "RacePlannerClient"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "${DATABASE_URL}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: RacePlanner
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
ConnectionStrings__DefaultConnection: Host=db;Database=RacePlanner;Username=postgres;Password=postgres
|
||||
Jwt__Key: your-super-secret-key-minimum-32-characters-long-here
|
||||
ports:
|
||||
- "5000:80"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:5000/api
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -0,0 +1,20 @@
|
||||
import { AnnouncementList } from '@/components/announcement-list';
|
||||
|
||||
interface AnnouncementsPageProps {
|
||||
params: Promise<{
|
||||
eventId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function AnnouncementsPage({ params }: AnnouncementsPageProps) {
|
||||
const { eventId } = await params;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<h1 className="text-3xl font-bold mb-8">Announcements</h1>
|
||||
<AnnouncementList eventId={eventId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Dashboard } from '@/components/dashboard';
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<h1 className="text-3xl font-bold mb-8">Dashboard</h1>
|
||||
<Dashboard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { EventDetail } from '@/components/event-detail';
|
||||
|
||||
interface EventPageProps {
|
||||
params: Promise<{
|
||||
eventId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function EventPage({ params }: EventPageProps) {
|
||||
const { eventId } = await params;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<EventDetail eventId={eventId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { RegistrationForm } from '@/components/registration-form';
|
||||
|
||||
interface RegisterPageProps {
|
||||
params: Promise<{
|
||||
eventId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function RegisterPage({ params }: RegisterPageProps) {
|
||||
const { eventId } = await params;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<RegistrationForm eventId={eventId} eventName="Event" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { EventForm } from '@/components/event-form';
|
||||
|
||||
export default function CreateEventPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<h1 className="text-3xl font-bold mb-8">Create Event</h1>
|
||||
<EventForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EventList } from '@/components/event-list';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export default function EventsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold">Events</h1>
|
||||
<a
|
||||
href="/events/create"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Create Event
|
||||
</a>
|
||||
</div>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<EventList />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
import { Navigation } from "@/components/navigation";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -13,8 +15,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "RacePlanner - Event Management",
|
||||
description: "Plan and manage race events",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -27,7 +29,14 @@ export default function RootLayout({
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full flex flex-col bg-gray-50">
|
||||
<AuthProvider>
|
||||
<Navigation />
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { LoginForm } from '@/components/login-form';
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<LoginForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PaymentForm } from '@/components/payment-form';
|
||||
|
||||
interface PaymentPageProps {
|
||||
params: Promise<{
|
||||
registrationId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function PaymentPage({ params }: PaymentPageProps) {
|
||||
const { registrationId } = await params;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<PaymentForm registrationId={registrationId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { RegisterForm } from '@/components/register-form';
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<RegisterForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { RegistrationList } from '@/components/registration-list';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export default function RegistrationsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<h1 className="text-3xl font-bold mb-8">My Registrations</h1>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<RegistrationList />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface AnnouncementFormProps {
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
export function AnnouncementForm({ eventId }: AnnouncementFormProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await api.createAnnouncement(eventId, title, content);
|
||||
router.push(`/events/${eventId}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create announcement');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow-md p-6 space-y-4">
|
||||
<h2 className="text-xl font-bold mb-4">Create Announcement</h2>
|
||||
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
maxLength={200}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Content
|
||||
</label>
|
||||
<textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
required
|
||||
rows={6}
|
||||
maxLength={5000}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
{isSubmitting ? 'Posting...' : 'Post Announcement'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, Announcement } from '@/lib/api';
|
||||
|
||||
interface AnnouncementListProps {
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
export function AnnouncementList({ eventId }: AnnouncementListProps) {
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadAnnouncements();
|
||||
}, [eventId]);
|
||||
|
||||
const loadAnnouncements = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await api.getEventAnnouncements(eventId);
|
||||
setAnnouncements(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load announcements');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-4">Loading announcements...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded text-sm">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (announcements.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-4 text-gray-500 text-sm">
|
||||
No announcements yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{announcements.map((announcement) => (
|
||||
<div key={announcement.id} className="bg-white rounded-lg shadow-sm border p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className="font-semibold text-lg">{announcement.title}</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(announcement.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{announcement.content}</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Posted by {announcement.authorName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
const [dashboard, setDashboard] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard();
|
||||
}, []);
|
||||
|
||||
const loadDashboard = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
if (user?.role === 'Organizer') {
|
||||
const data = await api.getOrganizerDashboard();
|
||||
setDashboard(data);
|
||||
} else {
|
||||
const data = await api.getParticipantDashboard();
|
||||
setDashboard(data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load dashboard');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8">Loading dashboard...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dashboard) {
|
||||
return <div className="text-center py-8">No data available</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Quick Actions */}
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{user?.role === 'Organizer' ? (
|
||||
<>
|
||||
<a href="/events/create" className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
Create Event
|
||||
</a>
|
||||
<a href="/events" className="px-6 py-3 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300">
|
||||
Manage Events
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<a href="/events" className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
Browse Events
|
||||
</a>
|
||||
<a href="/registrations" className="px-6 py-3 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300">
|
||||
My Registrations
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
{user?.role === 'Organizer' ? (
|
||||
<OrganizerDashboard data={dashboard} />
|
||||
) : (
|
||||
<ParticipantDashboard data={dashboard} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrganizerDashboard({ data }: { data: any }) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard title="Total Events" value={data.totalEvents} />
|
||||
<StatCard title="Published" value={data.publishedEvents} color="green" />
|
||||
<StatCard title="Draft" value={data.draftEvents} color="yellow" />
|
||||
<StatCard title="Total Registrations" value={data.totalRegistrations} color="blue" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Upcoming Events</h3>
|
||||
{data.upcomingEvents?.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{data.upcomingEvents.map((event: any) => (
|
||||
<div key={event.id} className="border-b pb-3 last:border-0">
|
||||
<a href={`/events/${event.id}`} className="font-medium hover:text-blue-600">
|
||||
{event.name}
|
||||
</a>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(event.eventDate).toLocaleDateString()} - {event.registrationCount} registered
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500">No upcoming events</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Revenue</h3>
|
||||
<div className="text-3xl font-bold text-green-600 mb-2">
|
||||
${data.totalRevenue?.toFixed(2) || '0.00'}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 mt-4 text-sm">
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-green-600">{data.paidRegistrations}</div>
|
||||
<div className="text-gray-500">Paid</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-yellow-600">{data.pendingRegistrations}</div>
|
||||
<div className="text-gray-500">Pending</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-red-600">{data.cancelledRegistrations}</div>
|
||||
<div className="text-gray-500">Cancelled</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantDashboard({ data }: { data: any }) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard title="Total Registrations" value={data.totalRegistrations} />
|
||||
<StatCard title="Upcoming Events" value={data.upcomingEvents} color="green" />
|
||||
<StatCard title="Completed" value={data.completedEvents} color="blue" />
|
||||
<StatCard title="Cancelled" value={data.cancelledRegistrations} color="red" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">My Recent Registrations</h3>
|
||||
{data.myRegistrations?.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{data.myRegistrations.slice(0, 5).map((reg: any) => (
|
||||
<div key={reg.id} className="flex justify-between items-center border-b pb-3 last:border-0">
|
||||
<div>
|
||||
<a href={`/events/${reg.eventId}`} className="font-medium hover:text-blue-600">
|
||||
{reg.eventName}
|
||||
</a>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(reg.eventDate).toLocaleDateString()} -
|
||||
<span className={`ml-2 px-2 py-1 text-xs rounded ${
|
||||
reg.status === 'Confirmed' ? 'bg-green-100 text-green-800' :
|
||||
reg.status === 'Pending' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{reg.status}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500">
|
||||
No registrations yet. <a href="/events" className="text-blue-600 hover:underline">Browse events</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ title, value, color = 'gray' }: { title: string; value: number; color?: string }) {
|
||||
const colorClasses: Record<string, string> = {
|
||||
gray: 'bg-white',
|
||||
green: 'bg-green-50',
|
||||
yellow: 'bg-yellow-50',
|
||||
blue: 'bg-blue-50',
|
||||
red: 'bg-red-50',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${colorClasses[color]} rounded-lg shadow-md p-6`}>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">{title}</h3>
|
||||
<div className="text-3xl font-bold">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, Event } from '@/lib/api';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
interface EventDetailProps {
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
export function EventDetail({ eventId }: EventDetailProps) {
|
||||
const [event, setEvent] = useState<Event | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
loadEvent();
|
||||
}, [eventId]);
|
||||
|
||||
const loadEvent = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await api.getEvent(eventId);
|
||||
setEvent(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load event');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8">Loading...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return <div className="text-center py-8">Event not found</div>;
|
||||
}
|
||||
|
||||
const isOrganizer = user?.id === event.organizer.id;
|
||||
const canEdit = isOrganizer || user?.role === 'Organizer';
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<span className={`inline-block px-2 py-1 text-xs rounded mb-2 ${
|
||||
event.status === 'Published' ? 'bg-green-100 text-green-800' :
|
||||
event.status === 'Draft' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{event.status}
|
||||
</span>
|
||||
<h1 className="text-3xl font-bold">{event.name}</h1>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<a
|
||||
href={`/events/${event.id}/edit`}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Edit Event
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">📅</span>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Date</p>
|
||||
<p className="font-medium">{new Date(event.eventDate).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">📍</span>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Location</p>
|
||||
<p className="font-medium">{event.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">👥</span>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Registrations</p>
|
||||
<p className="font-medium">
|
||||
{event.currentRegistrations} registered
|
||||
{event.maxParticipants && ` / ${event.maxParticipants} max`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{event.category && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🏷️</span>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Category</p>
|
||||
<p className="font-medium">{event.category}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">👤</span>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Organizer</p>
|
||||
<p className="font-medium">{event.organizer.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
{event.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{event.tags.map((tag) => (
|
||||
<span key={tag} className="px-2 py-1 bg-gray-100 text-gray-700 text-sm rounded">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6">
|
||||
<h2 className="text-xl font-semibold mb-3">Description</h2>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{event.description}</p>
|
||||
</div>
|
||||
|
||||
{user?.role === 'Participant' && event.status === 'Published' && (
|
||||
<div className="border-t pt-6 mt-6">
|
||||
<a
|
||||
href={`/events/${event.id}/register`}
|
||||
className="block text-center py-3 px-6 bg-green-600 text-white rounded-md hover:bg-green-700 font-semibold"
|
||||
>
|
||||
Register for Event
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { api, Event } from '@/lib/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface EventFormProps {
|
||||
event?: Event;
|
||||
}
|
||||
|
||||
export function EventForm({ event }: EventFormProps) {
|
||||
const [name, setName] = useState(event?.name || '');
|
||||
const [description, setDescription] = useState(event?.description || '');
|
||||
const [eventDate, setEventDate] = useState(event ? new Date(event.eventDate).toISOString().slice(0, 16) : '');
|
||||
const [location, setLocation] = useState(event?.location || '');
|
||||
const [category, setCategory] = useState(event?.category || '');
|
||||
const [maxParticipants, setMaxParticipants] = useState(event?.maxParticipants?.toString() || '');
|
||||
const [tags, setTags] = useState(event?.tags.join(', ') || '');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const categories = ['Running', 'Cycling', 'Triathlon', 'Trail', 'Road'];
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const eventData = {
|
||||
name,
|
||||
description,
|
||||
eventDate: new Date(eventDate).toISOString(),
|
||||
location,
|
||||
category: category || undefined,
|
||||
maxParticipants: maxParticipants ? parseInt(maxParticipants) : undefined,
|
||||
tags: tags.split(',').map(t => t.trim()).filter(t => t),
|
||||
};
|
||||
|
||||
if (event) {
|
||||
await api.updateEvent(event.id, eventData);
|
||||
} else {
|
||||
await api.createEvent(eventData);
|
||||
}
|
||||
|
||||
router.push('/events');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save event');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 bg-white rounded-lg shadow-md p-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Event Name *
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="eventDate" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Event Date & Time *
|
||||
</label>
|
||||
<input
|
||||
id="eventDate"
|
||||
type="datetime-local"
|
||||
value={eventDate}
|
||||
onChange={(e) => setEventDate(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="location" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Location *
|
||||
</label>
|
||||
<input
|
||||
id="location"
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select Category</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="maxParticipants" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Max Participants
|
||||
</label>
|
||||
<input
|
||||
id="maxParticipants"
|
||||
type="number"
|
||||
min="1"
|
||||
value={maxParticipants}
|
||||
onChange={(e) => setMaxParticipants(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="tags" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tags (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
id="tags"
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="e.g., beginner, competitive, charity"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : (event ? 'Update Event' : 'Create Event')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/events')}
|
||||
className="py-2 px-4 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, Event } from '@/lib/api';
|
||||
|
||||
export function EventList() {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [category, setCategory] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, [category, status]);
|
||||
|
||||
const loadEvents = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await api.getEvents({
|
||||
category: category || undefined,
|
||||
status: status || undefined,
|
||||
});
|
||||
setEvents(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load events');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const categories = ['Running', 'Cycling', 'Triathlon', 'Trail', 'Road'];
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8">Loading events...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="Published">Published</option>
|
||||
<option value="Draft">Draft</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Events Grid */}
|
||||
{events.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">No events found</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{events.map((event) => (
|
||||
<div key={event.id} className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className={`px-2 py-1 text-xs rounded ${
|
||||
event.status === 'Published' ? 'bg-green-100 text-green-800' :
|
||||
event.status === 'Draft' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{event.status}
|
||||
</span>
|
||||
{event.category && (
|
||||
<span className="text-sm text-gray-500">{event.category}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-2">{event.name}</h3>
|
||||
<p className="text-gray-600 mb-4 line-clamp-2">{event.description}</p>
|
||||
|
||||
<div className="space-y-2 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>📅</span>
|
||||
{new Date(event.eventDate).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>📍</span>
|
||||
{event.location}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>👥</span>
|
||||
{event.currentRegistrations}
|
||||
{event.maxParticipants && ` / ${event.maxParticipants}`} registered
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{event.tags.map((tag) => (
|
||||
<span key={tag} className="px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={`/events/${event.id}`}
|
||||
className="mt-4 block text-center py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
View Details
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function LoginForm() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { login, error } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
router.push('/dashboard');
|
||||
} catch (err) {
|
||||
// Error is handled by auth context
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto mt-8 p-6 bg-white rounded-lg shadow-md">
|
||||
<h2 className="text-2xl font-bold mb-6 text-gray-800">Login</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<a href="/register" className="text-blue-600 hover:underline">
|
||||
Register
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
export function Navigation() {
|
||||
const { user, logout, isAuthenticated } = useAuth();
|
||||
|
||||
return (
|
||||
<nav className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center gap-8">
|
||||
<a href="/" className="text-xl font-bold text-blue-600">
|
||||
RacePlanner
|
||||
</a>
|
||||
<a href="/events" className="text-gray-700 hover:text-blue-600">
|
||||
Events
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<a href="/dashboard" className="text-gray-700 hover:text-blue-600">
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/registrations" className="text-gray-700 hover:text-blue-600">
|
||||
My Registrations
|
||||
</a>
|
||||
<span className="text-sm text-gray-500">{user?.name}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="px-4 py-2 text-sm text-red-600 hover:text-red-700"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<a href="/login" className="text-gray-700 hover:text-blue-600">
|
||||
Login
|
||||
</a>
|
||||
<a
|
||||
href="/register"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm"
|
||||
>
|
||||
Register
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface PaymentFormProps {
|
||||
registrationId: string;
|
||||
}
|
||||
|
||||
export function PaymentForm({ registrationId }: PaymentFormProps) {
|
||||
const [amount, setAmount] = useState('');
|
||||
const [method, setMethod] = useState('Cash');
|
||||
const [transactionId, setTransactionId] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await api.recordPayment(
|
||||
registrationId,
|
||||
parseFloat(amount),
|
||||
method,
|
||||
transactionId || undefined,
|
||||
notes || undefined
|
||||
);
|
||||
router.push('/registrations');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Payment failed');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow-md p-6 space-y-4">
|
||||
<h2 className="text-xl font-bold mb-4">Record Payment</h2>
|
||||
|
||||
<div>
|
||||
<label htmlFor="amount" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Amount ($)
|
||||
</label>
|
||||
<input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="method" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Payment Method
|
||||
</label>
|
||||
<select
|
||||
id="method"
|
||||
value={method}
|
||||
onChange={(e) => setMethod(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="Cash">Cash</option>
|
||||
<option value="Online">Online</option>
|
||||
<option value="Transfer">Transfer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="transactionId" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Transaction ID (optional)
|
||||
</label>
|
||||
<input
|
||||
id="transactionId"
|
||||
type="text"
|
||||
value={transactionId}
|
||||
onChange={(e) => setTransactionId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2 px-4 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400"
|
||||
>
|
||||
{isSubmitting ? 'Recording...' : 'Record Payment'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function RegisterForm() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [role, setRole] = useState<'Organizer' | 'Participant'>('Participant');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const { register, error } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setValidationError(null);
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setValidationError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setValidationError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await register(email, password, name, role);
|
||||
router.push('/dashboard');
|
||||
} catch (err) {
|
||||
// Error is handled by auth context
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto mt-8 p-6 bg-white rounded-lg shadow-md">
|
||||
<h2 className="text-2xl font-bold mb-6 text-gray-800">Register</h2>
|
||||
|
||||
{(error || validationError) && (
|
||||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||
{error || validationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="role" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Account Type
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as 'Organizer' | 'Participant')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="Participant">Participant</option>
|
||||
<option value="Organizer">Organizer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'Registering...' : 'Register'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<a href="/login" className="text-blue-600 hover:underline">
|
||||
Login
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface RegistrationFormProps {
|
||||
eventId: string;
|
||||
eventName: string;
|
||||
}
|
||||
|
||||
export function RegistrationForm({ eventId, eventName }: RegistrationFormProps) {
|
||||
const [category, setCategory] = useState('');
|
||||
const [emergencyContact, setEmergencyContact] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await api.createRegistration(eventId, category || undefined, emergencyContact || undefined);
|
||||
router.push('/registrations');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow-md p-6 space-y-4">
|
||||
<h2 className="text-xl font-bold mb-4">Register for {eventName}</h2>
|
||||
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Category (optional)
|
||||
</label>
|
||||
<input
|
||||
id="category"
|
||||
type="text"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
placeholder="e.g., Elite, Amateur, Junior"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="emergencyContact" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Emergency Contact
|
||||
</label>
|
||||
<input
|
||||
id="emergencyContact"
|
||||
type="text"
|
||||
value={emergencyContact}
|
||||
onChange={(e) => setEmergencyContact(e.target.value)}
|
||||
placeholder="Name and phone number"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2 px-4 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400"
|
||||
>
|
||||
{isSubmitting ? 'Registering...' : 'Complete Registration'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, Registration } from '@/lib/api';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
export function RegistrationList() {
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
loadRegistrations();
|
||||
}, []);
|
||||
|
||||
const loadRegistrations = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await api.getMyRegistrations();
|
||||
setRegistrations(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load registrations');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to cancel this registration?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.cancelRegistration(id);
|
||||
loadRegistrations();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to cancel registration');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Confirmed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'Pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'Cancelled':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8">Loading registrations...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{registrations.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p className="mb-4">You haven't registered for any events yet.</p>
|
||||
<a href="/events" className="text-blue-600 hover:underline">
|
||||
Browse events
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{registrations.map((registration) => (
|
||||
<div key={registration.id} className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 text-xs rounded ${getStatusColor(registration.status)}`}>
|
||||
{registration.status}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{new Date(registration.eventDate).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">{registration.eventName}</h3>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
Registered: {new Date(registration.registeredAt).toLocaleDateString()}
|
||||
</p>
|
||||
{registration.totalPaid > 0 && (
|
||||
<p className="text-sm text-green-600">
|
||||
Paid: ${registration.totalPaid.toFixed(2)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<a
|
||||
href={`/events/${registration.eventId}`}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm text-center"
|
||||
>
|
||||
View Event
|
||||
</a>
|
||||
{registration.status !== 'Cancelled' && registration.status !== 'Completed' && (
|
||||
<button
|
||||
onClick={() => handleCancel(registration.id)}
|
||||
className="px-4 py-2 border border-red-500 text-red-500 rounded-md hover:bg-red-50 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000/api';
|
||||
|
||||
// Types
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'Organizer' | 'Participant';
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
eventDate: string;
|
||||
location: string;
|
||||
status: 'Draft' | 'Published' | 'Cancelled' | 'Completed';
|
||||
category?: string;
|
||||
tags: string[];
|
||||
maxParticipants?: number;
|
||||
currentRegistrations: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
organizer: User;
|
||||
}
|
||||
|
||||
export interface Registration {
|
||||
id: string;
|
||||
eventId: string;
|
||||
eventName: string;
|
||||
eventDate: string;
|
||||
participantId: string;
|
||||
participantName: string;
|
||||
participantEmail: string;
|
||||
status: string;
|
||||
category?: string;
|
||||
emergencyContact?: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
totalPaid: number;
|
||||
amountDue: number;
|
||||
}
|
||||
|
||||
export interface Announcement {
|
||||
id: string;
|
||||
eventId: string;
|
||||
eventName: string;
|
||||
title: string;
|
||||
content: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
isPublished: boolean;
|
||||
}
|
||||
|
||||
export interface PaymentReport {
|
||||
eventId: string;
|
||||
eventName: string;
|
||||
totalCollected: number;
|
||||
totalPending: number;
|
||||
totalOutstanding: number;
|
||||
totalRegistrations: number;
|
||||
paidRegistrations: number;
|
||||
partialRegistrations: number;
|
||||
unpaidRegistrations: number;
|
||||
}
|
||||
|
||||
// API Client
|
||||
class ApiClient {
|
||||
private token: string | null = null;
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== 'undefined') {
|
||||
this.token = localStorage.getItem('token');
|
||||
}
|
||||
}
|
||||
|
||||
setToken(token: string) {
|
||||
this.token = token;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('token', token);
|
||||
}
|
||||
}
|
||||
|
||||
clearToken() {
|
||||
this.token = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
private async fetch(endpoint: string, options: RequestInit = {}) {
|
||||
const url = `${API_URL}${endpoint}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...((options.headers as Record<string, string>) || {}),
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(error.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Auth
|
||||
async register(email: string, password: string, name: string, role: 'Organizer' | 'Participant' = 'Participant') {
|
||||
const data = await this.fetch('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, name, role }),
|
||||
});
|
||||
this.setToken(data.token);
|
||||
return data as AuthResponse;
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
const data = await this.fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
this.setToken(data.token);
|
||||
return data as AuthResponse;
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.clearToken();
|
||||
}
|
||||
|
||||
// Events
|
||||
async getEvents(filters?: { category?: string; status?: string; fromDate?: string; toDate?: string }) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.category) params.append('category', filters.category);
|
||||
if (filters?.status) params.append('status', filters.status);
|
||||
if (filters?.fromDate) params.append('fromDate', filters.fromDate);
|
||||
if (filters?.toDate) params.append('toDate', filters.toDate);
|
||||
|
||||
const query = params.toString();
|
||||
return this.fetch(`/events${query ? `?${query}` : ''}`) as Promise<Event[]>;
|
||||
}
|
||||
|
||||
async getEvent(id: string) {
|
||||
return this.fetch(`/events/${id}`) as Promise<Event>;
|
||||
}
|
||||
|
||||
async createEvent(event: Partial<Event>) {
|
||||
return this.fetch('/events', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(event),
|
||||
}) as Promise<Event>;
|
||||
}
|
||||
|
||||
async updateEvent(id: string, event: Partial<Event>) {
|
||||
return this.fetch(`/events/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(event),
|
||||
}) as Promise<Event>;
|
||||
}
|
||||
|
||||
async deleteEvent(id: string) {
|
||||
return this.fetch(`/events/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// Registrations
|
||||
async createRegistration(eventId: string, category?: string, emergencyContact?: string) {
|
||||
return this.fetch('/registrations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ eventId, category, emergencyContact }),
|
||||
}) as Promise<Registration>;
|
||||
}
|
||||
|
||||
async getMyRegistrations() {
|
||||
return this.fetch('/registrations/my-registrations') as Promise<Registration[]>;
|
||||
}
|
||||
|
||||
async getEventRegistrations(eventId: string) {
|
||||
return this.fetch(`/registrations/event/${eventId}`) as Promise<Registration[]>;
|
||||
}
|
||||
|
||||
async cancelRegistration(id: string) {
|
||||
return this.fetch(`/registrations/${id}/cancel`, {
|
||||
method: 'POST',
|
||||
}) as Promise<Registration>;
|
||||
}
|
||||
|
||||
// Payments
|
||||
async recordPayment(registrationId: string, amount: number, method: string, transactionId?: string, notes?: string) {
|
||||
return this.fetch('/payments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ registrationId, amount, method, transactionId, notes }),
|
||||
});
|
||||
}
|
||||
|
||||
async getPaymentReport(eventId: string) {
|
||||
return this.fetch(`/payments/event/${eventId}/report`) as Promise<PaymentReport>;
|
||||
}
|
||||
|
||||
// Announcements
|
||||
async getEventAnnouncements(eventId: string) {
|
||||
return this.fetch(`/announcements/event/${eventId}`) as Promise<Announcement[]>;
|
||||
}
|
||||
|
||||
async createAnnouncement(eventId: string, title: string, content: string) {
|
||||
return this.fetch('/announcements', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ eventId, title, content }),
|
||||
}) as Promise<Announcement>;
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
async getOrganizerDashboard() {
|
||||
return this.fetch('/dashboard/organizer');
|
||||
}
|
||||
|
||||
async getParticipantDashboard() {
|
||||
return this.fetch('/dashboard/participant');
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { api, User, AuthResponse } from './api';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string, name: string, role: 'Organizer' | 'Participant') => Promise<void>;
|
||||
logout: () => void;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for existing token on mount
|
||||
const token = api.getToken();
|
||||
if (token) {
|
||||
// Token exists, user is authenticated
|
||||
// In a real app, you might want to validate the token
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
const response = await api.login(email, password);
|
||||
setUser(response.user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (email: string, password: string, name: string, role: 'Organizer' | 'Participant') => {
|
||||
try {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
const response = await api.register(email, password, name, role);
|
||||
setUser(response.user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
api.logout();
|
||||
setUser(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated: !!user,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
error,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
## 1. Project Setup
|
||||
|
||||
- [x] 1.1 Initialize project structure with backend and frontend directories
|
||||
- [x] 1.2 Setup .NET backend (ASP.NET Core Web API with C#)
|
||||
- [x] 1.3 Setup Next.js frontend with TypeScript using Bun runtime
|
||||
- [x] 1.4 Configure ESLint and Prettier for frontend
|
||||
- [x] 1.5 Setup Entity Framework Core with PostgreSQL
|
||||
- [x] 1.6 Create initial database schema migration (EF Core)
|
||||
|
||||
## 2. Database Schema
|
||||
|
||||
- [x] 2.1 Create users table with roles
|
||||
- [x] 2.2 Create events table with organizer foreign key
|
||||
- [x] 2.3 Create registrations table with event and user foreign keys
|
||||
- [x] 2.4 Create payments table with registration foreign key
|
||||
- [x] 2.5 Create announcements table with event foreign key
|
||||
- [x] 2.6 Add database indexes for common queries
|
||||
|
||||
## 3. User Authentication (user-auth)
|
||||
|
||||
- [x] 3.1 Implement user registration endpoint
|
||||
- [x] 3.2 Implement user login endpoint with JWT
|
||||
- [x] 3.3 Create authentication middleware
|
||||
- [x] 3.4 Implement role-based access control middleware
|
||||
- [x] 3.5 Create registration form component
|
||||
- [x] 3.6 Create login form component
|
||||
- [x] 3.7 Implement logout functionality
|
||||
|
||||
## 4. Event Management (event-management)
|
||||
|
||||
- [x] 4.1 Implement create event endpoint
|
||||
- [x] 4.2 Implement update event endpoint
|
||||
- [x] 4.3 Implement list events endpoint with filtering
|
||||
- [x] 4.4 Implement get event details endpoint
|
||||
- [x] 4.5 Create event creation form component
|
||||
- [x] 4.6 Create event editing form component
|
||||
- [x] 4.7 Create event list view with filters
|
||||
- [x] 4.8 Create event detail view
|
||||
|
||||
## 5. Registration System (registration-system)
|
||||
|
||||
- [x] 5.1 Implement registration endpoint
|
||||
- [x] 5.2 Implement registration status update endpoint
|
||||
- [x] 5.3 Implement cancel registration endpoint
|
||||
- [x] 5.4 Create registration form component
|
||||
- [x] 5.5 Create my registrations list component
|
||||
- [x] 5.6 Implement registration status display
|
||||
|
||||
## 6. Payment Tracking (payment-tracking)
|
||||
|
||||
- [x] 6.1 Implement record payment endpoint
|
||||
- [x] 6.2 Implement payment status endpoint
|
||||
- [x] 6.3 Implement payment report endpoint
|
||||
- [x] 6.4 Create payment recording form component
|
||||
- [x] 6.5 Create payment status display component
|
||||
- [x] 6.6 Create payment report view
|
||||
|
||||
## 7. Announcements (announcements)
|
||||
|
||||
- [x] 7.1 Implement create announcement endpoint
|
||||
- [x] 7.2 Implement edit announcement endpoint
|
||||
- [x] 7.3 Implement delete announcement endpoint
|
||||
- [x] 7.4 Implement list announcements endpoint
|
||||
- [x] 7.5 Create announcement creation form
|
||||
- [x] 7.6 Create announcement list component
|
||||
- [ ] 7.7 Implement notification system
|
||||
|
||||
## 8. Dashboard (dashboard)
|
||||
|
||||
- [x] 8.1 Implement organizer metrics endpoint
|
||||
- [x] 8.2 Implement participant registrations endpoint
|
||||
- [x] 8.3 Create organizer dashboard component
|
||||
- [x] 8.4 Create participant dashboard component
|
||||
- [x] 8.5 Add quick action buttons
|
||||
|
||||
## 9. Integration and Polish
|
||||
|
||||
- [x] 9.1 Connect frontend to all backend endpoints
|
||||
- [x] 9.2 Add error handling and loading states
|
||||
- [x] 9.3 Implement responsive design
|
||||
- [x] 9.4 Add form validation feedback
|
||||
- [x] 9.5 Setup email service for notifications
|
||||
- [x] 9.6 Add basic unit tests for critical paths
|
||||
- [x] 9.7 Create deployment configuration
|
||||
@@ -1,84 +0,0 @@
|
||||
## 1. Project Setup
|
||||
|
||||
- [x] 1.1 Initialize project structure with backend and frontend directories
|
||||
- [x] 1.2 Setup .NET backend (ASP.NET Core Web API with C#)
|
||||
- [x] 1.3 Setup Next.js frontend with TypeScript using Bun runtime
|
||||
- [x] 1.4 Configure ESLint and Prettier for frontend
|
||||
- [x] 1.5 Setup Entity Framework Core with PostgreSQL
|
||||
- [x] 1.6 Create initial database schema migration (EF Core)
|
||||
|
||||
## 2. Database Schema
|
||||
|
||||
- [x] 2.1 Create users table with roles
|
||||
- [x] 2.2 Create events table with organizer foreign key
|
||||
- [x] 2.3 Create registrations table with event and user foreign keys
|
||||
- [x] 2.4 Create payments table with registration foreign key
|
||||
- [x] 2.5 Create announcements table with event foreign key
|
||||
- [x] 2.6 Add database indexes for common queries
|
||||
|
||||
## 3. User Authentication (user-auth)
|
||||
|
||||
- [ ] 3.1 Implement user registration endpoint
|
||||
- [ ] 3.2 Implement user login endpoint with JWT
|
||||
- [ ] 3.3 Create authentication middleware
|
||||
- [ ] 3.4 Implement role-based access control middleware
|
||||
- [ ] 3.5 Create registration form component
|
||||
- [ ] 3.6 Create login form component
|
||||
- [ ] 3.7 Implement logout functionality
|
||||
|
||||
## 4. Event Management (event-management)
|
||||
|
||||
- [ ] 4.1 Implement create event endpoint
|
||||
- [ ] 4.2 Implement update event endpoint
|
||||
- [ ] 4.3 Implement list events endpoint with filtering
|
||||
- [ ] 4.4 Implement get event details endpoint
|
||||
- [ ] 4.5 Create event creation form component
|
||||
- [ ] 4.6 Create event editing form component
|
||||
- [ ] 4.7 Create event list view with filters
|
||||
- [ ] 4.8 Create event detail view
|
||||
|
||||
## 5. Registration System (registration-system)
|
||||
|
||||
- [ ] 5.1 Implement registration endpoint
|
||||
- [ ] 5.2 Implement registration status update endpoint
|
||||
- [ ] 5.3 Implement cancel registration endpoint
|
||||
- [ ] 5.4 Create registration form component
|
||||
- [ ] 5.5 Create my registrations list component
|
||||
- [ ] 5.6 Implement registration status display
|
||||
|
||||
## 6. Payment Tracking (payment-tracking)
|
||||
|
||||
- [ ] 6.1 Implement record payment endpoint
|
||||
- [ ] 6.2 Implement payment status endpoint
|
||||
- [ ] 6.3 Implement payment report endpoint
|
||||
- [ ] 6.4 Create payment recording form component
|
||||
- [ ] 6.5 Create payment status display component
|
||||
- [ ] 6.6 Create payment report view
|
||||
|
||||
## 7. Announcements (announcements)
|
||||
|
||||
- [ ] 7.1 Implement create announcement endpoint
|
||||
- [ ] 7.2 Implement edit announcement endpoint
|
||||
- [ ] 7.3 Implement delete announcement endpoint
|
||||
- [ ] 7.4 Implement list announcements endpoint
|
||||
- [ ] 7.5 Create announcement creation form
|
||||
- [ ] 7.6 Create announcement list component
|
||||
- [ ] 7.7 Implement notification system
|
||||
|
||||
## 8. Dashboard (dashboard)
|
||||
|
||||
- [ ] 8.1 Implement organizer metrics endpoint
|
||||
- [ ] 8.2 Implement participant registrations endpoint
|
||||
- [ ] 8.3 Create organizer dashboard component
|
||||
- [ ] 8.4 Create participant dashboard component
|
||||
- [ ] 8.5 Add quick action buttons
|
||||
|
||||
## 9. Integration and Polish
|
||||
|
||||
- [ ] 9.1 Connect frontend to all backend endpoints
|
||||
- [ ] 9.2 Add error handling and loading states
|
||||
- [ ] 9.3 Implement responsive design
|
||||
- [ ] 9.4 Add form validation feedback
|
||||
- [ ] 9.5 Setup email service for notifications
|
||||
- [ ] 9.6 Add basic unit tests for critical paths
|
||||
- [ ] 9.7 Create deployment configuration
|
||||
Reference in New Issue
Block a user