Compare commits
2 Commits
0f74333a22
...
107c208746
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
107c208746 | ||
|
|
e5fdd0ecb8 |
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from . import auth, users, tiers, memberships, payments, email, email_templates
|
||||
from . import auth, users, tiers, memberships, payments, email, email_templates, events
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -10,3 +10,4 @@ api_router.include_router(memberships.router, prefix="/memberships", tags=["memb
|
||||
api_router.include_router(payments.router, prefix="/payments", tags=["payments"])
|
||||
api_router.include_router(email.router, prefix="/email", tags=["email"])
|
||||
api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"])
|
||||
api_router.include_router(events.router, prefix="/events", tags=["events"])
|
||||
|
||||
207
backend/app/api/v1/events.py
Normal file
207
backend/app/api/v1/events.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...models.models import Event, EventRSVP, User, EventStatus
|
||||
from ...schemas import (
|
||||
EventCreate, EventUpdate, EventResponse, EventRSVPResponse, EventRSVPUpdate, MessageResponse
|
||||
)
|
||||
from ...api.dependencies import get_current_active_user, get_admin_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[EventResponse])
|
||||
async def get_events(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all events (admin) or published events (members)"""
|
||||
if current_user.role in ['admin', 'super_admin']:
|
||||
events = db.query(Event).order_by(Event.event_date).all()
|
||||
else:
|
||||
events = db.query(Event).filter(
|
||||
Event.status == EventStatus.PUBLISHED
|
||||
).order_by(Event.event_date).all()
|
||||
return events
|
||||
|
||||
|
||||
@router.get("/upcoming", response_model=List[EventResponse])
|
||||
async def get_upcoming_events(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get upcoming events"""
|
||||
now = datetime.now()
|
||||
events = db.query(Event).filter(
|
||||
Event.event_date >= now.date(),
|
||||
Event.status == EventStatus.PUBLISHED
|
||||
).order_by(Event.event_date).all()
|
||||
return events
|
||||
|
||||
|
||||
@router.post("/", response_model=EventResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_event(
|
||||
event_data: EventCreate,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new event (admin only)"""
|
||||
# Validate event date is in the future
|
||||
if event_data.event_date < datetime.now():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Event date must be in the future"
|
||||
)
|
||||
|
||||
event = Event(
|
||||
title=event_data.title,
|
||||
description=event_data.description,
|
||||
event_date=event_data.event_date,
|
||||
event_time=event_data.event_time,
|
||||
location=event_data.location,
|
||||
max_attendees=event_data.max_attendees,
|
||||
status=EventStatus.DRAFT,
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(event)
|
||||
db.commit()
|
||||
db.refresh(event)
|
||||
return event
|
||||
|
||||
|
||||
@router.put("/{event_id}", response_model=EventResponse)
|
||||
async def update_event(
|
||||
event_id: int,
|
||||
event_data: EventUpdate,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update an event (admin only)"""
|
||||
event = db.query(Event).filter(Event.id == event_id).first()
|
||||
if not event:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Event not found"
|
||||
)
|
||||
|
||||
# Update fields
|
||||
for field, value in event_data.dict(exclude_unset=True).items():
|
||||
setattr(event, field, value)
|
||||
|
||||
event.updated_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(event)
|
||||
return event
|
||||
|
||||
|
||||
@router.delete("/{event_id}", response_model=MessageResponse)
|
||||
async def delete_event(
|
||||
event_id: int,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete an event (admin only)"""
|
||||
event = db.query(Event).filter(Event.id == event_id).first()
|
||||
if not event:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Event not found"
|
||||
)
|
||||
|
||||
db.delete(event)
|
||||
db.commit()
|
||||
return {"message": "Event deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/{event_id}/rsvps", response_model=List[EventRSVPResponse])
|
||||
async def get_event_rsvps(
|
||||
event_id: int,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get RSVPs for an event (admin only)"""
|
||||
event = db.query(Event).filter(Event.id == event_id).first()
|
||||
if not event:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Event not found"
|
||||
)
|
||||
|
||||
rsvps = db.query(EventRSVP).filter(EventRSVP.event_id == event_id).all()
|
||||
return rsvps
|
||||
|
||||
|
||||
@router.post("/{event_id}/rsvp", response_model=EventRSVPResponse)
|
||||
async def create_or_update_rsvp(
|
||||
event_id: int,
|
||||
rsvp_data: EventRSVPUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create or update RSVP for an event"""
|
||||
event = db.query(Event).filter(Event.id == event_id).first()
|
||||
if not event:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Event not found"
|
||||
)
|
||||
|
||||
if event.status != EventStatus.PUBLISHED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Event is not available for RSVP"
|
||||
)
|
||||
|
||||
# Check if RSVP already exists
|
||||
existing_rsvp = db.query(EventRSVP).filter(
|
||||
EventRSVP.event_id == event_id,
|
||||
EventRSVP.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if existing_rsvp:
|
||||
# Update existing RSVP
|
||||
existing_rsvp.status = rsvp_data.status
|
||||
if rsvp_data.notes is not None:
|
||||
existing_rsvp.notes = rsvp_data.notes
|
||||
existing_rsvp.updated_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(existing_rsvp)
|
||||
return existing_rsvp
|
||||
else:
|
||||
# Check attendee limit
|
||||
if event.max_attendees:
|
||||
current_rsvp_count = db.query(EventRSVP).filter(
|
||||
EventRSVP.event_id == event_id,
|
||||
EventRSVP.status == 'attending'
|
||||
).count()
|
||||
if current_rsvp_count >= event.max_attendees:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Event is at maximum capacity"
|
||||
)
|
||||
|
||||
# Create new RSVP
|
||||
rsvp = EventRSVP(
|
||||
event_id=event_id,
|
||||
user_id=current_user.id,
|
||||
status=rsvp_data.status,
|
||||
notes=rsvp_data.notes
|
||||
)
|
||||
db.add(rsvp)
|
||||
db.commit()
|
||||
db.refresh(rsvp)
|
||||
return rsvp
|
||||
|
||||
|
||||
@router.get("/my-rsvps", response_model=List[EventRSVPResponse])
|
||||
async def get_my_rsvps(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's RSVPs"""
|
||||
rsvps = db.query(EventRSVP).filter(EventRSVP.user_id == current_user.id).all()
|
||||
return rsvps
|
||||
@@ -41,7 +41,7 @@ class Settings(BaseSettings):
|
||||
FRONTEND_URL: str = "http://localhost:3500"
|
||||
|
||||
# CORS
|
||||
BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8080"]
|
||||
BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8080", "https://members.sasalliance.org"]
|
||||
|
||||
# File Storage
|
||||
UPLOAD_DIR: str = "/app/uploads"
|
||||
|
||||
@@ -135,6 +135,7 @@ class Event(Base):
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
event_date = Column(DateTime, nullable=False)
|
||||
event_time = Column(String(10), nullable=True) # HH:MM format
|
||||
location = Column(String(255), nullable=True)
|
||||
max_attendees = Column(Integer, nullable=True)
|
||||
status = Column(SQLEnum(EventStatus, values_callable=lambda x: [e.value for e in x]), default=EventStatus.DRAFT, nullable=False)
|
||||
|
||||
@@ -30,6 +30,13 @@ from .schemas import (
|
||||
EmailTemplateCreate,
|
||||
EmailTemplateUpdate,
|
||||
EmailTemplateResponse,
|
||||
EventBase,
|
||||
EventCreate,
|
||||
EventUpdate,
|
||||
EventResponse,
|
||||
EventRSVPBase,
|
||||
EventRSVPUpdate,
|
||||
EventRSVPResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -64,4 +71,11 @@ __all__ = [
|
||||
"EmailTemplateCreate",
|
||||
"EmailTemplateUpdate",
|
||||
"EmailTemplateResponse",
|
||||
"EventBase",
|
||||
"EventCreate",
|
||||
"EventUpdate",
|
||||
"EventResponse",
|
||||
"EventRSVPBase",
|
||||
"EventRSVPUpdate",
|
||||
"EventRSVPResponse",
|
||||
]
|
||||
|
||||
@@ -229,3 +229,58 @@ class EmailTemplateResponse(EmailTemplateBase):
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# Event Schemas
|
||||
class EventBase(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
event_date: datetime
|
||||
event_time: Optional[str] = Field(None, pattern=r'^([01]?[0-9]|2[0-3]):[0-5][0-9]$')
|
||||
location: Optional[str] = None
|
||||
max_attendees: Optional[int] = Field(None, gt=0)
|
||||
|
||||
|
||||
class EventCreate(EventBase):
|
||||
pass
|
||||
|
||||
|
||||
class EventUpdate(BaseModel):
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
event_date: Optional[datetime] = None
|
||||
event_time: Optional[str] = Field(None, pattern=r'^([01]?[0-9]|2[0-3]):[0-5][0-9]$')
|
||||
location: Optional[str] = None
|
||||
max_attendees: Optional[int] = Field(None, gt=0)
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class EventResponse(EventBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
status: str
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# Event RSVP Schemas
|
||||
class EventRSVPBase(BaseModel):
|
||||
status: str = Field(..., pattern="^(pending|attending|not_attending|maybe)$")
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class EventRSVPUpdate(EventRSVPBase):
|
||||
pass
|
||||
|
||||
|
||||
class EventRSVPResponse(EventRSVPBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
event_id: int
|
||||
user_id: int
|
||||
attended: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -71,6 +71,7 @@ CREATE TABLE events (
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
event_date TIMESTAMP NOT NULL,
|
||||
event_time VARCHAR(10), -- HH:MM format
|
||||
location VARCHAR(255),
|
||||
max_attendees INT,
|
||||
status ENUM('draft', 'published', 'cancelled', 'completed') NOT NULL DEFAULT 'draft',
|
||||
|
||||
@@ -79,7 +79,7 @@ body {
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
@@ -185,6 +185,70 @@ body {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Mobile responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.navbar h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Make tables responsive */
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 600px; /* Ensure minimum width for readability */
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Auth pages mobile adjustments */
|
||||
.auth-container {
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
max-width: 100%;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Welcome section mobile adjustments */
|
||||
.welcome-section {
|
||||
max-width: 100% !important;
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
.welcome-section h1 {
|
||||
font-size: 1.8rem !important;
|
||||
}
|
||||
|
||||
/* Form grid mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
form[style*="grid-template-columns"] {
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 16px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
@@ -255,7 +319,7 @@ body {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { authService, userService, membershipService, paymentService, User, Membership, Payment } from '../services/membershipService';
|
||||
import { authService, userService, membershipService, paymentService, eventService, User, Membership, Payment, Event, EventRSVP } from '../services/membershipService';
|
||||
import MembershipSetup from '../components/MembershipSetup';
|
||||
import ProfileMenu from '../components/ProfileMenu';
|
||||
import ProfileEdit from '../components/ProfileEdit';
|
||||
@@ -21,6 +21,11 @@ const Dashboard: React.FC = () => {
|
||||
const [showUserDetails, setShowUserDetails] = useState(false);
|
||||
const [isEditingUser, setIsEditingUser] = useState(false);
|
||||
const [editFormData, setEditFormData] = useState<Partial<User>>({});
|
||||
const [upcomingEvents, setUpcomingEvents] = useState<Event[]>([]);
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||
const [eventRSVPs, setEventRSVPs] = useState<EventRSVP[]>([]);
|
||||
const [eventRSVPCounts, setEventRSVPCounts] = useState<{[eventId: number]: {attending: number, maybe: number, not_attending: number}}>({});
|
||||
const [rsvpLoading, setRsvpLoading] = useState<{[eventId: number]: boolean}>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!authService.isAuthenticated()) {
|
||||
@@ -31,6 +36,16 @@ const Dashboard: React.FC = () => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const mergeRSVPStatus = (events: Event[], rsvps: EventRSVP[]): Event[] => {
|
||||
return events.map(event => {
|
||||
const rsvp = rsvps.find(r => r.event_id === event.id);
|
||||
return {
|
||||
...event,
|
||||
rsvp_status: rsvp ? rsvp.status : undefined
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [userData, membershipData, paymentData] = await Promise.all([
|
||||
@@ -43,16 +58,31 @@ const Dashboard: React.FC = () => {
|
||||
setMemberships(membershipData);
|
||||
setPayments(paymentData);
|
||||
|
||||
// Load upcoming events and user's RSVPs
|
||||
const [eventsData, rsvpsData] = await Promise.all([
|
||||
eventService.getUpcomingEvents(),
|
||||
eventService.getMyRSVPs()
|
||||
]);
|
||||
|
||||
// Merge RSVP status with events
|
||||
const eventsWithRSVP = mergeRSVPStatus(eventsData, rsvpsData);
|
||||
setUpcomingEvents(eventsWithRSVP);
|
||||
|
||||
// Load admin data if user is admin
|
||||
if (userData.role === 'admin' || userData.role === 'super_admin') {
|
||||
const [allPaymentsData, allMembershipsData, allUsersData] = await Promise.all([
|
||||
const [allPaymentsData, allMembershipsData, allUsersData, allEventsData] = await Promise.all([
|
||||
paymentService.getAllPayments(),
|
||||
membershipService.getAllMemberships(),
|
||||
userService.getAllUsers()
|
||||
userService.getAllUsers(),
|
||||
eventService.getAllEvents()
|
||||
]);
|
||||
setAllPayments(allPaymentsData);
|
||||
setAllMemberships(allMembershipsData);
|
||||
setAllUsers(allUsersData);
|
||||
setAllEvents(allEventsData);
|
||||
|
||||
// Load RSVP counts for all events
|
||||
await loadEventRSVPCounts(allEventsData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error);
|
||||
@@ -61,6 +91,26 @@ const Dashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadEventRSVPCounts = async (events: Event[]) => {
|
||||
const counts: {[eventId: number]: {attending: number, maybe: number, not_attending: number}} = {};
|
||||
|
||||
for (const event of events) {
|
||||
try {
|
||||
const rsvps = await eventService.getEventRSVPs(event.id);
|
||||
counts[event.id] = {
|
||||
attending: rsvps.filter(r => r.status === 'attending').length,
|
||||
maybe: rsvps.filter(r => r.status === 'maybe').length,
|
||||
not_attending: rsvps.filter(r => r.status === 'not_attending').length
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to load RSVPs for event ${event.id}:`, error);
|
||||
counts[event.id] = { attending: 0, maybe: 0, not_attending: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
setEventRSVPCounts(counts);
|
||||
};
|
||||
|
||||
const handleMembershipSetup = () => {
|
||||
setShowMembershipSetup(true);
|
||||
};
|
||||
@@ -198,11 +248,73 @@ const Dashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormChange = (field: keyof User, value: string) => {
|
||||
setEditFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
const handleRSVP = async (eventId: number, status: 'attending' | 'not_attending' | 'maybe') => {
|
||||
// Set loading state for this event
|
||||
setRsvpLoading(prev => ({ ...prev, [eventId]: true }));
|
||||
|
||||
// Optimistically update the UI
|
||||
setUpcomingEvents(prevEvents =>
|
||||
prevEvents.map(event =>
|
||||
event.id === eventId
|
||||
? { ...event, rsvp_status: status }
|
||||
: event
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await eventService.createOrUpdateRSVP(eventId, { status });
|
||||
// Reload RSVPs and merge with events to get the latest data
|
||||
const [eventsData, rsvpsData] = await Promise.all([
|
||||
eventService.getUpcomingEvents(),
|
||||
eventService.getMyRSVPs()
|
||||
]);
|
||||
const eventsWithRSVP = mergeRSVPStatus(eventsData, rsvpsData);
|
||||
setUpcomingEvents(eventsWithRSVP);
|
||||
} catch (error) {
|
||||
console.error('Failed to update RSVP:', error);
|
||||
alert('Failed to update RSVP. Please try again.');
|
||||
// Revert optimistic update on error
|
||||
const [eventsData, rsvpsData] = await Promise.all([
|
||||
eventService.getUpcomingEvents(),
|
||||
eventService.getMyRSVPs()
|
||||
]);
|
||||
const eventsWithRSVP = mergeRSVPStatus(eventsData, rsvpsData);
|
||||
setUpcomingEvents(eventsWithRSVP);
|
||||
} finally {
|
||||
// Clear loading state
|
||||
setRsvpLoading(prev => ({ ...prev, [eventId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublishEvent = async (eventId: number) => {
|
||||
try {
|
||||
await eventService.updateEvent(eventId, { status: 'published' });
|
||||
// Reload events to reflect the change
|
||||
const eventsData = await eventService.getAllEvents();
|
||||
setAllEvents(eventsData);
|
||||
// Reload RSVP counts
|
||||
await loadEventRSVPCounts(eventsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to publish event:', error);
|
||||
alert('Failed to publish event. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEvent = async (eventId: number) => {
|
||||
if (!confirm('Are you sure you want to cancel this event?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await eventService.updateEvent(eventId, { status: 'cancelled' });
|
||||
// Reload events to reflect the change
|
||||
const eventsData = await eventService.getAllEvents();
|
||||
setAllEvents(eventsData);
|
||||
// Reload RSVP counts
|
||||
await loadEventRSVPCounts(eventsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel event:', error);
|
||||
alert('Failed to cancel event. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
@@ -271,10 +383,11 @@ const Dashboard: React.FC = () => {
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: '16px' }}>Your Membership</h3>
|
||||
<h4 style={{ color: '#0066cc', marginBottom: '8px' }}>{activeMembership.tier.name}</h4>
|
||||
<p><strong>Membership Number:</strong> {activeMembership.id}</p>
|
||||
<p><strong>Status:</strong> <span className={`status-badge ${getStatusClass(activeMembership.status)}`}>{activeMembership.status.toUpperCase()}</span></p>
|
||||
<p><strong>Annual Fee:</strong> £{activeMembership.tier.annual_fee.toFixed(2)}</p>
|
||||
<p><strong>Member since:</strong> {formatDate(activeMembership.start_date)}</p>
|
||||
<p><strong>Renewal Date:</strong> {formatDate(activeMembership.end_date)}</p>
|
||||
<p><strong>Valid From:</strong> {formatDate(activeMembership.start_date)}</p>
|
||||
<p><strong>Valid Until:</strong> {formatDate(activeMembership.end_date)}</p>
|
||||
<p><strong>Auto Renew:</strong> {activeMembership.auto_renew ? 'Yes' : 'No'}</p>
|
||||
<div style={{ marginTop: '12px', padding: '12px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||
<strong>Benefits:</strong>
|
||||
@@ -301,6 +414,7 @@ const Dashboard: React.FC = () => {
|
||||
<div className="card" style={{ marginTop: '20px' }}>
|
||||
<h3 style={{ marginBottom: '16px' }}>Payment History</h3>
|
||||
{payments.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||
@@ -325,11 +439,131 @@ const Dashboard: React.FC = () => {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: '#666' }}>No payment history available.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upcoming Events */}
|
||||
<div className="card" style={{ marginTop: '20px' }}>
|
||||
<h3 style={{ marginBottom: '16px' }}>Upcoming Events</h3>
|
||||
{upcomingEvents.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{upcomingEvents.map(event => (
|
||||
<div key={event.id} style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#f9f9f9'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px' }}>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 4px 0', color: '#0066cc' }}>{event.title}</h4>
|
||||
<p style={{ margin: '0', fontSize: '14px', color: '#666' }}>
|
||||
{formatDate(event.event_date)} at {event.event_time}
|
||||
</p>
|
||||
{event.location && (
|
||||
<p style={{ margin: '4px 0 0 0', fontSize: '14px', color: '#666' }}>
|
||||
📍 {event.location}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
className={`btn ${event.rsvp_status === 'attending' ? 'btn-success' : 'btn-outline-secondary'}`}
|
||||
onClick={() => handleRSVP(event.id, 'attending')}
|
||||
disabled={rsvpLoading[event.id]}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
padding: '8px 16px',
|
||||
opacity: rsvpLoading[event.id] ? 0.6 : event.rsvp_status ? (event.rsvp_status === 'attending' ? 1 : 0.4) : 1,
|
||||
cursor: rsvpLoading[event.id] ? 'not-allowed' : 'pointer',
|
||||
fontWeight: event.rsvp_status === 'attending' ? 'bold' : 'normal',
|
||||
border: event.rsvp_status === 'attending' ? '3px solid #28a745' : '2px solid #adb5bd',
|
||||
backgroundColor: event.rsvp_status === 'attending' ? '#28a745' : 'transparent',
|
||||
color: event.rsvp_status === 'attending' ? 'white' : '#6c757d',
|
||||
transform: event.rsvp_status === 'attending' ? 'scale(1.1)' : event.rsvp_status ? 'scale(0.95)' : 'scale(1)',
|
||||
boxShadow: event.rsvp_status === 'attending' ? '0 4px 8px rgba(40, 167, 69, 0.3)' : 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
filter: event.rsvp_status && event.rsvp_status !== 'attending' ? 'grayscale(50%)' : 'none'
|
||||
}}
|
||||
>
|
||||
{rsvpLoading[event.id] ? '...' : 'Attending'}
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${event.rsvp_status === 'maybe' ? 'btn-warning' : 'btn-outline-secondary'}`}
|
||||
onClick={() => handleRSVP(event.id, 'maybe')}
|
||||
disabled={rsvpLoading[event.id]}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
padding: '8px 16px',
|
||||
opacity: rsvpLoading[event.id] ? 0.6 : event.rsvp_status ? (event.rsvp_status === 'maybe' ? 1 : 0.4) : 1,
|
||||
cursor: rsvpLoading[event.id] ? 'not-allowed' : 'pointer',
|
||||
fontWeight: event.rsvp_status === 'maybe' ? 'bold' : 'normal',
|
||||
border: event.rsvp_status === 'maybe' ? '3px solid #ffc107' : '2px solid #adb5bd',
|
||||
backgroundColor: event.rsvp_status === 'maybe' ? '#ffc107' : 'transparent',
|
||||
color: event.rsvp_status === 'maybe' ? '#212529' : '#6c757d',
|
||||
transform: event.rsvp_status === 'maybe' ? 'scale(1.1)' : event.rsvp_status ? 'scale(0.95)' : 'scale(1)',
|
||||
boxShadow: event.rsvp_status === 'maybe' ? '0 4px 8px rgba(255, 193, 7, 0.3)' : 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
filter: event.rsvp_status && event.rsvp_status !== 'maybe' ? 'grayscale(50%)' : 'none'
|
||||
}}
|
||||
>
|
||||
{rsvpLoading[event.id] ? '...' : 'Maybe'}
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${event.rsvp_status === 'not_attending' ? 'btn-danger' : 'btn-outline-secondary'}`}
|
||||
onClick={() => handleRSVP(event.id, 'not_attending')}
|
||||
disabled={rsvpLoading[event.id]}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
padding: '8px 16px',
|
||||
opacity: rsvpLoading[event.id] ? 0.6 : event.rsvp_status ? (event.rsvp_status === 'not_attending' ? 1 : 0.4) : 1,
|
||||
cursor: rsvpLoading[event.id] ? 'not-allowed' : 'pointer',
|
||||
fontWeight: event.rsvp_status === 'not_attending' ? 'bold' : 'normal',
|
||||
border: event.rsvp_status === 'not_attending' ? '3px solid #dc3545' : '2px solid #adb5bd',
|
||||
backgroundColor: event.rsvp_status === 'not_attending' ? '#dc3545' : 'transparent',
|
||||
color: event.rsvp_status === 'not_attending' ? 'white' : '#6c757d',
|
||||
transform: event.rsvp_status === 'not_attending' ? 'scale(1.1)' : event.rsvp_status ? 'scale(0.95)' : 'scale(1)',
|
||||
boxShadow: event.rsvp_status === 'not_attending' ? '0 4px 8px rgba(220, 53, 69, 0.3)' : 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
filter: event.rsvp_status && event.rsvp_status !== 'not_attending' ? 'grayscale(50%)' : 'none'
|
||||
}}
|
||||
>
|
||||
{rsvpLoading[event.id] ? '...' : 'Not Attending'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{event.description && (
|
||||
<p style={{ margin: '0', fontSize: '14px', lineHeight: '1.4' }}>
|
||||
{event.description}
|
||||
</p>
|
||||
)}
|
||||
{event.rsvp_status && (
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: event.rsvp_status === 'attending' ? '#d4edda' :
|
||||
event.rsvp_status === 'maybe' ? '#fff3cd' : '#f8d7da',
|
||||
border: `1px solid ${event.rsvp_status === 'attending' ? '#c3e6cb' :
|
||||
event.rsvp_status === 'maybe' ? '#ffeaa7' : '#f5c6cb'}`,
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
color: event.rsvp_status === 'attending' ? '#155724' :
|
||||
event.rsvp_status === 'maybe' ? '#856404' : '#721c24'
|
||||
}}>
|
||||
<strong>Your RSVP:</strong> <span style={{ textTransform: 'capitalize' }}>{event.rsvp_status.replace('_', ' ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: '#666' }}>No upcoming events at this time.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Admin Section */}
|
||||
{(user?.role === 'admin' || user?.role === 'super_admin') && (
|
||||
<div className="card" style={{ marginTop: '20px' }}>
|
||||
@@ -339,6 +573,7 @@ const Dashboard: React.FC = () => {
|
||||
{allPayments.filter(p => p.status === 'pending').length > 0 && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h4 style={{ marginBottom: '12px' }}>Pending Payments</h4>
|
||||
<div className="table-container">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||
@@ -375,12 +610,14 @@ const Dashboard: React.FC = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending Memberships */}
|
||||
{allMemberships.filter(m => m.status === 'pending').length > 0 && (
|
||||
<div>
|
||||
<h4 style={{ marginBottom: '12px' }}>Pending Memberships</h4>
|
||||
<div className="table-container">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||
@@ -406,6 +643,7 @@ const Dashboard: React.FC = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allPayments.filter(p => p.status === 'pending').length === 0 &&
|
||||
@@ -437,11 +675,13 @@ const Dashboard: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Name</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Email</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Membership #</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Role</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Status</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Joined</th>
|
||||
@@ -449,7 +689,9 @@ const Dashboard: React.FC = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map(u => (
|
||||
{filteredUsers.map(u => {
|
||||
const userMembership = allMemberships.find(m => m.user_id === u.id && m.status === 'active');
|
||||
return (
|
||||
<tr
|
||||
key={u.id}
|
||||
style={{ borderBottom: '1px solid #eee', cursor: 'pointer' }}
|
||||
@@ -457,6 +699,7 @@ const Dashboard: React.FC = () => {
|
||||
>
|
||||
<td style={{ padding: '12px' }}>{u.first_name} {u.last_name}</td>
|
||||
<td style={{ padding: '12px' }}>{u.email}</td>
|
||||
<td style={{ padding: '12px' }}>{userMembership ? userMembership.id : 'N/A'}</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<span style={{
|
||||
backgroundColor: u.role === 'super_admin' ? '#dc3545' :
|
||||
@@ -506,10 +749,12 @@ const Dashboard: React.FC = () => {
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -521,6 +766,110 @@ const Dashboard: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Event Management Section for Admins */}
|
||||
{(user?.role === 'admin' || user?.role === 'super_admin') && (
|
||||
<div className="card" style={{ marginTop: '20px' }}>
|
||||
<h3 style={{ marginBottom: '16px' }}>Event Management</h3>
|
||||
|
||||
{/* Create New Event Button */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {/* TODO: Implement create event modal */}}
|
||||
style={{ fontSize: '14px', padding: '8px 16px' }}
|
||||
>
|
||||
Create New Event
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="table-container">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Event</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Date & Time</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Location</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Status</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>RSVPs</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allEvents.map(event => (
|
||||
<tr key={event.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<div>
|
||||
<strong>{event.title}</strong>
|
||||
{event.description && (
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px', maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{event.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<div>{formatDate(event.event_date)}</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>{event.event_time}</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>{event.location || 'TBD'}</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<span className={`status-badge ${event.status === 'published' ? 'status-active' : event.status === 'cancelled' ? 'status-expired' : 'status-pending'}`}>
|
||||
{event.status.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
{eventRSVPCounts[event.id] ? (
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
<div>Attending: {eventRSVPCounts[event.id].attending}</div>
|
||||
<div>Maybe: {eventRSVPCounts[event.id].maybe}</div>
|
||||
<div>Not: {eventRSVPCounts[event.id].not_attending}</div>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>Loading...</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {/* TODO: Implement edit event */}}
|
||||
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{event.status === 'draft' && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handlePublishEvent(event.id)}
|
||||
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
)}
|
||||
{event.status === 'published' && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => handleCancelEvent(event.id)}
|
||||
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{allEvents.length === 0 && (
|
||||
<p style={{ color: '#666', textAlign: 'center', padding: '20px' }}>No events created yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Details Modal */}
|
||||
{showUserDetails && selectedUser && (
|
||||
<div style={{
|
||||
|
||||
@@ -43,7 +43,7 @@ const Login: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container" style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: '40px', padding: '20px' }}>
|
||||
<div className="auth-container" style={{ gap: '40px', padding: '20px' }}>
|
||||
<div className="welcome-section" style={{
|
||||
flex: '1',
|
||||
maxWidth: '400px',
|
||||
|
||||
@@ -14,6 +14,10 @@ export interface LoginData {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ForgotPasswordData {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
@@ -62,10 +66,6 @@ export interface Payment {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ForgotPasswordData {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ResetPasswordData {
|
||||
token: string;
|
||||
new_password: string;
|
||||
@@ -83,6 +83,14 @@ export interface MembershipCreateData {
|
||||
auto_renew: boolean;
|
||||
}
|
||||
|
||||
export interface MembershipTierUpdateData {
|
||||
name?: string;
|
||||
description?: string;
|
||||
annual_fee?: number;
|
||||
benefits?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface MembershipUpdateData {
|
||||
status?: string;
|
||||
start_date?: string;
|
||||
@@ -111,12 +119,54 @@ export interface MembershipTierCreateData {
|
||||
benefits?: string;
|
||||
}
|
||||
|
||||
export interface MembershipTierUpdateData {
|
||||
name?: string;
|
||||
export interface Event {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
event_date: string;
|
||||
event_time: string | null;
|
||||
location: string | null;
|
||||
max_attendees: number | null;
|
||||
status: string;
|
||||
created_by: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
rsvp_status?: string; // Current user's RSVP status
|
||||
}
|
||||
|
||||
export interface EventCreateData {
|
||||
title: string;
|
||||
description?: string;
|
||||
annual_fee?: number;
|
||||
benefits?: string;
|
||||
is_active?: boolean;
|
||||
event_date: string;
|
||||
event_time?: string;
|
||||
location?: string;
|
||||
max_attendees?: number;
|
||||
}
|
||||
|
||||
export interface EventUpdateData {
|
||||
title?: string;
|
||||
description?: string;
|
||||
event_date?: string;
|
||||
event_time?: string;
|
||||
location?: string;
|
||||
max_attendees?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface EventRSVP {
|
||||
id: number;
|
||||
event_id: number;
|
||||
user_id: number;
|
||||
status: string;
|
||||
attended: boolean;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface EventRSVPData {
|
||||
status: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
@@ -245,3 +295,45 @@ export const paymentService = {
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
export const eventService = {
|
||||
async getAllEvents(): Promise<Event[]> {
|
||||
const response = await api.get('/events/');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getUpcomingEvents(): Promise<Event[]> {
|
||||
const response = await api.get('/events/upcoming');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createEvent(data: EventCreateData): Promise<Event> {
|
||||
const response = await api.post('/events/', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateEvent(eventId: number, data: EventUpdateData): Promise<Event> {
|
||||
const response = await api.put(`/events/${eventId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deleteEvent(eventId: number): Promise<{ message: string }> {
|
||||
const response = await api.delete(`/events/${eventId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getEventRSVPs(eventId: number): Promise<EventRSVP[]> {
|
||||
const response = await api.get(`/events/${eventId}/rsvps`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createOrUpdateRSVP(eventId: number, data: EventRSVPData): Promise<EventRSVP> {
|
||||
const response = await api.post(`/events/${eventId}/rsvp`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getMyRSVPs(): Promise<EventRSVP[]> {
|
||||
const response = await api.get('/events/my-rsvps');
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user