Enhancements
This commit is contained in:
@@ -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.
|
||||
@@ -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>
|
||||
Generated
+2158
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user