Compare commits

..

2 Commits

Author SHA1 Message Date
James Pattinson
107c208746 Basic event management 2025-11-12 18:08:11 +00:00
James Pattinson
e5fdd0ecb8 Mobile improvements 2025-11-12 16:47:21 +00:00
11 changed files with 960 additions and 176 deletions

View File

@@ -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"])

View 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

View File

@@ -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"

View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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',

View File

@@ -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;
}

View File

@@ -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={{

View File

@@ -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',

View File

@@ -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;
}
};