Adding more shit
This commit is contained in:
@@ -4,11 +4,12 @@ from typing import List
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from ...core.database import get_db
|
from ...core.database import get_db
|
||||||
from ...models.models import Membership, MembershipStatus, User, MembershipTier
|
from ...models.models import Membership, MembershipStatus, User, MembershipTier, Payment, PaymentStatus
|
||||||
from ...schemas import (
|
from ...schemas import (
|
||||||
MembershipCreate, MembershipUpdate, MembershipResponse, MessageResponse
|
MembershipCreate, MembershipUpdate, MembershipResponse, MessageResponse
|
||||||
)
|
)
|
||||||
from ...api.dependencies import get_current_active_user, get_admin_user
|
from ...api.dependencies import get_current_active_user, get_admin_user
|
||||||
|
from ...services.email_service import email_service
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -112,6 +113,11 @@ async def update_membership(
|
|||||||
)
|
)
|
||||||
|
|
||||||
update_data = membership_update.model_dump(exclude_unset=True)
|
update_data = membership_update.model_dump(exclude_unset=True)
|
||||||
|
was_activated = False
|
||||||
|
|
||||||
|
# Check if status is being changed to ACTIVE
|
||||||
|
if update_data.get("status") == MembershipStatus.ACTIVE and membership.status != MembershipStatus.ACTIVE:
|
||||||
|
was_activated = True
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(membership, field, value)
|
setattr(membership, field, value)
|
||||||
@@ -119,6 +125,32 @@ async def update_membership(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(membership)
|
db.refresh(membership)
|
||||||
|
|
||||||
|
# Send activation email if membership was just activated
|
||||||
|
if was_activated:
|
||||||
|
try:
|
||||||
|
# Get the most recent payment for this membership
|
||||||
|
recent_payment = db.query(Payment).filter(
|
||||||
|
Payment.membership_id == membership.id,
|
||||||
|
Payment.status == PaymentStatus.COMPLETED
|
||||||
|
).order_by(Payment.payment_date.desc()).first()
|
||||||
|
|
||||||
|
payment_amount = recent_payment.amount if recent_payment else membership.tier.annual_fee
|
||||||
|
payment_method = recent_payment.payment_method.value if recent_payment else "N/A"
|
||||||
|
|
||||||
|
# Send activation email (non-blocking)
|
||||||
|
await email_service.send_membership_activation_email(
|
||||||
|
to_email=membership.user.email,
|
||||||
|
first_name=membership.user.first_name,
|
||||||
|
membership_tier=membership.tier.name,
|
||||||
|
annual_fee=membership.tier.annual_fee,
|
||||||
|
payment_amount=payment_amount,
|
||||||
|
payment_method=payment_method,
|
||||||
|
renewal_date=membership.end_date.strftime("%d %B %Y")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but don't fail the membership update
|
||||||
|
print(f"Failed to send membership activation email: {e}")
|
||||||
|
|
||||||
return membership
|
return membership
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ from typing import List
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from ...core.database import get_db
|
from ...core.database import get_db
|
||||||
from ...models.models import Payment, PaymentStatus, User, Membership
|
from ...models.models import Payment, PaymentStatus, User, Membership, MembershipStatus
|
||||||
from ...schemas import (
|
from ...schemas import (
|
||||||
PaymentCreate, PaymentUpdate, PaymentResponse, MessageResponse
|
PaymentCreate, PaymentUpdate, PaymentResponse, MessageResponse
|
||||||
)
|
)
|
||||||
from ...api.dependencies import get_current_active_user, get_admin_user
|
from ...api.dependencies import get_current_active_user, get_admin_user
|
||||||
|
from ...services.email_service import email_service
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -114,6 +115,31 @@ async def update_payment(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(payment)
|
db.refresh(payment)
|
||||||
|
|
||||||
|
# If payment was just marked as completed and has an associated membership,
|
||||||
|
# activate the membership and send activation email
|
||||||
|
if (update_data.get("status") == PaymentStatus.COMPLETED and
|
||||||
|
payment.membership_id and
|
||||||
|
payment.membership.status == MembershipStatus.PENDING):
|
||||||
|
|
||||||
|
# Activate the membership
|
||||||
|
payment.membership.status = MembershipStatus.ACTIVE
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Send activation email (non-blocking)
|
||||||
|
try:
|
||||||
|
await email_service.send_membership_activation_email(
|
||||||
|
to_email=payment.membership.user.email,
|
||||||
|
first_name=payment.membership.user.first_name,
|
||||||
|
membership_tier=payment.membership.tier.name,
|
||||||
|
annual_fee=payment.membership.tier.annual_fee,
|
||||||
|
payment_amount=payment.amount,
|
||||||
|
payment_method=payment.payment_method.value,
|
||||||
|
renewal_date=payment.membership.end_date.strftime("%d %B %Y")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but don't fail the payment update
|
||||||
|
print(f"Failed to send membership activation email: {e}")
|
||||||
|
|
||||||
return payment
|
return payment
|
||||||
|
|
||||||
|
|
||||||
@@ -152,6 +178,7 @@ async def record_manual_payment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Verify membership if provided
|
# Verify membership if provided
|
||||||
|
membership = None
|
||||||
if payment_data.membership_id:
|
if payment_data.membership_id:
|
||||||
membership = db.query(Membership).filter(
|
membership = db.query(Membership).filter(
|
||||||
Membership.id == payment_data.membership_id,
|
Membership.id == payment_data.membership_id,
|
||||||
@@ -178,4 +205,24 @@ async def record_manual_payment(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(payment)
|
db.refresh(payment)
|
||||||
|
|
||||||
|
# If payment has an associated membership that's pending, activate it and send email
|
||||||
|
if membership and membership.status == MembershipStatus.PENDING:
|
||||||
|
membership.status = MembershipStatus.ACTIVE
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Send activation email (non-blocking)
|
||||||
|
try:
|
||||||
|
await email_service.send_membership_activation_email(
|
||||||
|
to_email=membership.user.email,
|
||||||
|
first_name=membership.user.first_name,
|
||||||
|
membership_tier=membership.tier.name,
|
||||||
|
annual_fee=membership.tier.annual_fee,
|
||||||
|
payment_amount=payment.amount,
|
||||||
|
payment_method=payment.payment_method.value,
|
||||||
|
renewal_date=membership.end_date.strftime("%d %B %Y")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but don't fail the payment creation
|
||||||
|
print(f"Failed to send membership activation email: {e}")
|
||||||
|
|
||||||
return payment
|
return payment
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from ...models.models import MembershipTier
|
|||||||
from ...schemas import (
|
from ...schemas import (
|
||||||
MembershipTierCreate, MembershipTierUpdate, MembershipTierResponse, MessageResponse
|
MembershipTierCreate, MembershipTierUpdate, MembershipTierResponse, MessageResponse
|
||||||
)
|
)
|
||||||
from ...api.dependencies import get_current_active_user, get_admin_user
|
from ...api.dependencies import get_current_active_user, get_admin_user, get_super_admin_user
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -47,10 +47,10 @@ async def get_membership_tier(
|
|||||||
@router.post("/", response_model=MembershipTierResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/", response_model=MembershipTierResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_membership_tier(
|
async def create_membership_tier(
|
||||||
tier_data: MembershipTierCreate,
|
tier_data: MembershipTierCreate,
|
||||||
current_user = Depends(get_admin_user),
|
current_user = Depends(get_super_admin_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Create a new membership tier (admin only)"""
|
"""Create a new membership tier (super admin only)"""
|
||||||
# Check if tier with same name exists
|
# Check if tier with same name exists
|
||||||
existing_tier = db.query(MembershipTier).filter(
|
existing_tier = db.query(MembershipTier).filter(
|
||||||
MembershipTier.name == tier_data.name
|
MembershipTier.name == tier_data.name
|
||||||
@@ -74,10 +74,10 @@ async def create_membership_tier(
|
|||||||
async def update_membership_tier(
|
async def update_membership_tier(
|
||||||
tier_id: int,
|
tier_id: int,
|
||||||
tier_update: MembershipTierUpdate,
|
tier_update: MembershipTierUpdate,
|
||||||
current_user = Depends(get_admin_user),
|
current_user = Depends(get_super_admin_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Update membership tier (admin only)"""
|
"""Update membership tier (super admin only)"""
|
||||||
tier = db.query(MembershipTier).filter(MembershipTier.id == tier_id).first()
|
tier = db.query(MembershipTier).filter(MembershipTier.id == tier_id).first()
|
||||||
if not tier:
|
if not tier:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -99,10 +99,10 @@ async def update_membership_tier(
|
|||||||
@router.delete("/{tier_id}", response_model=MessageResponse)
|
@router.delete("/{tier_id}", response_model=MessageResponse)
|
||||||
async def delete_membership_tier(
|
async def delete_membership_tier(
|
||||||
tier_id: int,
|
tier_id: int,
|
||||||
current_user = Depends(get_admin_user),
|
current_user = Depends(get_super_admin_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Delete membership tier (admin only)"""
|
"""Delete membership tier (super admin only)"""
|
||||||
tier = db.query(MembershipTier).filter(MembershipTier.id == tier_id).first()
|
tier = db.query(MembershipTier).filter(MembershipTier.id == tier_id).first()
|
||||||
if not tier:
|
if not tier:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import httpx
|
import httpx
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
from ..core.config import settings
|
from ..core.config import settings
|
||||||
|
|
||||||
|
|
||||||
@@ -141,6 +142,83 @@ class EmailService:
|
|||||||
|
|
||||||
return await self.send_email(to_email, subject, html_body, text_body)
|
return await self.send_email(to_email, subject, html_body, text_body)
|
||||||
|
|
||||||
|
async def send_membership_activation_email(
|
||||||
|
self,
|
||||||
|
to_email: str,
|
||||||
|
first_name: str,
|
||||||
|
membership_tier: str,
|
||||||
|
annual_fee: float,
|
||||||
|
payment_amount: float,
|
||||||
|
payment_method: str,
|
||||||
|
renewal_date: str
|
||||||
|
) -> dict:
|
||||||
|
"""Send membership activation email with payment details and renewal date"""
|
||||||
|
subject = f"Your {settings.APP_NAME} Membership is Now Active!"
|
||||||
|
|
||||||
|
html_body = f"""
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<h2 style="color: #28a745;">Welcome to {settings.APP_NAME}!</h2>
|
||||||
|
<p>Hello {first_name},</p>
|
||||||
|
<p>Great news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f8f9fa; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #28a745;">
|
||||||
|
<h3 style="margin-top: 0; color: #28a745;">Membership Details</h3>
|
||||||
|
<p style="margin: 8px 0;"><strong>Membership Tier:</strong> {membership_tier}</p>
|
||||||
|
<p style="margin: 8px 0;"><strong>Annual Fee:</strong> £{annual_fee:.2f}</p>
|
||||||
|
<p style="margin: 8px 0;"><strong>Next Renewal Date:</strong> {renewal_date}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background-color: #e9ecef; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0; color: #495057;">Payment Information</h3>
|
||||||
|
<p style="margin: 8px 0;"><strong>Amount Paid:</strong> £{payment_amount:.2f}</p>
|
||||||
|
<p style="margin: 8px 0;"><strong>Payment Method:</strong> {payment_method}</p>
|
||||||
|
<p style="margin: 8px 0;"><strong>Payment Date:</strong> {datetime.now().strftime('%d %B %Y')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Your membership will automatically renew on <strong>{renewal_date}</strong> unless you choose to cancel it. You can manage your membership settings in your account dashboard.</p>
|
||||||
|
|
||||||
|
<p>If you have any questions about your membership or need assistance, please don't hesitate to contact us.</p>
|
||||||
|
|
||||||
|
<p>Welcome to the {settings.APP_NAME} community!</p>
|
||||||
|
|
||||||
|
<p>Best regards,<br>
|
||||||
|
<strong>{settings.APP_NAME} Team</strong></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
text_body = f"""
|
||||||
|
Welcome to {settings.APP_NAME}!
|
||||||
|
|
||||||
|
Hello {first_name},
|
||||||
|
|
||||||
|
Great news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.
|
||||||
|
|
||||||
|
MEMBERSHIP DETAILS
|
||||||
|
------------------
|
||||||
|
Membership Tier: {membership_tier}
|
||||||
|
Annual Fee: £{annual_fee:.2f}
|
||||||
|
Next Renewal Date: {renewal_date}
|
||||||
|
|
||||||
|
PAYMENT INFORMATION
|
||||||
|
-------------------
|
||||||
|
Amount Paid: £{payment_amount:.2f}
|
||||||
|
Payment Method: {payment_method}
|
||||||
|
Payment Date: {datetime.now().strftime('%d %B %Y')}
|
||||||
|
|
||||||
|
Your membership will automatically renew on {renewal_date} unless you choose to cancel it. You can manage your membership settings in your account dashboard.
|
||||||
|
|
||||||
|
If you have any questions about your membership or need assistance, please don't hesitate to contact us.
|
||||||
|
|
||||||
|
Welcome to the {settings.APP_NAME} community!
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
{settings.APP_NAME} Team
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await self.send_email(to_email, subject, html_body, text_body)
|
||||||
|
|
||||||
async def send_membership_renewal_reminder(
|
async def send_membership_renewal_reminder(
|
||||||
self,
|
self,
|
||||||
to_email: str,
|
to_email: str,
|
||||||
|
|||||||
@@ -309,3 +309,79 @@ body {
|
|||||||
background: #6c757d;
|
background: #6c757d;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tab Styles */
|
||||||
|
.tab-active {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid #007bff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-inactive {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-inactive:hover {
|
||||||
|
color: #007bff;
|
||||||
|
border-bottom-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Super Admin Panel Styles */
|
||||||
|
.super-admin-table th {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.super-admin-table td {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.super-admin-loading {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.super-admin-placeholder {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons in tables */
|
||||||
|
.action-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid #007bff;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #007bff;
|
||||||
|
color: white !important;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: inline-block;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
border-color: #0056b3;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-danger {
|
||||||
|
border-color: #dc3545;
|
||||||
|
background: #dc3545;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-danger:hover {
|
||||||
|
background: #c82333;
|
||||||
|
border-color: #c82333;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
<div style={{ backgroundColor: '#fff3cd', border: '1px solid #ffeaa7', borderRadius: '4px', padding: '16px', marginBottom: '20px' }}>
|
<div style={{ backgroundColor: '#fff3cd', border: '1px solid #ffeaa7', borderRadius: '4px', padding: '16px', marginBottom: '20px' }}>
|
||||||
<strong>Demo Payment</strong>
|
<strong>Demo Payment</strong>
|
||||||
<p style={{ marginTop: '8px', marginBottom: 0 }}>
|
<p style={{ marginTop: '8px', marginBottom: 0 }}>
|
||||||
This is a fake payment flow for demonstration purposes. In a real application, you would integrate with a payment processor like Stripe or Square.
|
This is a fake payment flow for demo purposes. Square will come soon
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { authService } from '../services/membershipService';
|
import { authService } from '../services/membershipService';
|
||||||
|
import SuperAdminMenu from './SuperAdminMenu';
|
||||||
|
|
||||||
interface ProfileMenuProps {
|
interface ProfileMenuProps {
|
||||||
userName: string;
|
userName: string;
|
||||||
|
userRole: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName }) => {
|
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||||
|
const [showSuperAdmin, setShowSuperAdmin] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -35,8 +38,13 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName }) => {
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseChangePassword = () => {
|
const handleSuperAdmin = () => {
|
||||||
setShowChangePassword(false);
|
setShowSuperAdmin(true);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseSuperAdmin = () => {
|
||||||
|
setShowSuperAdmin(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const dropdownStyle: React.CSSProperties = {
|
const dropdownStyle: React.CSSProperties = {
|
||||||
@@ -85,8 +93,20 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName }) => {
|
|||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div style={dropdownStyle}>
|
<div style={dropdownStyle}>
|
||||||
|
{userRole === 'super_admin' && (
|
||||||
<button
|
<button
|
||||||
style={{ ...menuItemStyle, borderRadius: '4px 4px 0 0' }}
|
style={{ ...menuItemStyle, borderRadius: '4px 4px 0 0' }}
|
||||||
|
onClick={handleSuperAdmin}
|
||||||
|
>
|
||||||
|
Super Admin Panel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
...menuItemStyle,
|
||||||
|
borderRadius: userRole === 'super_admin' ? '0' : '4px 4px 0 0',
|
||||||
|
borderTop: userRole === 'super_admin' ? '1px solid #eee' : 'none'
|
||||||
|
}}
|
||||||
onClick={handleChangePassword}
|
onClick={handleChangePassword}
|
||||||
>
|
>
|
||||||
Change Password
|
Change Password
|
||||||
@@ -104,6 +124,10 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName }) => {
|
|||||||
{showChangePassword && (
|
{showChangePassword && (
|
||||||
<ChangePasswordModal onClose={handleCloseChangePassword} />
|
<ChangePasswordModal onClose={handleCloseChangePassword} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showSuperAdmin && (
|
||||||
|
<SuperAdminMenu onClose={handleCloseSuperAdmin} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
376
frontend/src/components/SuperAdminMenu.tsx
Normal file
376
frontend/src/components/SuperAdminMenu.tsx
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
|
||||||
|
|
||||||
|
interface SuperAdminMenuProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'tiers' | 'users' | 'system'>('tiers');
|
||||||
|
const [tiers, setTiers] = useState<MembershipTier[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [editingTier, setEditingTier] = useState<MembershipTier | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'tiers') {
|
||||||
|
loadTiers();
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
const loadTiers = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const tierData = await membershipService.getAllTiers(true);
|
||||||
|
setTiers(tierData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tiers:', error);
|
||||||
|
alert('Failed to load membership tiers');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTier = async (data: MembershipTierCreateData) => {
|
||||||
|
try {
|
||||||
|
await membershipService.createTier(data);
|
||||||
|
setShowCreateForm(false);
|
||||||
|
loadTiers();
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error.response?.data?.detail || 'Failed to create tier');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateTier = async (tierId: number, data: MembershipTierUpdateData) => {
|
||||||
|
try {
|
||||||
|
await membershipService.updateTier(tierId, data);
|
||||||
|
setEditingTier(null);
|
||||||
|
loadTiers();
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error.response?.data?.detail || 'Failed to update tier');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTier = async (tierId: number) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this membership tier? This action cannot be undone.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await membershipService.deleteTier(tierId);
|
||||||
|
loadTiers();
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error.response?.data?.detail || 'Failed to delete tier');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal-content" style={{ maxWidth: '900px', width: '90%' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
|
<h3 style={{ margin: 0, color: '#333' }}>Super Admin Panel</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '20px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#666'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '10px', borderBottom: '1px solid #ddd' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('tiers')}
|
||||||
|
className={activeTab === 'tiers' ? 'tab-active' : 'tab-inactive'}
|
||||||
|
>
|
||||||
|
Membership Tiers
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('users')}
|
||||||
|
className={activeTab === 'users' ? 'tab-active' : 'tab-inactive'}
|
||||||
|
>
|
||||||
|
User Management
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('system')}
|
||||||
|
className={activeTab === 'system' ? 'tab-active' : 'tab-inactive'}
|
||||||
|
>
|
||||||
|
System Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'tiers' && (
|
||||||
|
<TierManagement
|
||||||
|
tiers={tiers}
|
||||||
|
loading={loading}
|
||||||
|
showCreateForm={showCreateForm}
|
||||||
|
editingTier={editingTier}
|
||||||
|
onCreateTier={handleCreateTier}
|
||||||
|
onUpdateTier={handleUpdateTier}
|
||||||
|
onDeleteTier={handleDeleteTier}
|
||||||
|
onShowCreateForm={() => setShowCreateForm(true)}
|
||||||
|
onHideCreateForm={() => setShowCreateForm(false)}
|
||||||
|
onEditTier={setEditingTier}
|
||||||
|
onCancelEdit={() => setEditingTier(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'users' && (
|
||||||
|
<div style={{ padding: '20px', textAlign: 'center' }} className="super-admin-placeholder">
|
||||||
|
User management features coming soon...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'system' && (
|
||||||
|
<div style={{ padding: '20px', textAlign: 'center' }} className="super-admin-placeholder">
|
||||||
|
System settings coming soon...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TierManagementProps {
|
||||||
|
tiers: MembershipTier[];
|
||||||
|
loading: boolean;
|
||||||
|
showCreateForm: boolean;
|
||||||
|
editingTier: MembershipTier | null;
|
||||||
|
onCreateTier: (data: MembershipTierCreateData) => void;
|
||||||
|
onUpdateTier: (tierId: number, data: MembershipTierUpdateData) => void;
|
||||||
|
onDeleteTier: (tierId: number) => void;
|
||||||
|
onShowCreateForm: () => void;
|
||||||
|
onHideCreateForm: () => void;
|
||||||
|
onEditTier: (tier: MembershipTier) => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TierManagement: React.FC<TierManagementProps> = ({
|
||||||
|
tiers,
|
||||||
|
loading,
|
||||||
|
showCreateForm,
|
||||||
|
editingTier,
|
||||||
|
onCreateTier,
|
||||||
|
onUpdateTier,
|
||||||
|
onDeleteTier,
|
||||||
|
onShowCreateForm,
|
||||||
|
onHideCreateForm,
|
||||||
|
onEditTier,
|
||||||
|
onCancelEdit
|
||||||
|
}) => {
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{ padding: '20px', textAlign: 'center' }} className="super-admin-loading">Loading tiers...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
|
<h4 style={{ margin: 0, color: '#333' }}>Membership Tiers Management</h4>
|
||||||
|
<button
|
||||||
|
onClick={onShowCreateForm}
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ fontSize: '14px', padding: '8px 16px' }}
|
||||||
|
>
|
||||||
|
Create New Tier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreateForm && (
|
||||||
|
<TierForm
|
||||||
|
onSubmit={onCreateTier}
|
||||||
|
onCancel={onHideCreateForm}
|
||||||
|
title="Create New Membership Tier"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingTier && (
|
||||||
|
<TierForm
|
||||||
|
initialData={editingTier}
|
||||||
|
onSubmit={(data) => onUpdateTier(editingTier.id, data)}
|
||||||
|
onCancel={onCancelEdit}
|
||||||
|
title="Edit Membership Tier"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '20px' }} className="super-admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Name</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Description</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Annual Fee</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Benefits</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Status</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tiers.map(tier => (
|
||||||
|
<tr key={tier.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||||
|
<td style={{ padding: '12px', fontWeight: 'bold' }}>{tier.name}</td>
|
||||||
|
<td style={{ padding: '12px', maxWidth: '200px' }}>
|
||||||
|
{tier.description || 'No description'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px' }}>£{tier.annual_fee.toFixed(2)}</td>
|
||||||
|
<td style={{ padding: '12px', maxWidth: '250px' }}>
|
||||||
|
{tier.benefits || 'No benefits listed'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
<span className={`status-badge ${tier.is_active ? 'status-active' : 'status-expired'}`}>
|
||||||
|
{tier.is_active ? 'ACTIVE' : 'INACTIVE'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onEditTier(tier)}
|
||||||
|
className="action-btn"
|
||||||
|
style={{ marginRight: '8px', color: 'white', backgroundColor: '#007bff', border: '1px solid #007bff' }}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDeleteTier(tier.id)}
|
||||||
|
className="action-btn action-btn-danger"
|
||||||
|
style={{ color: 'white', backgroundColor: '#dc3545', border: '1px solid #dc3545' }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TierFormProps {
|
||||||
|
initialData?: MembershipTier;
|
||||||
|
onSubmit: (data: MembershipTierCreateData | MembershipTierUpdateData) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, title }) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: initialData?.name || '',
|
||||||
|
description: initialData?.description || '',
|
||||||
|
annual_fee: initialData?.annual_fee || 0,
|
||||||
|
benefits: initialData?.benefits || '',
|
||||||
|
is_active: initialData?.is_active ?? true
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (field: string, value: any) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: '#f8f9fa',
|
||||||
|
padding: '20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
border: '1px solid #dee2e6'
|
||||||
|
}}>
|
||||||
|
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>{title}</h4>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
||||||
|
<div className="modal-form-group">
|
||||||
|
<label>Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => handleChange('name', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-form-group">
|
||||||
|
<label>Annual Fee (£) *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={formData.annual_fee}
|
||||||
|
onChange={(e) => handleChange('annual_fee', parseFloat(e.target.value) || 0)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-form-group" style={{ marginBottom: '16px' }}>
|
||||||
|
<label>Description</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => handleChange('description', e.target.value)}
|
||||||
|
placeholder="Optional description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-form-group" style={{ marginBottom: '16px' }}>
|
||||||
|
<label>Benefits</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.benefits}
|
||||||
|
onChange={(e) => handleChange('benefits', e.target.value)}
|
||||||
|
placeholder="List the benefits of this membership tier"
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#333',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
resize: 'vertical'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.is_active}
|
||||||
|
onChange={(e) => handleChange('is_active', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Active (visible to users)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="modal-btn-cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="modal-btn-primary"
|
||||||
|
>
|
||||||
|
{initialData ? 'Update Tier' : 'Create Tier'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SuperAdminMenu;
|
||||||
@@ -132,7 +132,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<nav className="navbar">
|
<nav className="navbar">
|
||||||
<h1>SASA Membership Portal</h1>
|
<h1>SASA Membership Portal</h1>
|
||||||
<ProfileMenu userName={`${user?.first_name} ${user?.last_name}`} />
|
<ProfileMenu userName={`${user?.first_name} ${user?.last_name}`} userRole={user?.role || ''} />
|
||||||
</nav>
|
</nav>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<MembershipSetup
|
<MembershipSetup
|
||||||
@@ -150,7 +150,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<nav className="navbar">
|
<nav className="navbar">
|
||||||
<h1>SASA Membership Portal</h1>
|
<h1>SASA Membership Portal</h1>
|
||||||
<ProfileMenu userName={`${user?.first_name} ${user?.last_name}`} />
|
<ProfileMenu userName={`${user?.first_name} ${user?.last_name}`} userRole={user?.role || ''} />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="container">
|
<div className="container">
|
||||||
|
|||||||
@@ -97,11 +97,19 @@ export interface PaymentUpdateData {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MembershipUpdateData {
|
export interface MembershipTierCreateData {
|
||||||
tier_id?: number;
|
name: string;
|
||||||
status?: string;
|
description?: string;
|
||||||
end_date?: string;
|
annual_fee: number;
|
||||||
auto_renew?: boolean;
|
benefits?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MembershipTierUpdateData {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
annual_fee?: number;
|
||||||
|
benefits?: string;
|
||||||
|
is_active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
@@ -186,6 +194,26 @@ export const membershipService = {
|
|||||||
async getTiers(): Promise<MembershipTier[]> {
|
async getTiers(): Promise<MembershipTier[]> {
|
||||||
const response = await api.get('/tiers/');
|
const response = await api.get('/tiers/');
|
||||||
return response.data;
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTier(data: MembershipTierCreateData): Promise<MembershipTier> {
|
||||||
|
const response = await api.post('/tiers/', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateTier(tierId: number, data: MembershipTierUpdateData): Promise<MembershipTier> {
|
||||||
|
const response = await api.put(`/tiers/${tierId}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteTier(tierId: number): Promise<{ message: string }> {
|
||||||
|
const response = await api.delete(`/tiers/${tierId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAllTiers(showInactive: boolean = true): Promise<MembershipTier[]> {
|
||||||
|
const response = await api.get(`/tiers/?show_inactive=${showInactive}`);
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user