Enhancements

This commit is contained in:
2026-06-25 11:19:08 +02:00
parent 8804d21c32
commit 9343657d20
62 changed files with 10210 additions and 976 deletions
+206
View File
@@ -0,0 +1,206 @@
# SUST Admin Frontend
A modern, responsive Bootstrap-based admin dashboard for managing SUST members and payments.
## Features
### 📊 Dashboard Overview
- Quick statistics on active, inactive, and deactivated members
- Payment and member count summary
- Quick navigation to management sections
### 👥 Member Management
- View all members in a searchable, sortable table
- Filter by name or email
- View detailed member information with modal
- Add new members with validation
- See member activity and payment history
### 💳 Payment Management
- View all payment records
- Associate payments with members
- Search and filter payments by member name
- Track payment dates and amounts
## Project Structure
```
admin-frontend/
├── src/
│ ├── components/ # React components
│ │ ├── Navbar.tsx # Navigation bar
│ │ ├── StatsCard.tsx # Statistics dashboard cards
│ │ ├── MembersTable.tsx # Members list table
│ │ ├── MemberModal.tsx # Member details modal
│ │ ├── PaymentsTable.tsx # Payments list
│ │ └── AddMemberForm.tsx # New member form
│ ├── api.ts # API client helper functions
│ ├── types.ts # TypeScript type definitions
│ ├── App.tsx # Main application component
│ ├── App.css # Custom styles
│ └── main.tsx # Entry point
├── index.html # HTML template
├── vite.config.ts # Vite configuration
├── tsconfig.json # TypeScript configuration
└── package.json # Dependencies
```
## Installation
1. Navigate to the admin-frontend directory:
```bash
cd sustApp/admin-frontend
```
2. Install dependencies:
```bash
npm install
```
## Development
Start the development server:
```bash
npm run dev
```
The frontend will be available at `http://localhost:5174`
**Important:** Make sure your FastAPI backend is running on `http://localhost:8000`
## Build for Production
```bash
npm run build
```
Output files will be in the `dist/` directory.
## API Integration
The frontend connects to these FastAPI endpoints:
### Members
- `GET /members/` - List members
- `GET /member/{member_id}` - Get member details
- `POST /members/` - Create single member
- `POST /fullmembers/` - Create multiple members
### Payments
- `GET /payments/` - List all payments
- `GET /payments/{member_id}` - Get member payments
### Stats
- `GET /activememberscount/` - Count active members
- `GET /inactivememberscount/` - Count inactive members
- `GET /deactivatedmemberscount/` - Count deactivated members
## Configuration
To change the API base URL, edit `src/api.ts`:
```typescript
const API_BASE = 'http://localhost:8000'
```
To change the development server port, edit `vite.config.ts`:
```typescript
server: {
port: 5174,
...
}
```
## Technologies Used
- **React 19** - UI framework
- **TypeScript** - Type safety
- **Bootstrap 5** - CSS framework
- **Axios** - HTTP client
- **Vite** - Build tool
## Features Overview
### Dashboard Tab
- Real-time statistics cards
- Active/inactive/deactivated member counts
- Quick links to management sections
### Members Tab
- **Search/Filter**: Find members by name or email
- **View Details**: Click "View" to see full member information
- Contact details
- Address information
- Status and active/inactive status
- Recent payment history
- **Add Member**: Create new members with comprehensive form
- Required fields: First name, surname, street, city
- Optional: Email, spouse name, ZIP code, etc.
### Payments Tab
- **Complete Payment History**: View all payments with member associations
- **Search**: Filter by member name
- **Details**: Payment date, year, and amount
## Responsive Design
The interface is fully responsive and works on:
- Desktop (1024px+)
- Tablet (768px-1024px)
- Mobile (< 768px)
## Styling
Custom Bootstrap styles are in `src/App.css` with:
- Dark navbar
- Custom tab navigation
- Hover effects on buttons
- Modal styling
- Mobile-responsive adjustments
## Error Handling
The frontend includes error handling for:
- API connection failures
- Missing data
- Form validation errors
- Loading states
## Future Enhancements
- Edit member functionality
- Delete member confirmation
- Payment record management
- Member state transitions
- Bulk member import
- Export reports
- Authentication/authorization
- Member notes/comments
- Email notifications
- Advanced filtering and sorting
## Troubleshooting
### API Connection Issues
If you see "Failed to load data" errors:
1. Verify FastAPI backend is running on port 8000
2. Check CORS configuration in `sustApp/main.py`
3. Check browser console for specific errors
### Build Issues
```bash
# Clear node_modules and reinstall
rm -rf node_modules package-lock.json
npm install
```
### Port Already in Use
```bash
# Change port in vite.config.ts or
npm run dev -- --port 5175
```
## Support
For issues or questions, check the main SUST project documentation.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SUST Admin - Member & Payment Management</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "sust-admin-frontend",
"version": "1.0.0",
"description": "Bootstrap admin frontend for SUST member and payment management",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"bootstrap": "^5.3.2",
"axios": "^1.6.2"
},
"devDependencies": {
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.7.0",
"typescript": "^5.8.3",
"vite": "^7.0.6"
}
}
+338
View File
@@ -0,0 +1,338 @@
/* Modern Admin Dashboard Styles */
:root {
--primary-color: #27ae60;
--secondary-color: #f39c12;
--danger-color: #e74c3c;
--info-color: #3498db;
--text-dark: #2c3e50;
--text-muted: #7f8c8d;
--bg-light: #f8f9fa;
--border-color: #ecf0f1;
}
* {
box-sizing: border-box;
}
body {
background-color: var(--bg-light);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: var(--text-dark);
}
/* Navbar */
.navbar {
background-color: #ffffff !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
border-bottom: 1px solid var(--border-color);
}
.navbar-brand {
font-weight: 700 !important;
font-size: 1.4rem !important;
color: var(--text-dark) !important;
}
.nav-link {
color: var(--text-muted) !important;
font-weight: 500;
transition: color 0.3s ease;
font-size: 0.95rem;
}
.nav-link:hover {
color: var(--primary-color) !important;
}
/* Cards */
.card {
border: none !important;
border-radius: 8px !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08) !important;
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important;
}
.card-body {
padding: 1.5rem;
}
.card-title {
color: var(--text-dark);
font-weight: 600;
margin-bottom: 1rem;
}
/* Badges */
.badge {
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
font-weight: 500;
border-radius: 4px;
}
/* Tables */
.table {
margin-bottom: 0;
}
.table thead {
background-color: var(--bg-light);
border-bottom: 2px solid var(--border-color);
}
.table thead th {
color: var(--text-muted);
font-weight: 600;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
border: none;
padding: 1rem 0.75rem;
}
.table tbody tr {
transition: background-color 0.2s ease;
border-bottom: 1px solid var(--border-color);
}
.table tbody tr:hover {
background-color: rgba(39, 174, 96, 0.03);
}
.table tbody td {
padding: 1rem 0.75rem;
color: var(--text-dark);
vertical-align: middle;
}
/* Buttons */
.btn {
border-radius: 6px;
font-weight: 500;
transition: all 0.3s ease;
border: none;
}
.btn-primary {
background-color: var(--primary-color) !important;
color: white !important;
}
.btn-primary:hover {
background-color: #229954 !important;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.3) !important;
}
.btn-secondary {
background-color: var(--text-muted) !important;
color: white !important;
}
.btn-secondary:hover {
background-color: #6c757d !important;
}
.btn-sm {
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
}
/* Form Controls */
.form-control,
.form-select {
border: 1px solid var(--border-color) !important;
border-radius: 6px !important;
padding: 0.75rem 1rem !important;
font-size: 0.95rem;
transition: all 0.3s ease;
background-color: #ffffff;
}
.form-control:focus,
.form-select:focus {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 3px rgba(39, 174, 96, 0.1) !important;
}
.form-label {
color: var(--text-dark);
font-weight: 500;
margin-bottom: 0.5rem;
}
/* Modals */
.modal {
display: block;
position: fixed;
z-index: 1050;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
animation: fadeIn 0.3s ease;
}
.modal-dialog {
animation: slideUp 0.3s ease;
}
.modal-content {
border: none !important;
border-radius: 12px !important;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2) !important;
}
.modal-header {
border-bottom: 1px solid var(--border-color) !important;
padding: 1.5rem !important;
background-color: var(--bg-light);
}
.modal-title {
color: var(--text-dark);
font-weight: 600;
font-size: 1.25rem;
}
.modal-body {
padding: 1.5rem !important;
color: var(--text-dark);
}
.modal-footer {
border-top: 1px solid var(--border-color) !important;
padding: 1.5rem !important;
background-color: var(--bg-light);
}
/* Spinner */
.spinner-border {
color: var(--primary-color) !important;
}
/* Alerts */
.alert {
border: none !important;
border-radius: 8px !important;
border-left: 4px solid;
}
.alert-danger {
background-color: #fee !important;
border-left-color: #e74c3c !important;
color: #c0392b !important;
}
.alert-info {
background-color: #eff !important;
border-left-color: #3498db !important;
color: #2980b9 !important;
}
.alert-success {
background-color: #efe !important;
border-left-color: #27ae60 !important;
color: #229954 !important;
}
.alert-warning {
background-color: #ffeaa7 !important;
border-left-color: #f39c12 !important;
color: #d68910 !important;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Responsive */
@media (max-width: 768px) {
.container-fluid {
padding-left: 1rem;
padding-right: 1rem;
}
.card-body {
padding: 1rem;
}
.table {
font-size: 0.9rem;
}
.table thead th {
padding: 0.75rem 0.5rem;
}
.table tbody td {
padding: 0.75rem 0.5rem;
}
h2 {
font-size: 1.5rem;
}
.btn {
font-size: 0.9rem;
padding: 0.5rem 1rem;
}
.d-flex {
flex-direction: column;
gap: 0.5rem !important;
}
.d-flex.gap-2 {
gap: 0.5rem !important;
}
}
@media (max-width: 480px) {
.navbar-brand {
font-size: 1.1rem !important;
}
h2 {
font-size: 1.25rem;
}
.card-body {
padding: 0.75rem;
}
.modal-body {
padding: 1rem !important;
}
.btn-sm {
padding: 0.35rem 0.5rem;
font-size: 0.8rem;
}
}
+262
View File
@@ -0,0 +1,262 @@
import React, { useState, useEffect } from 'react'
import { Navbar } from './components/Navbar'
import { StatsCard } from './components/StatsCard'
import { MembersTable } from './components/MembersTable'
import { MemberModal } from './components/MemberModal'
import { PaymentsTable } from './components/PaymentsTable'
import { AddMemberForm } from './components/AddMemberForm'
import { getMembers, getAllPayments, getStats } from './api'
import { Member, Payment, Stats } from './types'
import './App.css'
function App() {
const [members, setMembers] = useState<Member[]>([])
const [payments, setPayments] = useState<Payment[]>([])
const [stats, setStats] = useState<Stats>({
activeCount: 0,
inactiveCount: 0,
deactivatedCount: 0,
})
const [loading, setLoading] = useState(true)
const [selectedTab, setSelectedTab] = useState<'overview' | 'members' | 'payments'>('overview')
const [selectedMember, setSelectedMember] = useState<Member | null>(null)
const [showAddMember, setShowAddMember] = useState(false)
const loadData = async () => {
try {
setLoading(true)
const [membersData, paymentsData, statsData] = await Promise.all([
getMembers(0, 500),
getAllPayments(),
getStats(),
])
setMembers(membersData)
setPayments(paymentsData)
setStats(statsData)
} catch (error) {
console.error('Failed to load data:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
loadData()
}, [])
const handleAddMemberSuccess = () => {
setShowAddMember(false)
loadData()
}
const tabContent = [
{ id: 'overview', label: 'Dashboard', icon: '📊' },
{ id: 'members', label: 'Member', icon: '👥' },
{ id: 'payments', label: 'Payments', icon: '💳' },
]
return (
<div style={{ backgroundColor: '#f8f9fa', minHeight: '100vh' }}>
<Navbar />
<main style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
<div className="container-fluid px-4">
{/* Tab Navigation */}
<div className="d-flex justify-content-between align-items-center mb-4">
<div className="d-flex gap-2">
{tabContent.map((tab) => (
<button
key={tab.id}
onClick={() => setSelectedTab(tab.id as 'overview' | 'members' | 'payments')}
style={{
padding: '0.5rem 1.5rem',
border: 'none',
backgroundColor:
selectedTab === tab.id ? '#27ae60' : '#ffffff',
color: selectedTab === tab.id ? '#ffffff' : '#555',
borderRadius: '6px',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.3s ease',
boxShadow: selectedTab === tab.id ? '0 2px 8px rgba(39, 174, 96, 0.2)' : 'none',
}}
className="btn"
>
{tab.icon} {tab.label}
</button>
))}
</div>
{selectedTab === 'members' && (
<button
onClick={() => setShowAddMember(true)}
style={{
padding: '0.5rem 1.5rem',
backgroundColor: '#f39c12',
color: '#ffffff',
border: 'none',
borderRadius: '6px',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.3s ease',
}}
className="btn"
>
Add Member
</button>
)}
</div>
{/* Overview Tab */}
{selectedTab === 'overview' && (
<div>
<div className="mb-4">
<h2 style={{ color: '#2c3e50', fontWeight: 600, marginBottom: '1.5rem' }}>
Dashboard Overview
</h2>
</div>
{loading ? (
<div className="text-center py-5">
<div className="spinner-border" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
) : (
<>
<div className="row mb-5">
<StatsCard title="Active Members" count={stats.activeCount} icon="✅" color="success" />
<StatsCard title="Inactive Members" count={stats.inactiveCount} icon="⏸️" color="warning" />
<StatsCard
title="Deactivated Members"
count={stats.deactivatedCount}
icon="❌"
color="danger"
/>
</div>
<div className="row">
<div className="col-lg-6 mb-4">
<div className="card border-0 shadow-sm">
<div className="card-body">
<h5 className="card-title" style={{ color: '#2c3e50', fontWeight: 600 }}>
Quick Stats
</h5>
<div style={{ marginTop: '1.5rem' }}>
<p className="mb-3">
<strong>Total Members:</strong>
<span style={{ float: 'right', fontSize: '1.1rem', color: '#27ae60' }}>
{members.length}
</span>
</p>
<p className="mb-3">
<strong>Total Payments:</strong>
<span style={{ float: 'right', fontSize: '1.1rem', color: '#3498db' }}>
{payments.length}
</span>
</p>
<p>
<strong>Member Distribution:</strong>
<span style={{ float: 'right', fontSize: '1.1rem', color: '#e74c3c' }}>
{members.reduce((sum, m) => sum + m.member_count, 0)}
</span>
</p>
</div>
</div>
</div>
</div>
<div className="col-lg-6 mb-4">
<div className="card border-0 shadow-sm">
<div className="card-body">
<h5 className="card-title" style={{ color: '#2c3e50', fontWeight: 600 }}>
Quick Actions
</h5>
<div className="d-grid gap-2" style={{ marginTop: '1.5rem' }}>
<button
onClick={() => setSelectedTab('members')}
className="btn"
style={{
backgroundColor: '#27ae60',
color: 'white',
padding: '0.75rem',
fontWeight: 500,
}}
>
Browse Members
</button>
<button
onClick={() => setSelectedTab('payments')}
className="btn"
style={{
backgroundColor: '#3498db',
color: 'white',
padding: '0.75rem',
fontWeight: 500,
}}
>
View Payments
</button>
</div>
</div>
</div>
</div>
</div>
</>
)}
</div>
)}
{/* Members Tab */}
{selectedTab === 'members' && (
<div>
<div className="mb-4">
<h2 style={{ color: '#2c3e50', fontWeight: 600, marginBottom: '1.5rem' }}>
Member Management
</h2>
</div>
<div className="card border-0 shadow-sm">
<div className="card-body p-4">
<MembersTable
members={members}
onViewDetails={setSelectedMember}
loading={loading}
/>
</div>
</div>
</div>
)}
{/* Payments Tab */}
{selectedTab === 'payments' && (
<div>
<div className="mb-4">
<h2 style={{ color: '#2c3e50', fontWeight: 600, marginBottom: '1.5rem' }}>
Payment Records
</h2>
</div>
<div className="card border-0 shadow-sm">
<div className="card-body p-4">
<PaymentsTable members={members} payments={payments} loading={loading} />
</div>
</div>
</div>
)}
</div>
</main>
{/* Modals */}
{selectedMember && (
<MemberModal member={selectedMember} onClose={() => setSelectedMember(null)} />
)}
{showAddMember && (
<AddMemberForm
onSuccess={handleAddMemberSuccess}
onCancel={() => setShowAddMember(false)}
/>
)}
</div>
)
}
export default App
+59
View File
@@ -0,0 +1,59 @@
import axios from 'axios'
import { Member, Payment, Stats } from './types'
const API_BASE = 'http://localhost:8000'
const api = axios.create({
baseURL: API_BASE,
headers: {
'Content-Type': 'application/json',
},
})
// Members
export const getMembers = async (skip = 0, limit = 100) => {
const response = await api.get<Member[]>('/members/', { params: { skip, limit } })
return response.data
}
export const getMember = async (memberId: number) => {
const response = await api.get<Member>(`/member/${memberId}`)
return response.data
}
export const createMember = async (member: Partial<Member>) => {
const response = await api.post<Member>('/members/', member)
return response.data
}
export const createMembers = async (members: Partial<Member>[]) => {
const response = await api.post<void>('/fullmembers/', members)
return response.data
}
// Payments
export const getAllPayments = async () => {
const response = await api.get<Payment[]>('/payments/')
return response.data
}
export const getMemberPayments = async (memberId: number) => {
const response = await api.get<Payment[]>(`/payments/${memberId}`)
return response.data
}
// Stats
export const getStats = async (): Promise<Stats> => {
const [activeCount, inactiveCount, deactivatedCount] = await Promise.all([
api.get<number>('/activememberscount/').then((r) => r.data || 0),
api.get<number>('/inactivememberscount/').then((r) => r.data || 0),
api.get<number>('/deactivatedmemberscount/').then((r) => r.data || 0),
])
return {
activeCount: activeCount || 0,
inactiveCount: inactiveCount || 0,
deactivatedCount: deactivatedCount || 0,
}
}
export default api
@@ -0,0 +1,219 @@
import React, { useState } from 'react'
import { Member } from '../types'
import { createMember } from '../api'
interface AddMemberFormProps {
onSuccess: () => void
onCancel: () => void
}
export const AddMemberForm: React.FC<AddMemberFormProps> = ({ onSuccess, onCancel }) => {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [formData, setFormData] = useState<Partial<Member>>({
member_firstname: '',
member_surname: '',
member_street: '',
member_city: '',
member_count: 1,
member_active: true,
})
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value, type } = e.target
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : value,
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
if (!formData.member_firstname || !formData.member_surname || !formData.member_street || !formData.member_city) {
throw new Error('Please fill in all required fields')
}
await createMember(formData)
onSuccess()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create member')
} finally {
setLoading(false)
}
}
return (
<div className="modal d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Add New Member</h5>
<button
type="button"
className="btn-close"
onClick={onCancel}
style={{ filter: 'invert(0)' }}
></button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
{error && (
<div className="alert alert-danger" style={{ borderLeft: '4px solid #e74c3c' }}>
{error}
</div>
)}
<div className="row mb-3">
<div className="col-md-6">
<label className="form-label">
First Name <span style={{ color: '#e74c3c' }}>*</span>
</label>
<input
type="text"
className="form-control"
name="member_firstname"
value={formData.member_firstname || ''}
onChange={handleChange}
required
/>
</div>
<div className="col-md-6">
<label className="form-label">
Surname <span style={{ color: '#e74c3c' }}>*</span>
</label>
<input
type="text"
className="form-control"
name="member_surname"
value={formData.member_surname || ''}
onChange={handleChange}
required
/>
</div>
</div>
<div className="mb-3">
<label className="form-label">Email</label>
<input
type="email"
className="form-control"
name="member_email"
value={formData.member_email || ''}
onChange={handleChange}
/>
</div>
<div className="row mb-3">
<div className="col-md-8">
<label className="form-label">
Street <span style={{ color: '#e74c3c' }}>*</span>
</label>
<input
type="text"
className="form-control"
name="member_street"
value={formData.member_street || ''}
onChange={handleChange}
required
/>
</div>
<div className="col-md-4">
<label className="form-label">Street Number</label>
<input
type="text"
className="form-control"
name="member_streetnumber"
value={formData.member_streetnumber || ''}
onChange={handleChange}
/>
</div>
</div>
<div className="row mb-3">
<div className="col-md-4">
<label className="form-label">ZIP Code</label>
<input
type="number"
className="form-control"
name="member_zip"
value={formData.member_zip || ''}
onChange={handleChange}
/>
</div>
<div className="col-md-8">
<label className="form-label">
City <span style={{ color: '#e74c3c' }}>*</span>
</label>
<input
type="text"
className="form-control"
name="member_city"
value={formData.member_city || ''}
onChange={handleChange}
required
/>
</div>
</div>
<div className="row mb-3">
<div className="col-md-6">
<label className="form-label">Member Count</label>
<input
type="number"
className="form-control"
name="member_count"
value={formData.member_count || 1}
onChange={handleChange}
min="1"
/>
</div>
<div className="col-md-6">
<label className="form-label">Status</label>
<select
className="form-select"
name="member_active"
value={formData.member_active ? 'true' : 'false'}
onChange={handleChange}
>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn"
onClick={onCancel}
style={{
backgroundColor: '#7f8c8d',
color: 'white',
borderRadius: '6px',
}}
>
Cancel
</button>
<button
type="submit"
className="btn"
disabled={loading}
style={{
backgroundColor: '#27ae60',
color: 'white',
borderRadius: '6px',
}}
>
{loading ? '⏳ Creating...' : '✓ Create Member'}
</button>
</div>
</form>
</div>
</div>
</div>
)
}
@@ -0,0 +1,142 @@
import React from 'react'
import { Member } from '../types'
interface MemberModalProps {
member: Member | null
onClose: () => void
}
export const MemberModal: React.FC<MemberModalProps> = ({ member, onClose }) => {
if (!member) return null
return (
<div className="modal d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
{member.member_firstname} {member.member_surname}
</h5>
<button
type="button"
className="btn-close"
onClick={onClose}
style={{ filter: 'invert(0)' }}
></button>
</div>
<div className="modal-body">
<div className="row mb-4">
<div className="col-md-6">
<div className="mb-3">
<label className="text-muted small">ID</label>
<p style={{ fontSize: '1.1rem', fontWeight: 600, color: '#27ae60' }}>{member.id}</p>
</div>
<div className="mb-3">
<label className="text-muted small">First Name</label>
<p style={{ fontSize: '1rem' }}>{member.member_firstname}</p>
</div>
<div className="mb-3">
<label className="text-muted small">Surname</label>
<p style={{ fontSize: '1rem' }}>{member.member_surname}</p>
</div>
<div className="mb-3">
<label className="text-muted small">Email</label>
<p style={{ fontSize: '1rem' }}>{member.member_email || 'N/A'}</p>
</div>
</div>
<div className="col-md-6">
<div className="mb-3">
<label className="text-muted small">Street</label>
<p style={{ fontSize: '1rem' }}>{member.member_street}</p>
</div>
<div className="mb-3">
<label className="text-muted small">ZIP Code</label>
<p style={{ fontSize: '1rem' }}>{member.member_zip || 'N/A'}</p>
</div>
<div className="mb-3">
<label className="text-muted small">City</label>
<p style={{ fontSize: '1rem' }}>{member.member_city}</p>
</div>
<div className="mb-3">
<label className="text-muted small">Status</label>
<p>
<span
className="badge"
style={{
backgroundColor: member.member_active ? '#d4edda' : '#f8d7da',
color: member.member_active ? '#155724' : '#721c24',
fontSize: '0.9rem',
padding: '0.5rem 0.75rem',
}}
>
{member.member_active ? '✓ Active' : '✗ Inactive'}
</span>
</p>
</div>
</div>
</div>
{member.member_firstname_spouse && (
<div className="alert" style={{ backgroundColor: '#eff', borderLeft: '4px solid #3498db' }}>
<strong>Spouse:</strong> {member.member_firstname_spouse}
</div>
)}
{member.member_payments && member.member_payments.length > 0 && (
<div className="mt-4">
<h6 style={{ fontWeight: 600, color: '#2c3e50', marginBottom: '1rem' }}>Recent Payments</h6>
<div className="table-responsive">
<table className="table table-sm">
<thead style={{ backgroundColor: '#f8f9fa' }}>
<tr>
<th>Date</th>
<th>Year</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
{member.member_payments.slice(0, 5).map((payment) => (
<tr key={payment.id}>
<td>{new Date(payment.payment_date).toLocaleDateString()}</td>
<td>{payment.payment_year}</td>
<td style={{ fontWeight: 600, color: '#3498db' }}>
{payment.payment_amount ? payment.payment_amount.toFixed(2) : 'N/A'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
<div className="modal-footer">
<button
type="button"
className="btn"
onClick={onClose}
style={{
backgroundColor: '#7f8c8d',
color: 'white',
borderRadius: '6px',
}}
>
Close
</button>
<button
type="button"
className="btn"
style={{
backgroundColor: '#27ae60',
color: 'white',
borderRadius: '6px',
}}
>
Edit Member
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,109 @@
import React, { useState } from 'react'
import { Member } from '../types'
interface MembersTableProps {
members: Member[]
onViewDetails: (member: Member) => void
loading: boolean
}
export const MembersTable: React.FC<MembersTableProps> = ({ members, onViewDetails, loading }) => {
const [filter, setFilter] = useState('')
const filteredMembers = members.filter(
(m) =>
m.member_firstname.toLowerCase().includes(filter.toLowerCase()) ||
m.member_surname.toLowerCase().includes(filter.toLowerCase()) ||
m.member_email?.toLowerCase().includes(filter.toLowerCase())
)
if (loading) {
return (
<div className="text-center py-5">
<div className="spinner-border" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
)
}
return (
<div>
<div className="mb-4">
<input
type="text"
className="form-control"
placeholder="🔍 Search by name or email..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
style={{ fontSize: '0.95rem' }}
/>
</div>
{filteredMembers.length > 0 ? (
<>
<div className="table-responsive">
<table className="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>First Name</th>
<th>Surname</th>
<th>Email</th>
<th>City</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filteredMembers.map((member) => (
<tr key={member.id}>
<td style={{ fontWeight: 600, color: '#27ae60' }}>{member.id}</td>
<td>{member.member_firstname}</td>
<td>{member.member_surname}</td>
<td style={{ fontSize: '0.9rem', color: '#7f8c8d' }}>{member.member_email}</td>
<td>{member.member_city}</td>
<td>
<span
className="badge"
style={{
backgroundColor: member.member_active ? '#d4edda' : '#f8d7da',
color: member.member_active ? '#155724' : '#721c24',
}}
>
{member.member_active ? '✓ Active' : '✗ Inactive'}
</span>
</td>
<td>
<button
className="btn btn-sm"
onClick={() => onViewDetails(member)}
style={{
backgroundColor: '#27ae60',
color: 'white',
borderRadius: '4px',
padding: '0.4rem 0.8rem',
fontSize: '0.85rem',
fontWeight: 500,
}}
>
View
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-3 text-muted">
Showing <strong>{filteredMembers.length}</strong> of <strong>{members.length}</strong> members
</div>
</>
) : (
<div className="text-center py-5 text-muted">
<p>No members found matching your search</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,52 @@
import React from 'react'
export const Navbar: React.FC = () => {
return (
<>
<nav className="navbar navbar-expand-lg navbar-light border-bottom" style={{ backgroundColor: '#ffffff' }}>
<div className="container-fluid px-4">
<a className="navbar-brand fw-bold" href="/" style={{ fontSize: '1.4rem', color: '#2c3e50' }}>
<span style={{ color: '#27ae60' }}></span> SUST Admin
</a>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
>
<span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navbarNav">
<ul className="navbar-nav ms-auto">
<li className="nav-item">
<a className="nav-link" href="#dashboard" style={{ color: '#555', fontWeight: 500 }}>
Dashboard
</a>
</li>
<li className="nav-item">
<a className="nav-link" href="#members" style={{ color: '#555', fontWeight: 500 }}>
Members
</a>
</li>
<li className="nav-item">
<a className="nav-link" href="#payments" style={{ color: '#555', fontWeight: 500 }}>
Payments
</a>
</li>
<li className="nav-item">
<a className="nav-link" href="#settings" style={{ color: '#555', fontWeight: 500 }}>
Settings
</a>
</li>
</ul>
</div>
</div>
</nav>
<div style={{ backgroundColor: '#f9c74f', padding: '0.75rem 1rem', color: '#333', fontSize: '0.9rem', fontWeight: 500 }}>
<div className="container-fluid px-4">
Last updated: {new Date().toLocaleTimeString()}
</div>
</div>
</>
)
}
@@ -0,0 +1,94 @@
import React, { useState } from 'react'
import { Member, Payment } from '../types'
interface PaymentsTableProps {
members: Member[]
payments: Payment[]
loading: boolean
}
export const PaymentsTable: React.FC<PaymentsTableProps> = ({ members, payments, loading }) => {
const [filter, setFilter] = useState('')
const getMemberName = (memberId: number) => {
const member = members.find((m) => m.id === memberId)
return member ? `${member.member_firstname} ${member.member_surname}` : 'Unknown'
}
const filteredPayments = payments.filter((p) => {
const memberName = getMemberName(p.member_id).toLowerCase()
return memberName.includes(filter.toLowerCase())
})
if (loading) {
return (
<div className="text-center py-5">
<div className="spinner-border" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
)
}
return (
<div>
<div className="mb-4">
<input
type="text"
className="form-control"
placeholder="🔍 Search by member name..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
style={{ fontSize: '0.95rem' }}
/>
</div>
{filteredPayments.length > 0 ? (
<>
<div className="table-responsive">
<table className="table table-hover">
<thead>
<tr>
<th>Payment ID</th>
<th>Member Name</th>
<th>Member ID</th>
<th>Payment Date</th>
<th>Year</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
{filteredPayments.map((payment) => (
<tr key={payment.id}>
<td style={{ fontWeight: 600, color: '#27ae60' }}>{payment.id}</td>
<td>{getMemberName(payment.member_id)}</td>
<td>{payment.member_id}</td>
<td style={{ fontSize: '0.9rem' }}>
{new Date(payment.payment_date).toLocaleDateString()}
</td>
<td>{payment.payment_year}</td>
<td style={{ fontWeight: 600, color: '#3498db' }}>
{payment.payment_amount ? payment.payment_amount.toFixed(2) : '0.00'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-3 text-muted">
Showing <strong>{filteredPayments.length}</strong> of <strong>{payments.length}</strong> payments
{filteredPayments.length > 0 && (
<span className="ms-3">
Total: <strong>{filteredPayments.reduce((sum, p) => sum + (p.payment_amount || 0), 0).toFixed(2)}</strong>
</span>
)}
</div>
</>
) : (
<div className="text-center py-5 text-muted">
<p>No payments found matching your search</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,42 @@
import React from 'react'
interface StatsCardProps {
title: string
count: number
icon: string
color: string
}
export const StatsCard: React.FC<StatsCardProps> = ({ title, count, icon, color }) => {
const colorMap: { [key: string]: string } = {
success: '#27ae60',
warning: '#f39c12',
danger: '#e74c3c',
primary: '#3498db',
}
const bgColor = colorMap[color] || '#3498db'
return (
<div className="col-md-6 col-lg-4 mb-4">
<div
className="card border-0 shadow-sm h-100"
style={{ borderLeft: `4px solid ${bgColor}`, borderRadius: '8px' }}
>
<div className="card-body">
<div className="d-flex justify-content-between align-items-start">
<div>
<p className="text-muted mb-2" style={{ fontSize: '0.9rem', fontWeight: 500 }}>
{title}
</p>
<h3 className="mb-0" style={{ fontSize: '2.5rem', fontWeight: 600, color: '#2c3e50' }}>
{count}
</h3>
</div>
<div style={{ fontSize: '2.5rem', opacity: 0.7 }}>{icon}</div>
</div>
</div>
</div>
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import 'bootstrap/dist/css/bootstrap.min.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+41
View File
@@ -0,0 +1,41 @@
export interface Member {
id: number
member_count: number
member_firstname: string
member_firstname_spouse?: string
member_surname: string
member_suffix?: string
member_street: string
member_streetnumber?: string
member_email?: string
member_zip?: number
member_city: string
member_active: boolean
member_gender?: string
member_connected?: number
member_printed?: boolean
current_state_code?: string
current_state_name?: string
member_deliveries?: Delivery[]
member_payments?: Payment[]
}
export interface Payment {
id: number
payment_date: string
payment_year: string
member_id: number
payment_amount?: number
}
export interface Delivery {
id: number
delivery_date: string
delivered_by_id?: number
}
export interface Stats {
activeCount: number
inactiveCount: number
deactivatedCount: number
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"noEmit": true,
"strict": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
})