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
Binary file not shown.

After

Width:  |  Height:  |  Size: 999 KiB

+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/, '')
}
}
}
})
+47
View File
@@ -0,0 +1,47 @@
from fastapi import FastAPI, WebSocket
import asyncio
import logging
app = FastAPI()
# Store active WebSocket connections
connected_clients = []
@app.websocket("/ws/logs")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
connected_clients.append(websocket)
try:
while True:
await websocket.receive_text()
except:
connected_clients.remove(websocket)
# Custom logging handler to send logs via WebSocket
class WebSocketHandler(logging.Handler):
def emit(self, record):
log_message = self.format(record)
# Send to all connected clients
asyncio.create_task(broadcast_log(log_message))
async def broadcast_log(message):
for client in connected_clients[:]: # Copy list to avoid modification during iteration
try:
await client.send_json({
"type": "log",
"message": message,
"timestamp": asyncio.get_event_loop().time()
})
except:
connected_clients.remove(client)
# Set up logging
logger = logging.getLogger("app")
logger.setLevel(logging.INFO)
ws_handler = WebSocketHandler()
logger.addHandler(ws_handler)
@app.get("/test")
async def test_endpoint():
logger.info("This is a test log message from backend!")
return {"message": "Check your browser console"}
+2 -1
View File
@@ -1,5 +1,6 @@
{'maintainer': 'Markus Zimmerberger',
'year': 2025,
'year': 2026,
'yearly_fee': 18,
'map_params': {'metrics': ['euclidean', 'l1', 'manhattan'],
'min_dist': [0.01, 0.1, 0.5],
'spread': 1.0},
+5 -1
View File
@@ -1,5 +1,9 @@
import yaml
import os
def load_config(config_path="./sustApp/config.yaml"):
def load_config(config_path=None):
if config_path is None:
# Resolve path relative to this module's location
config_path = os.path.join(os.path.dirname(__file__), "config.yaml")
with open(config_path, encoding='utf8') as f:
return yaml.load(f, Loader=yaml.FullLoader)
+26 -10
View File
@@ -4,8 +4,8 @@ from time import time
from datetime import datetime
from models import models
from models.database import engine, SessionLocal
# import sustApp.models.models as models
# from sustApp.models.database import engine, SessionLocal
#import sustApp.models.models as models
#from sustApp.models.database import engine, SessionLocal
import os, re
from pytz import timezone
@@ -13,11 +13,12 @@ import pandas as pd
import glob
#import csv
# def Load_Data(file_name):
# with open(source_csv, encoding='utf-8-sig') as f:
# for line in f:
# fields = line.strip().split(';')
# print(fields)
def extract_year_from_reference(reference):
"""Extract year from payment reference (e.g., '2025-4732-163-1' -> '2025')"""
if reference:
year = reference.split('-')[0]
return year
return None
def Load_Data(file_name):
df = pd.read_csv(file_name, delimiter=';', encoding='utf-8-sig', dtype=str, header=None)
@@ -91,11 +92,26 @@ if __name__ == "__main__":
new_list = extract_values(i[1])
if len(new_list) > 0:
outData.append([i[0], new_list[0].get('Auftraggeber'), new_list[0].get('Zahlungsreferenz'), new_list[0].get('Verwendungszweck'), new_list[0].get('IBAN'), i[2], i[3].replace(',','.'), i[4], i[5]])
zahlungsreferenz = new_list[0].get('Zahlungsreferenz')
year = extract_year_from_reference(zahlungsreferenz)
#outData.append([i[0], new_list[0].get('Auftraggeber'), new_list[0].get('Zahlungsreferenz'), new_list[0].get('Verwendungszweck'), new_list[0].get('IBAN'), i[2], i[3].replace(',','.'), i[4], i[5], new_list[0].get('Zahlungsreferenz')])
outData.append([
i[0], # valuta_date
new_list[0].get('Auftraggeber'), # client
zahlungsreferenz, # reference
new_list[0].get('Verwendungszweck'), # intended
new_list[0].get('IBAN'), # IBAN
i[2], # entry_date
i[3].replace(',','.'), # amount
i[4], # currency
i[5], # timestamp
year # year (extracted from reference)
])
# Replace None with empty string before saving
outData = [['' if x is None else x for x in row] for row in outData]
savetxt(target_csv, outData, delimiter=";", encoding='utf-8', header='valuta_date;client;reference;intended;IBAN;entry_date;amount;currency;timestamp', fmt='%s')
savetxt(target_csv, outData, delimiter=";", encoding='utf-8', header='valuta_date;client;reference;intended;IBAN;entry_date;amount;currency;timestamp;year', fmt='%s')
bak_dir = os.path.join(os.path.dirname(source_csv), "bak")
os.makedirs(bak_dir, exist_ok=True)
os.rename(source_csv, os.path.join(bak_dir, os.path.basename(source_csv)))
+152 -40
View File
@@ -1,18 +1,28 @@
import sustApp.models.models as models
import models.models as models
from models.database import engine, SessionLocal
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from segno import helpers
from reportlab.lib import colors
from reportlab.lib.pagesizes import letter
from reportlab.platypus import Table, TableStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
import datetime
import locale
import os
from config_loader import load_config
from salutation_helpers import get_gender_salutation, get_pronouns
cfg = load_config()
year = cfg['year']
yearly_fee = cfg['yearly_fee']
MM_TO_POINTS = 72 / 25.4 # conversion factor
offset_2mm = 2 * MM_TO_POINTS
offset_1mm = MM_TO_POINTS
def print_multiple_lines(lines: int, text):
cnt = 1
@@ -25,27 +35,31 @@ def create_payment_slip(member: models.Members = None,
locale.setlocale(locale.LC_ALL, '')
salutation = "Deine"
salutation2 = "Dein"
salutation3 = "Dich"
salutation4 = "Solltest Du"
salutation5 = "sende"
# pronouns = get_pronouns(is_plural=False)
# salutation = pronouns['possessive']
# salutation2 = pronouns['neutral']
# salutation3 = pronouns['accusative']
# salutation4 = pronouns['conditional']
# salutation5 = pronouns['verb']
subfolder = ""
w, h = A4
db = SessionLocal()
# create epc qr-code
if not member is None:
qrcode = helpers.make_epc_qr(name='Sportunion St. Thomas',
iban='AT493443700000113407',
amount=15*member.member_count,
amount=yearly_fee*member.member_count,
reference=f'{year}-4732-{member.id:03}-{member.member_count}')
qrcode.save("epc_qr.png", scale=2)
subfolder = "mail" if member.member_email else ""
fullfilepath = os.path.join(os.path.expanduser("~"),"sust","payment_slips",subfolder,f"{member.member_zip}_payment_slip_member{member.id}.pdf")
fullfilepath = os.path.join(os.path.expanduser("~"),"sust",f"payment_slips_{year}",subfolder,f"{member.member_zip}_payment_slip_member{member.id}.pdf")
else:
fullfilepath = os.path.join(os.path.expanduser("~"),"sust","payment_slips",subfolder,f"4732_payment_slip_blanko.pdf")
fullfilepath = os.path.join(os.path.expanduser("~"),"sust",f"payment_slips_{year}",subfolder,f"4732_payment_slip_blanko.pdf")
# Ensure the directory exists
os.makedirs(os.path.dirname(fullfilepath), exist_ok=True)
@@ -53,10 +67,12 @@ def create_payment_slip(member: models.Members = None,
c = canvas.Canvas(fullfilepath, pagesize=A4)
if not member is None:
c.drawImage(image="epc_qr.png", x=500, y=40) # epc qr
c.drawImage(image="epc_qr.png", x=500-offset_2mm, y=40+offset_2mm) # epc qr
#c.drawImage(image="Sportunion quer 4c.png", x=(w-180), y=(h-100), width=150, height=50, mask='auto')
image_path = os.path.join(os.path.dirname(__file__), "Sportunion quer 4c.png")
c.drawImage(image=image_path, x=(w-180), y=(h-100), width=150, height=50, mask='auto')
c.drawImage(image=os.path.join(os.path.dirname(__file__), "qr-code_scannen_v3.png"), x=(w-160), y=(h-570), width=140, height=140, mask='auto')
c.setFont("Helvetica", 9)
text = c.beginText(60, h - 150)
@@ -68,24 +84,30 @@ def create_payment_slip(member: models.Members = None,
text.textLine(f"{member.member_street} {member.member_streetnumber}")
text.textLine(f"{member.member_zip} {member.member_city}")
print_multiple_lines(5, text)
if member.member_count > 1:
salutation = "eure"
salutation2 = "euer"
salutation3 = "euch"
salutation4 = "Solltet ihr"
salutation5 = "sendet"
text.textLine(f"Liebe {member.member_firstname_spouse} und lieber {member.member_firstname}!")
pronouns = get_pronouns(is_plural=True)
salutation = pronouns['possessive']
salutation2 = pronouns['neutral']
salutation3 = pronouns['accusative']
salutation4 = pronouns['conditional']
salutation5 = pronouns['verb']
# Get spouse's salutation based on their gender
spouse_salutation = get_gender_salutation(str(member.spouse.member_gender), capitalize=False)
member_salutation = get_gender_salutation(str(member.member_gender), capitalize=False)
text.textLine(f"{spouse_salutation.capitalize()} {member.spouse.member_firstname} und {member_salutation} {member.member_firstname}!")
else:
salutation = "Deine"
salutation2 = "Dein"
salutation3 = "Dich"
salutation4 = "Solltest Du"
salutation5 = "sende"
if member.member_gender == "female":
salutation_gender = "liebe"
else:
salutation_gender = "lieber"
text.textLine(f"{salutation_gender.capitalize()} {member.member_firstname}!")
pronouns = get_pronouns(is_plural=False)
salutation = pronouns['possessive']
salutation2 = pronouns['neutral']
salutation3 = pronouns['accusative']
salutation4 = pronouns['conditional']
salutation5 = pronouns['verb']
member_salutation = get_gender_salutation(str(member.member_gender), capitalize=True)
text.textLine(f"{member_salutation} {member.member_firstname}!")
else:
print_multiple_lines(10, text)
@@ -95,7 +117,7 @@ def create_payment_slip(member: models.Members = None,
text.textLines(f"Vielen Dank für {salutation} Treue!\n\n{salutation2.capitalize()} Beitrag ist gut angelegt, denn der Sportverein St. Thomas bietet für jede(n) etwas:")
print_multiple_lines(2, text)
text.textLines("* Skigymnastik\n* Radfahren\n* Stockschiessen\n* Pilates\n* Tagesskifahrt\n* uvam")
print_multiple_lines(5, text)
print_multiple_lines(2, text)
if not forMail:
text.textLines(f"Wir möchten das Zustellen des Mitgliedsbeitrags-Zahlscheines zuküntig auf Mail umstellen, daher bitten wir {salutation3},\nuns eine Mail mit dem Betreff \"Zahlschein\" an unsere Mail-Adresse sv.stthomas@gmx.at zu senden!")
else:
@@ -104,23 +126,29 @@ def create_payment_slip(member: models.Members = None,
text.textLines(f"{salutation2.capitalize()} Sportverein-Team!")
c.drawText(text)
text = c.beginText(60, h - 500)
text = c.beginText(60, h - 520)
c.setFont("Helvetica", 7)
text.textLines(f"Auf Grund der DSGVO müssen wir darauf hinweisen, dass wir außer Name, Adresse, Mail-Adresse\nzum Zwecke der Einebung des Mitgliedsbeitrags, keine persönlichen Daten unserer Mitglieder führen!\nDiese Daten werden von uns nicht an Dritte weitergegeben!\nWeiters informieren wir darüber, dass Fotos, die bei Veranstaltungen oder Aktivitäten entstehen,\nauf unserer Homepage oder in Zeitungen veröffentlicht werden können.\n{salutation4} gegen die Speicherung {salutation}r Daten oder die Veröffentlichung diverser Foto sein, {salutation5} uns bitte eine Mail.")
c.drawText(text)
if forMail:
text = c.beginText(60, h - 580)
c.setFont("Helvetica", 7)
text.textLines(f"IBAN: AT49 3443 7000 0011 3407\nBIC: RZOOAT2L437\nBank: Raiffeisenbank Prambachkirchen, Bankstelle Waizenkirchen")
c.drawText(text)
# payment slip details
text = c.beginText(11, 75)
text = c.beginText(11 - offset_2mm, 75 + offset_1mm)
c.setFont("Helvetica", 8)
if not member is None:
text.textLine(f"Beitrag gesamt: {locale.format_string('%.2f', 15 * member.member_count)}")
text.textLine(f"Beitrag gesamt: {locale.format_string('%.2f', yearly_fee * member.member_count)}")
text.textLine(f"Mitgliedsnummer: 4732-{member.id:03}")
else:
text.textLine(f"Beitrag gesamt: {locale.format_string('%.2f', 15)}")
text.textLine(f"Beitrag gesamt: {locale.format_string('%.2f', yearly_fee)}")
text.textLine(f"Mitgliedsnummer: 4732-NEU")
c.drawText(text)
text = c.beginText(188, 103)
text = c.beginText(188 - offset_2mm, 103 + offset_2mm)
c.setFont("Helvetica", 8)
if not member is None:
text.textLine(f"Bei Online-Zahlung bitte die Zahlungsreferenz {year}-4732-{member.id:03}-{member.member_count} eintragen")
@@ -132,12 +160,96 @@ def create_payment_slip(member: models.Members = None,
c.drawText(text)
c.setFont("Helvetica", 12)
c.drawString(77, 50, f"{year}")
c.drawString(300, 120, f"{year}")
c.drawString(300 - offset_2mm, 120 + offset_1mm, f"{year}") # Zahlungsanweisung: Jahr
c.drawString(77 - offset_2mm, 50 + offset_1mm, f"{year}") # Auftragsbestätigung: Jahr
c.setFont("Helvetica", 10)
c.drawString(188, h - 696, f'{year}-4732-{member.id:03}-{member.member_count}') # Zahlungsreferenz Zahlungsanweisung
c.drawString(11, 120, f'{year}-4732-{member.id:03}-{member.member_count}') # Zahlungsreferenz Auftragsbestätigung
c.drawString(130, h - 694, (locale.format_string('%.2f', 15 * member.member_count))) # Betrag Auftragsbestätigung
c.drawString(w - 30, 170, (locale.format_string('%.2f', 15 * member.member_count))) # Betrag Zahlungsanweisung
c.drawString(188 - offset_2mm, h - 696 + offset_1mm, f'{year}-4732-{member.id:03}-{member.member_count}') # Zahlungsanweisung: Zahlungsreferenz
c.drawString(w - 30 - offset_2mm, 170 + offset_2mm, (locale.format_string('%.2f', yearly_fee * member.member_count))) # Zahlungsanweisung: Betrag
c.drawString(11 - offset_2mm, 120 + offset_2mm, f'{year}-4732-{member.id:03}-{member.member_count}') # Auftragsbestätigung: Zahlungsreferenz
c.drawString(130 - offset_2mm, h - 694 + offset_1mm, (locale.format_string('%.2f', yearly_fee * member.member_count))) # Auftragsbestätigung: Betrag
#TODO: config-switch
# print payment-history
payments = db.query(models.MembersPaidYears).filter(
models.MembersPaidYears.id == member.id).first()
if payments:
# View returns 'X' for paid, 'N' for not applicable, NULL for not paid
paid_2024 = payments.paid_2024 == 'X'
paid_2025 = payments.paid_2025 == 'X'
paid_2026 = payments.paid_2026 == 'X'
# Determine display status
status_2024 = '' if paid_2024 else ('kein Mitglied' if payments.paid_2024 == 'N' else '')
status_2025 = '' if paid_2025 else ('kein Mitglied' if payments.paid_2025 == 'N' else '')
status_2026 = '' if paid_2026 else ('kein Mitglied' if payments.paid_2026 == 'N' else '')
else:
status_2024 = ''
status_2025 = ''
status_2026 = ''
data = [
[f'{salutation.capitalize()} Beitragshistorie', ''],
['2024', '2025', '2026'],
[status_2024, status_2025, status_2026],
]
# 2. Create the Table object
table = Table(data, colWidths=[100, 100], rowHeights=[16, 13, 13])
# Apply styling - use string alignment constants
style = TableStyle([
# Span first row across all columns
('SPAN', (0, 0), (2, 0)),
# Title row styling
('ALIGN', (0, 0), (2, 0), 'CENTER'), # Changed from TA_CENTER to 'CENTER'
('VALIGN', (0, 0), (2, 0), 'MIDDLE'),
('FONTNAME', (0, 0), (2, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (2, 0), 11),
('BACKGROUND', (0, 0), (2, 0), colors.HexColor("#168ef1")),
('TEXTCOLOR', (0, 0), (2, 0), colors.whitesmoke),
('BOTTOMPADDING', (0, 0), (2, 0), 8),
('TOPPADDING', (0, 0), (2, 0), 8),
# Header row styling (2024, 2025)
('ALIGN', (0, 1), (2, 1), 'CENTER'), # Changed from TA_CENTER to 'CENTER'
('VALIGN', (0, 1), (2, 1), 'MIDDLE'),
('FONTNAME', (0, 1), (2, 1), 'Helvetica-Bold'),
('FONTSIZE', (0, 1), (2, 1), 9),
('BACKGROUND', (0, 1), (2, 1), colors.HexColor('#e8e8e8')),
('BOTTOMPADDING', (0, 1), (2, 1), 6),
('TOPPADDING', (0, 1), (2, 1), 6),
# Data row styling
('ALIGN', (0, 2), (2, 2), 'CENTER'), # Changed from TA_CENTER to 'CENTER'
('VALIGN', (0, 2), (2, 2), 'MIDDLE'),
('FONTNAME', (0, 2), (2, 2), 'Helvetica'),
('FONTSIZE', (0, 2), (2, 2), 10),
('BACKGROUND', (0, 2), (2, 2), colors.white),
('BOTTOMPADDING', (0, 2), (2, 2), 6),
('TOPPADDING', (0, 2), (2, 2), 6),
# Color cells based on payment status
# Green for ✓, Red for ✗, Grey for N (not applicable)
('TEXTCOLOR', (0, 2), (0, 2),
colors.HexColor('#006400') if status_2024 == ''
else (colors.HexColor('#808080') if status_2024 == 'kein Mitglied' else colors.HexColor('#dc143c'))),
('TEXTCOLOR', (1, 2), (1, 2),
colors.HexColor('#006400') if status_2025 == ''
else (colors.HexColor('#808080') if status_2025 == 'kein Mitglied' else colors.HexColor('#dc143c'))),
('TEXTCOLOR', (2, 2), (2, 2),
colors.HexColor('#006400') if status_2026 == ''
else (colors.HexColor('#808080') if status_2026 == 'kein Mitglied' else colors.HexColor('#dc143c'))),
# Grid styling
('GRID', (0, 0), (2, -1), 0.5, colors.grey),
])
table.setStyle(style)
# 4. Wrap and Draw on the Canvas
# You must call wrap() to calculate the size before drawing
width, height = table.wrapOn(c, 0, 0)
table.drawOn(c, 60, h - 500) # (canvas, x, y) coordinate from bottom-left
db.close()
c.save()
+202
View File
@@ -0,0 +1,202 @@
import models.models as models
from reportlab.lib.pagesizes import A4
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from segno import helpers
from payment_report import get_payment_table_element
import datetime
import locale
import os
from config_loader import load_config
from salutation_helpers import get_gender_salutation, get_pronouns
cfg = load_config()
year = cfg['year']
yearly_fee = cfg['yearly_fee']
MM_TO_POINTS = 72 / 25.4
offset_2mm = 2 * MM_TO_POINTS
offset_1mm = MM_TO_POINTS
def create_payment_slip(member: models.Members = None,
forMail: bool = False):
locale.setlocale(locale.LC_ALL, '')
w, h = A4
# Create EPC QR code
if not member is None:
qrcode = helpers.make_epc_qr(
name='Sportunion St. Thomas',
iban='AT493443700000113407',
amount=yearly_fee * member.member_count,
reference=f'{year}-4732-{member.id:03}-{member.member_count}'
)
qrcode.save("epc_qr.png", scale=2)
subfolder = "mail" if member.member_email else ""
fullfilepath = os.path.join(
os.path.expanduser("~"),
"sust",
f"payment_slips_{year}",
subfolder,
f"{member.member_zip}_payment_slip_member{member.id}.pdf"
)
else:
fullfilepath = os.path.join(
os.path.expanduser("~"),
"sust",
f"payment_slips_{year}",
"",
f"4732_payment_slip_blanko.pdf"
)
os.makedirs(os.path.dirname(fullfilepath), exist_ok=True)
# Create PDF with Platypus
doc = SimpleDocTemplate(fullfilepath, pagesize=A4, topMargin=15*mm, bottomMargin=15*mm)
elements = []
styles = getSampleStyleSheet()
# Custom styles
header_style = ParagraphStyle(
'CustomHeader',
parent=styles['Normal'],
fontSize=11,
leading=14
)
body_style = ParagraphStyle(
'CustomBody',
parent=styles['Normal'],
fontSize=9,
leading=12
)
small_style = ParagraphStyle(
'CustomSmall',
parent=styles['Normal'],
fontSize=7,
leading=9
)
# Logo and header
image_path = os.path.join(os.path.dirname(__file__), "Sportunion quer 4c.png")
from reportlab.platypus import Image
logo = Image(image_path, width=150*mm/25.4, height=50*mm/25.4)
elements.append(logo)
elements.append(Spacer(1, 5*mm))
# Address and salutation
if not member is None:
if member.member_count > 1:
address_line1 = f"{member.member_firstname_spouse} u. {member.member_firstname} {member.member_surname}"
else:
address_line1 = f"{member.member_firstname} {member.member_surname} {member.member_suffix}"
address_text = f"""
{address_line1}<br/>
{member.member_street} {member.member_streetnumber}<br/>
{member.member_zip} {member.member_city}
"""
elements.append(Paragraph(address_text, header_style))
elements.append(Spacer(1, 5*mm))
# Salutation
if member.member_count > 1:
spouse_salutation = get_gender_salutation(str(member.spouse.member_gender), capitalize=False)
member_salutation = get_gender_salutation(str(member.member_gender), capitalize=False)
salutation_text = f"{spouse_salutation.capitalize()} {member.spouse.member_firstname} und {member_salutation} {member.member_firstname}!"
pronouns = get_pronouns(is_plural=True)
salutation = pronouns['possessive']
salutation2 = pronouns['neutral']
salutation3 = pronouns['accusative']
salutation4 = pronouns['conditional']
salutation5 = pronouns['verb']
else:
member_salutation = get_gender_salutation(str(member.member_gender), capitalize=True)
salutation_text = f"{member_salutation} {member.member_firstname}!"
pronouns = get_pronouns(is_plural=False)
salutation = pronouns['possessive']
salutation2 = pronouns['neutral']
salutation3 = pronouns['accusative']
salutation4 = pronouns['conditional']
salutation5 = pronouns['verb']
elements.append(Paragraph(salutation_text, body_style))
elements.append(Spacer(1, 3*mm))
# Main text
main_text = f"""
Vielen Dank für {salutation} Treue!<br/><br/>
{salutation2.capitalize()} Beitrag ist gut angelegt, denn der Sportverein St. Thomas bietet für jede(n) etwas:<br/>
<br/>
• Skigymnastik<br/>
• Radfahren<br/>
• Stockschiessen<br/>
• Pilates<br/>
• Tagesskifahrt<br/>
• uvam
"""
elements.append(Paragraph(main_text, body_style))
elements.append(Spacer(1, 3*mm))
# Email request
if not forMail:
email_text = f"""
Wir möchten das Zustellen des Mitgliedsbeitrags-Zahlscheines zuküntig auf Mail umstellen,
daher bitten wir {salutation3}, uns eine Mail mit dem Betreff "Zahlschein" an unsere
Mail-Adresse sv.stthomas@gmx.at zu senden!
"""
elements.append(Paragraph(email_text, body_style))
elements.append(Spacer(1, 3*mm))
closing_text = f"{salutation2.capitalize()} Sportverein-Team!"
elements.append(Paragraph(closing_text, body_style))
# GDPR disclaimer
elements.append(Spacer(1, 5*mm))
gdpr_text = f"""
Auf Grund der DSGVO müssen wir darauf hinweisen, dass wir außer Name, Adresse, Mail-Adresse
zum Zwecke der Einebung des Mitgliedsbeitrags, keine persönlichen Daten unserer Mitglieder führen!
Diese Daten werden von uns nicht an Dritte weitergegeben!
Weiters informieren wir darüber, dass Fotos, die bei Veranstaltungen oder Aktivitäten entstehen,
auf unserer Homepage oder in Zeitungen veröffentlicht werden können.
{salutation4} gegen die Speicherung {salutation}r Daten oder die Veröffentlichung diverser Foto sein, {salutation5} uns bitte eine Mail.
"""
elements.append(Paragraph(gdpr_text, small_style))
# Payment details table (right aligned)
if not member is None:
amount = locale.format_string('%.2f', yearly_fee * member.member_count)
member_number = f"4732-{member.id:03}"
payment_reference = f"{year}-4732-{member.id:03}-{member.member_count}"
payment_details = f"""
<b>Beitrag gesamt:</b> {amount}<br/>
<b>Mitgliedsnummer:</b> {member_number}<br/>
<b>Zahlungsreferenz:</b> {payment_reference}<br/>
"""
elements.append(Spacer(1, 5*mm))
elements.append(Paragraph(payment_details, body_style))
# Page break before payment table
elements.append(PageBreak())
# Add payment summary table
# payment_table = get_payment_table_element()
# if payment_table:
# elements.append(Paragraph("Zahlungsübersicht", styles['Heading2']))
# elements.append(Spacer(1, 5*mm))
# elements.append(payment_table)
# Build PDF
doc.build(elements)
print(f"✅ Payment slip created: {fullfilepath}")
+111
View File
@@ -0,0 +1,111 @@
-- 2. Migrate spouse data to new member records
INSERT INTO sust.members (
member_count, member_firstname, member_surname, member_suffix,
member_street, member_streetnumber, member_email, member_zip,
member_city, member_active, member_gender, member_died, member_printed,
member_coordinates, member_geo_latitude, member_geo_longitude,
member_membership_start, member_phone, current_state_code,
current_state_changed_at, spouse_id
)
SELECT
1, member_firstname_spouse, member_surname, member_suffix,
member_street, member_streetnumber,
member_email || '_partner' as member_email,
member_zip, member_city, member_active, 'female', member_died,
member_printed, member_coordinates, member_geo_latitude,
member_geo_longitude, member_membership_start,
member_phone || '_partner' as member_phone,
current_state_code, current_state_changed_at, id
FROM sust.members
WHERE member_firstname_spouse IS NOT NULL AND member_firstname_spouse != '' and members.id = 93
RETURNING id, spouse_id;
-- 3. Copy section memberships to new spouse members
INSERT INTO sust.member_sections (member_id, section_id)
SELECT
new_spouse.id, ms.section_id
FROM sust.member_sections ms
JOIN sust.members orig ON ms.member_id = orig.id
JOIN sust.members new_spouse ON new_spouse.spouse_id = orig.id
WHERE new_spouse.spouse_id IS NOT NULL
ON CONFLICT (member_id, section_id) DO NOTHING;
-- 5. Update primary members with spouse links
UPDATE sust.members m1
SET spouse_id = m2.id
FROM sust.members m2
WHERE m2.spouse_id = m1.id and m2.id = 93;
-- Add ONLY spouses of members ALREADY in event_attends for this event
INSERT INTO sust.event_attends (event_id, member_id, notes)
SELECT 1, m.spouse_id, 'Spouse of event attendee - all 3 days (Sylvia)'
FROM sust.members m
JOIN sust.event_attends ea ON m.id = ea.member_id
WHERE m.spouse_id IS NOT NULL
AND ea.event_id = 1
AND NOT EXISTS (
SELECT 1 FROM sust.event_attends ea2
WHERE ea2.event_id = 1 AND ea2.member_id = m.spouse_id
);
-- Check primary vs spouse attendees for event
SELECT
ea.notes,
COUNT(*) as count,
string_agg(m.member_firstname || ' ' || m.member_surname, ', ' ORDER BY m.member_surname) as names
FROM sust.event_attends ea
JOIN sust.members m ON ea.member_id = m.id
WHERE ea.event_id = 1
GROUP BY ea.notes
ORDER BY ea.notes;
-- Add birthday column to members table
ALTER TABLE sust.members
ADD COLUMN member_birthday date NULL;
-- Add index for birthday queries
CREATE INDEX ix_members_member_birthday ON sust.members USING btree (member_birthday);
-- Optional: Add check constraint for valid dates
ALTER TABLE sust.members
ADD CONSTRAINT members_birthday_check
CHECK (member_birthday IS NULL OR member_birthday < CURRENT_DATE);
-- Much simpler: List households with attendee count + email
SELECT
MIN(m.member_firstname || ' ' || m.member_surname) AS household_representative,
MIN(m.member_surname) AS surname,
COUNT(DISTINCT ea.member_id) AS attendees,
STRING_AGG(
DISTINCT
CASE
WHEN m.member_birthday IS NOT NULL
THEN m.member_firstname || ' (' || TO_CHAR(m.member_birthday, 'DD.MM.YYYY') || ')'
ELSE m.member_firstname
END,
', '
) AS attendees_birthdays,
MIN(
TRIM(
COALESCE(m.member_street, '') || ' ' || COALESCE(m.member_streetnumber, '') ||
', ' || COALESCE(m.member_zip::text, '') || ' ' || COALESCE(m.member_city, '')
)
) AS address,
MIN(m.member_email) AS email -- household email (one per household)
FROM sust.event_attends ea
JOIN sust.members m ON ea.member_id = m.id
WHERE ea.event_id = 1
GROUP BY
LEAST(
m.id,
COALESCE(
(SELECT spouse_id FROM sust.members WHERE id = m.id),
m.id
)
),
m.member_surname
ORDER BY m.member_surname;
update members set member_primary = false where id in (217,221);
select * from payments where member_id = 119
+28 -7
View File
@@ -25,12 +25,12 @@ update members set member_city = 'Sankt Thomas' where member_city = 'St. Thomas'
update members set member_city = trim(member_city)
insert into deliveries (delivery_date, member_id, delivered_by_id, delivery_year)
values (now(), 204, 186, '2025-01-01') --,
(now(), 79, 186, '2024-01-01'),
(now(), 3, 186, '2024-01-01'),
(now(), 4, 186, '2024-01-01'),
(now(), 9, 186, '2024-01-01')
values (now(), 126, 186, '2026-01-01'),
(now(), 176, 186, '2026-01-01'),
(now(), 4, 186, '2026-01-01'),
(now(), 128, 186, '2026-01-01'),
(now(), 18, 186, '2026-01-01')
insert into deliveries (delivery_date, member_id, delivered_by_id, delivery_year)
values (now(), 8, 186, '2024-02-21')
@@ -115,7 +115,7 @@ WHERE m.id IN (
EXCEPT
SELECT mp.id
FROM members_paid mp
WHERE mp.payment_year IN ('2024-01-01', '2025-01-01')
WHERE mp.payment_year IN ('2025-01-01')
)
ORDER BY m.member_surname, m.member_firstname, d.delivery_date ASC;
@@ -149,3 +149,24 @@ WHERE m.id NOT IN (
AND mp.id IS NOT NULL
)
ORDER BY m.member_surname, m.member_firstname;
-- number of active/inactive/deactivated members
SELECT SUM(m.member_count)
FROM members m
JOIN member_states s ON m.current_state_code = s.state_code
WHERE s.state_code = 'I';
SELECT *
FROM members m
JOIN member_states s ON m.current_state_code = s.state_code
WHERE s.state_code = 'I';
update members m
set member_count = 1
--JOIN member_states s ON m.current_state_code = s.state_code
WHERE current_state_code = 'I' and m.member_count =0;
create trigger update_members_updated_on_membership_end before
update
of member_membership_end on
sust.members for each row execute function update_active_on_members_death()
+33 -19
View File
@@ -1,18 +1,25 @@
# Importing libraries
from numpy import genfromtxt
#from numpy import genfromtxt
import pandas as pd
from time import time
from datetime import datetime
from datetime import date, datetime
from models import models
from models.database import engine, SessionLocal
import os, glob
from pytz import timezone
def Load_Data(file_name):
""" def Load_Data(file_name):
#data = genfromtxt(file_name, delimiter=';', skip_header=1, converters={0: lambda s: str(s)})
#str2date = lambda x: datetime.strptime(x, '%d-%m-%y')
data = genfromtxt(file_name, delimiter=';', dtype=None, skip_header=1, encoding='utf-8') #
return data.tolist()
"""
#use pandas instead of genfromtxt for better performance and handling of csv files
def Load_Data(file_name):
df = pd.read_csv(file_name, delimiter=',', encoding='utf-8')
return df.values.tolist()
if __name__ == "__main__":
t = time()
@@ -25,24 +32,31 @@ if __name__ == "__main__":
source_path = os.path.join(home_directory, "OneDrive", "Dokumente", "SUST", "import_csv", "*_out.csv")
for source_csv in glob.glob(source_path):
#source_csv = os.path.join(home_directory, "OneDrive", "Dokumente", "SUST", "UmsatzListe_AT493443700000113407_20250529_091945_mod_out.csv")
data = Load_Data(source_csv)
datas = Load_Data(source_csv)
tz = timezone('UTC') # Replace with your desired timezone
dt = datetime.strptime(data[8], '%d.%m.%Y %H:%M:%S:%f') # type: ignore
ts_timestamp = tz.localize(dt)
for data in datas:
dt = datetime.strptime(data[8], '%d.%m.%Y %H:%M:%S:%f') # type: ignore
ts_timestamp = tz.localize(dt)
record = models.Payments_CSV(**{
'pc_valuta_date' : datetime.strptime(data[0], '%d.%m.%Y').date(),
'pc_client' : data[1],
'pc_reference' : data[2],
'pc_intended' : data[3],
'pc_iban' : data[4],
'pc_entry_date' : datetime.strptime(data[5], '%d.%m.%Y').date(),
'pc_amount' : float(data[6]),
'pc_currency' : data[7],
'pc_timestamp' : ts_timestamp
})
db.add(record) #Add all the records
# Parse year - data[9] is now the year (from convert_payment_export_csv.py)
# Convert year string to a valid date (January 1st of that year)
year_value = data[9] # This is '2025' from the CSV
pc_year = date(int(year_value), 1, 1) # Convert to date: 2025-01-01
#TODO: Check if amout is valid!
record = models.Payments_CSV(**{
'pc_valuta_date' : datetime.strptime(data[0], '%d.%m.%Y').date(),
'pc_client' : data[1],
'pc_reference' : data[2],
'pc_intended' : data[3],
'pc_iban' : data[4],
'pc_entry_date' : datetime.strptime(data[5], '%d.%m.%Y').date(),
'pc_amount' : float(data[6]),
'pc_currency' : data[7],
'pc_timestamp' : ts_timestamp,
'pc_year' : pc_year
})
db.add(record) #Add all the records
try:
db.commit() #Attempt to commit all the records
+79
View File
@@ -0,0 +1,79 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/react-frontend/src",
"sourceMaps": true
},
{
"name": "Python: Current File",
"type": "python",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": true,
"cwd": "${workspaceFolder}/sustApp"
},
{
"name": "Python: print_payment_slips.py",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/sustApp/print_payment_slips.py",
"console": "integratedTerminal",
"justMyCode": true,
"cwd": "${workspaceFolder}/sustApp",
"env": {
"PYTHONPATH": "${workspaceFolder}"
}
},
{
"name": "Python: FastAPI (uvicorn)",
"type": "python",
"request": "launch",
"module": "uvicorn",
"args": [
"main:app",
"--reload",
"--host",
"127.0.0.1",
"--port",
"8000"
],
"console": "integratedTerminal",
"justMyCode": true,
"cwd": "${workspaceFolder}/sustApp",
"env": {
"PYTHONPATH": "${workspaceFolder}"
}
},
{
"name": "Python: convert_payment_export_csv.py",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/sustApp/convert_payment_export_csv.py",
"console": "integratedTerminal",
"justMyCode": true,
"cwd": "${workspaceFolder}/sustApp",
"env": {
"PYTHONPATH": "${workspaceFolder}"
}
},
{
"name": "Python: Debug with Arguments",
"type": "python",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": false,
"cwd": "${workspaceFolder}/sustApp",
"env": {
"PYTHONPATH": "${workspaceFolder}"
},
"args": []
}
]
}
+5 -4
View File
@@ -12,7 +12,7 @@ import json
import time
import logging
from typing import Optional
from sqlalchemy import extract
from sqlalchemy import extract, or_
from models.database import engine, SessionLocal
import models.models as models
import re
@@ -112,7 +112,7 @@ def save_to_postgres(first_name: str, sur_name: str, address: str):
# find by name
db = SessionLocal()
member = db.query(models.Members).filter(
models.Members.member_email == None,
or_(models.Members.member_email.is_(None),models.Members.member_email == ''),
models.Members.member_firstname == first_name,
models.Members.member_surname == sur_name,
).first()
@@ -164,7 +164,6 @@ Dein lokaler Sportverein KI-Assistent
logging.info(f"Bestätigung gesendet an {sender_email}")
#TODO: In DB markieren als bestätigt
#TODO: Re: Mail als gelesen markieren
return True
except Exception as e:
@@ -176,7 +175,7 @@ def main():
while True:
try:
mail = connect_imap()
status, messages = mail.search(None, 'UNSEEN SUBJECT "Zahlschein"') # Nur neue mit Subject![web:11][web:20]
_, messages = mail.search(None, 'UNSEEN', 'SUBJECT', 'zahlschein') # Nur neue mit Subject![web:11][web:20]
mail_ids = messages[0].split()
for mail_id in mail_ids: # [-5:] letzte 5 neue
@@ -215,6 +214,8 @@ def main():
mail.store(mail_id, '+FLAGS', '\\Seen')
mail.select('INBOX', readonly=True)
# TODO: Move thread to SUST-Folder
# Send confirmation reply
msg_id = msg['Message-ID']
subject = msg['Subject'] or ''
+39 -11
View File
@@ -1,6 +1,6 @@
from sqlalchemy import extract
from sustApp.models.database import engine, SessionLocal
import sustApp.models.models as models
from models.database import engine, SessionLocal
import models.models as models
import logging, sys
import smtplib
from email.mime.multipart import MIMEMultipart
@@ -11,10 +11,17 @@ import datetime
from create_payment_slip import create_payment_slip
import os
from config_loader import load_config
import argparse
from salutation_helpers import get_gender_salutation, get_pronouns
cfg = load_config()
year = cfg['year']
# Parse CLI arguments
parser = argparse.ArgumentParser(description='Send payment slip emails to members')
parser.add_argument('--resend', action='store_true', help='Send email again even if already mailed')
args = parser.parse_args()
logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
@@ -28,14 +35,14 @@ def payment_slip_paid(member: models.Members, year: int):
.count()
def send_mail(member: models.Members):
def send_mail(member: models.Members, force_resend: bool = False):
paid = payment_slip_paid(member, year)
if paid:
logging.debug(f'Member: {member.member_surname} already paid!')
return
delivered = payment_slip_delivered(member, year)
if delivered:
if delivered and not force_resend:
logging.debug(f'Member: {member.member_surname} already mailed or delivered!')
return
@@ -43,12 +50,29 @@ def send_mail(member: models.Members):
smtp_port = 587
smtp_username = 'zisco@gmx.at'
#TODO Secure
smtp_password = '57spunk07'
smtp_password = '[Jy010]Q&7te2qR&2x0d'
from_email = 'sv.stthomas@gmx.at'
to_email = member.member_email
subject = f'Mitgliedsbeitrag {year}'
body = f'Liebes Mitglied,\n\nDein Zahlschein für {year} liegt bei.\nDu kannst gerne mittels beiliegendem, aufgedrucktem QR-Code überweisen.\nDanke sagt Dein Team vom Sportverein!'
is_plural = bool(member.member_count > 1)
pronouns = get_pronouns(is_plural=is_plural)
salutation = pronouns['possessive']
salutation2 = pronouns['neutral']
salutation3 = pronouns['accusative']
salutation4 = pronouns['conditional']
salutation5 = pronouns['verb']
# Get spouse's salutation based on their gender
if is_plural and member.spouse:
spouse_salutation = get_gender_salutation(str(member.spouse.member_gender), capitalize=False)
member_salutation = get_gender_salutation(str(member.member_gender), capitalize=False)
combined_salutation = f"{spouse_salutation.capitalize()} {member.spouse.member_firstname} und {member_salutation} {member.member_firstname}"
else:
combined_salutation = f"{get_gender_salutation(str(member.member_gender), capitalize=True)} {member.member_firstname}"
body = f"{combined_salutation},\n\n{salutation2} Zahlschein für {year} liegt bei.\n\nBitte mittels auf beiliegendem Zahlschein aufgedrucktem QR-Code überweisen.\nDanke sagt Dein Team vom Sportverein!"
msg = MIMEMultipart()
msg['From'] = from_email
@@ -56,7 +80,7 @@ def send_mail(member: models.Members):
msg['Subject'] = subject
msg.attach(MIMEText(body))
path_to_file = os.path.join(os.path.expanduser("~"),"sust","payment_slips","mail",f"{member.member_zip}_payment_slip_member{member.id}.pdf")
path_to_file = os.path.join(os.path.expanduser("~"),"sust",f"payment_slips_{year}","mail",f"{member.member_zip}_payment_slip_member{member.id}.pdf")
if not exists(path_to_file):
create_payment_slip(member=member,
forMail=True)
@@ -69,7 +93,10 @@ def send_mail(member: models.Members):
with smtplib.SMTP(smtp_server, smtp_port) as smtp:
smtp.starttls()
smtp.login(smtp_username, smtp_password)
smtp.send_message(msg)
# smtp.send_message(msg)
# Send to recipient AND BCC to sender (so it appears in sent folder)
recipients = [to_email, from_email] # Add from_email as BCC
smtp.sendmail(from_email, recipients, msg.as_string())
newDelivery = models.Deliveries(delivery_date = datetime.datetime.now(),
member_id = member.id,
@@ -85,15 +112,16 @@ db = SessionLocal()
models.Base.metadata.create_all(bind=engine)
members = db.query(models.Members).filter(
models.Members.member_active == True,
models.Members.member_email != None
models.Members.member_email != None,
models.Members.member_primary == True
).all()
#members = db.query(models.Members).filter(models.Members.member_email != None, ~models.Members.id.in_([183,51,200,49])).all()
#members = db.query(models.Members).filter(models.Members.member_active == True).all()
#members = db.query(models.Members).filter(models.Members.id.in_([2,3,9,199]), models.Members.member_active == True, models.Members.member_printed == False).all()
#members = db.query(models.Members).filter(models.Members.id.in_([8])).all()
members = db.query(models.Members).filter(models.Members.id.in_([77])).all()
for member in members:
if member.member_email:
send_mail(member=member)
send_mail(member=member, force_resend=args.resend)
else:
logging.debug(f'Member {member.member_surname} has no email associated!')
+116 -15
View File
@@ -1,12 +1,16 @@
from fastapi import FastAPI, HTTPException, Depends, WebSocket
from fastapi.security import OAuth2PasswordBearer
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import List, Annotated, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
from typing import List, Annotated, Optional, Union
from sustApp.models.models import Base, Members, Payments
from sustApp.models.database import engine, SessionLocal
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import datetime
from fastapi.encoders import jsonable_encoder
# from create_payment_report import create_payment_report
import asyncio
import logging
@@ -31,6 +35,7 @@ origins = [
"http://localhost:8080",
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:5174",
]
app.add_middleware(
@@ -91,10 +96,12 @@ class User(BaseModel):
class DeliveryBase(BaseModel):
delivery_date: datetime
delivered_by: int
delivered_by_id: int | None = None
class PaymentBase(BaseModel):
payment_date: datetime
payment_date: datetime | None = None
payment_year: datetime | None = None
class MemberBase(BaseModel):
member_count: int
@@ -105,19 +112,31 @@ class MemberBase(BaseModel):
member_street: str
member_streetnumber: str | None = None
member_email: str | None = None
member_zip: int
member_zip: int | None = Field(default=None, description="Member ZIP code")
#member_zip: Optional[int] = Field(default=None, description="Member ZIP code")
member_printed: bool | None = None
member_city: str
member_active: bool
member_active: bool | None = None
member_gender: str | None = None
member_connected: int | None = None
member_printed: bool
#member_coordinates:
#payments: Optional[List[PaymentBase]]
member_deliveries: Optional[List[DeliveryBase]]
#deliveries: Mapped[List["DeliveryBase"]] = relationship()
member_deliveries: Optional[List[DeliveryBase]] = []
member_payments: Optional[List[PaymentBase]] = []
current_state_code: str | None = None
current_state_name: str | None = None
@model_validator(mode='before')
@classmethod
def handle_empty_strings(cls, data):
"""Convert empty strings to None for int fields"""
if isinstance(data, dict):
if data.get('member_zip') == '':
data['member_zip'] = None
return data
model_config = ConfigDict(from_attributes=True) # Enables SQLAlchemy object conversion
def get_db():
db = SessionLocal()
try:
@@ -128,17 +147,84 @@ def get_db():
db_dependency = Annotated[Session, Depends(get_db)]
@app.get("/members/", response_model=List[MemberBase])
async def read_members(db: db_dependency, skip: int = 0, limit: int = 100):
members = db.query(Members).offset(skip).limit(limit).all()
if not members:
raise HTTPException(status_code=404, detail='No members found')
return [
{
"id": m.id,
"member_firstname": m.member_firstname,
"member_surname": m.member_surname,
"member_count": m.member_count,
"member_street": m.member_street,
"member_zip": m.member_zip,
"member_city": m.member_city,
"member_active": m.member_active,
"member_printed": m.member_printed,
"member_deliveries": [
{
"id": d.id,
"delivery_date": d.delivery_date, # Correct field
"delivered_by_id": d.delivered_by_id,
# Add any other required delivery fields here
}
for d in m.member_deliveries
] if m.member_deliveries else [],
"member_payments": [
{
"id": p.id,
"payment_date": p.payment_date,
"payment_year": p.payment_year,
# Add any other required delivery fields here
}
for p in m.member_payments
] if m.member_payments else []
}
for m in members
]
""" @app.get("/members/", response_model=List[MemberBase])
async def read_members(db: db_dependency, skip: int = 0, limit: int = 100):
members = db.query(Members).offset(skip).limit(limit).all()
if not members:
raise HTTPException(status_code=404, detail='No members found') # Use 404 for not found
# DEBUG: Print first member attributes
if members:
print("First member:", vars(members[0]))
print("First member type:", type(members[0]))
# TEMP: Return dicts to bypass Pydantic entirely
#return [{"id": m.id, "name": getattr(m, 'name', None)} for m in members] #
# Safe conversion - handles any SQLAlchemy objects
return jsonable_encoder(members)
# return members # Now works with ORM mode enabled """
""" @app.get("/members/", response_model=List[MemberBase])
async def read_members(db: db_dependency, skip: int = 0, limit: int = 100 ):
members = db.query(Members).offset(skip).limit(limit).all()
if not members:
raise HTTPException(status_code=400, detail='No members')
return members
return members """
@app.get("/activememberscount/", response_model=int)
async def read_activememberscount(db: db_dependency):
count = db.query(Members).filter(Members.member_active == True).count()
logger.info("This is a test log message from backend!")
return count
count = db.query(func.sum(Members.member_count)).filter(Members.current_state_code == 'A', Members.member_primary == True).scalar()
return count or 0
@app.get("/inactivememberscount/", response_model=int)
async def read_inactivememberscount(db: db_dependency):
count = db.query(func.sum(Members.member_count)).filter(Members.current_state_code == 'I', Members.member_primary == True).scalar()
return count or 0
@app.get("/deactivatedmemberscount/", response_model=int)
async def read_deactivatedmemberscount(db: db_dependency):
count = db.query(func.sum(Members.member_count)).filter(Members.current_state_code == 'D', Members.member_primary == True).scalar()
return count or 0
@app.get("/member/{member_id}")
async def read_member(member_id: int, db: db_dependency): #, token: Annotated[str, Depends(oauth2_scheme)]):
@@ -151,9 +237,16 @@ async def read_member(member_id: int, db: db_dependency): #, token: Annotated[st
return result
@app.get("/payments/")
async def read_all_payments(db: db_dependency):
result = db.query(Payments).all()
if not result:
raise HTTPException(status_code=400, detail=f'No payments found')
return result
@app.get("/payments/{member_id}")
async def read_payments(member_id: int, db: db_dependency):
result = db.query(Members).filter(Members.id == member_id).all()
result = db.query(Payments).filter(Payments.member_id == member_id).all()
if not result:
raise HTTPException(status_code=400, detail=f'No payments found for member {member_id}')
return result
@@ -191,4 +284,12 @@ async def create_members(members: List[MemberBase], db: db_dependency):
db_payment = Payments(payment_date=payment.payment_date,
member_id=db_member.id)
db.add(db_payment)
db.commit()
db.commit()
# @app.get("/api/reports/payments")
# async def get_payment_report():
# try:
# report_path = create_payment_report()
# return {"message": "Report generated", "path": report_path}
# except Exception as e:
# raise HTTPException(status_code=500, detail=str(e))
+1 -1
View File
@@ -2,7 +2,7 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base
URL_DATABASE = 'postgresql://sust:sust@192.168.1.202:5432/sust'
URL_DATABASE = 'postgresql://sust:sust@postgresql.home:5432/sust'
engine = create_engine(URL_DATABASE)
+50 -13
View File
@@ -3,13 +3,14 @@ from sqlalchemy.types import TypeDecorator, VARCHAR, Integer, CHAR
from sqlalchemy_utils import EmailType
from sqlalchemy.orm import relationship
#from models.database import Base
from sustApp.models.database import Base
from .database import Base
import datetime
class TrimChar(TypeDecorator):
impl = CHAR
cache_key = False
cache_ok = True
def process_result_value(self, value, dialect):
if value:
@@ -59,20 +60,27 @@ class Members(Base):
member_geo_longitude = Column(Float, nullable=True)
member_deliveries = relationship('Deliveries', backref='members')
member_payments = relationship('Payments', backref='members')
#member_deliveries: Mapped[List["Deliveries"]] = relationship()
#current_state_code = Column(String, index=True)
#current_state_name = relationship('MemberStates', backref='state_code')
current_state_code = Column(TrimChar, index=True) # No foreign key
current_state_changed_at = Column(DateTime)
# Use primaryjoin to specify the join condition
current_state = relationship(
'MemberStates',
primaryjoin='Members.current_state_code == MemberStates.state_code',
foreign_keys=[current_state_code]
spouse_id = Column(Integer, ForeignKey('members.id'), nullable=True)
spouse = relationship(
"Members",
remote_side=[id],
foreign_keys=[spouse_id],
backref="spouse_of",
single_parent=True
)
current_state_code = Column(TrimChar, index=True)
# current_state = relationship(
# 'MemberStates',
# foreign_keys=[current_state_code],
# # primaryjoin="foreign(Members.current_state_code) == remote(MemberStates.state_code)",
# viewonly=True
# )
current_state_changed_at = Column(DateTime)
member_primary = Column(Boolean, default=True)
def __repr__(self):
return f'<Comment "{self.content[:20]}...">'
@@ -118,6 +126,7 @@ class Payments_CSV(Base):
pc_currency = Column(String)
pc_timestamp = Column(DateTime)
pc_processed = Column(Boolean, default=False)
pc_year = Column(DateTime)
class MemberStates(Base):
__tablename__ = 'member_states'
@@ -126,6 +135,7 @@ class MemberStates(Base):
state_name = Column(String(20), nullable=False)
description = Column(String)
created_at = Column(DateTime)
# members = relationship("Members", back_populates="current_state")
class MemberStateLog(Base):
__tablename__ = 'member_state_log'
@@ -139,4 +149,31 @@ class MemberStateLog(Base):
# Relationships
member = relationship('Members', backref='state_history')
state = relationship('MemberStates')
state = relationship('MemberStates')
class MembersPaidYears(Base):
"""View for members and their payment years"""
__tablename__ = 'members_paid_years'
__table_args__ = {'extend_existing': True}
id = Column(Integer, primary_key=True)
member_firstname = Column(String)
member_surname = Column(String)
member_suffix = Column(String)
member_email = Column(String)
member_active = Column(Boolean)
# payment_year = Column(DateTime)
# payment_amount = Column(Float)
# payment_date = Column(DateTime)
member_active = Column(Boolean, default=True)
current_state_code = Column(TrimChar, index=True)
# current_state = relationship(
# 'MemberStates', # Ensure this matches the CLASS name exactly
# primaryjoin='Members.current_state_code == MemberStates.state_code',
# foreign_keys='[Members.current_state_code]', # Use a string here to avoid registry issues
# viewonly=True # Recommended if there is no actual ForeignKey constraint in DB
# )
member_membership_start = Column(DateTime)
paid_2024 = Column(Boolean, default=False)
paid_2025 = Column(Boolean, default=False)
paid_2026 = Column(Boolean, default=False)
+209
View File
@@ -0,0 +1,209 @@
import models.models as models
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from datetime import datetime
import locale
import os
from config_loader import load_config
from models.database import engine, SessionLocal
from sqlalchemy import func
cfg = load_config()
year = cfg['year']
locale.setlocale(locale.LC_ALL, '')
def get_payment_table_data():
"""Get payment data organized by member with summary columns"""
db = SessionLocal()
try:
# Query members with their payment information
members = db.query(models.Members).filter(
models.Members.member_active == True
).order_by(
models.Members.member_surname,
models.Members.member_firstname
).all()
table_data = []
# Header row
table_data.append([
'Paid 2024',
'Paid 2025'
])
# Data rows - one per member
for member in members:
payments = db.query(models.MembersPaidYears).filter(
models.MembersPaidYears.id == member.id
)
if payments:
table_data.append([
'' if payments.paid_2024 else '',
'' if payments.paid_2025 else ''
])
return table_data
finally:
db.close()
def create_payment_summary_table():
"""Create a single table with payment summary for all members"""
try:
# Get table data
table_data = get_payment_table_data()
if len(table_data) <= 1:
print("⚠️ No member data found")
return None
# Create filename
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
output_path = os.path.join(
os.path.expanduser("~"),
"sust",
f"payment_reports_{year}",
f"payment_summary_{timestamp}.pdf"
)
# Ensure directory exists
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Create PDF document
doc = SimpleDocTemplate(
output_path,
pagesize=landscape(A4),
topMargin=15*mm,
bottomMargin=15*mm,
leftMargin=10*mm,
rightMargin=10*mm
)
elements = []
# Title
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
fontSize=16,
textColor=colors.HexColor('#1f4788'),
spaceAfter=15,
alignment=TA_CENTER
)
title = Paragraph(f"Zahlungsübersicht Mitgliederbeiträge {year}", title_style)
elements.append(title)
# Create table
table = Table(table_data, repeatRows=1)
# Style table
table.setStyle(TableStyle([
# Header styling
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#1f4788')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
('ALIGN', (0, 0), (-1, 0), TA_CENTER),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 9),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
('TOPPADDING', (0, 0), (-1, 0), 8),
# Body styling
('ALIGN', (0, 1), (0, -1), TA_CENTER), # Member ID centered
('ALIGN', (1, 1), (1, -1), TA_LEFT), # Name left
('ALIGN', (2, 1), (2, -1), TA_LEFT), # Email left
('ALIGN', (3, 1), (3, -1), TA_CENTER), # Member Count centered
('ALIGN', (4, 1), (6, -1), TA_CENTER), # Payment columns centered
('FONTNAME', (0, 1), (-1, -1), 'Helvetica'),
('FONTSIZE', (0, 1), (-1, -1), 8),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f9f9f9')]),
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 5),
('RIGHTPADDING', (0, 0), (-1, -1), 5),
('TOPPADDING', (0, 1), (-1, -1), 4),
('BOTTOMPADDING', (0, 1), (-1, -1), 4),
# Highlight payment status columns
('BACKGROUND', (4, 0), (5, -1), colors.HexColor('#f0f8ff')),
]))
elements.append(table)
# Add footer with summary
elements.append(Spacer(1, 1.5*mm))
summary_style = ParagraphStyle(
'Summary',
parent=styles['Normal'],
fontSize=8,
textColor=colors.grey
)
summary = Paragraph(
f"Total Members: {len(table_data) - 1} | Generated: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}",
summary_style
)
elements.append(summary)
# Build PDF
doc.build(elements)
print(f"✅ Payment summary created: {output_path}")
print(f"📊 Total members: {len(table_data) - 1}")
return output_path
except Exception as e:
print(f"❌ Error creating payment summary: {str(e)}")
import traceback
traceback.print_exc()
return None
# Function to integrate into existing PDF
def get_payment_table_element():
"""Return a ReportLab Table element for embedding in existing PDFs"""
table_data = get_payment_table_data()
if len(table_data) <= 1:
return None
table = Table(table_data, repeatRows=1)
table.setStyle(TableStyle([
# Header styling
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#1f4788')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
('ALIGN', (0, 0), (-1, 0), TA_CENTER),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 9),
('BOTTOMPADDING', (0, 0), (-1, 0), 8),
# Body styling
('ALIGN', (0, 1), (0, -1), TA_CENTER),
('ALIGN', (1, 1), (1, -1), TA_LEFT),
('ALIGN', (2, 1), (2, -1), TA_LEFT),
('ALIGN', (3, 1), (-1, -1), TA_CENTER),
('FONTNAME', (0, 1), (-1, -1), 'Helvetica'),
('FONTSIZE', (0, 1), (-1, -1), 7),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f9f9f9')]),
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 3),
('RIGHTPADDING', (0, 0), (-1, -1), 3),
('TOPPADDING', (0, 1), (-1, -1), 2),
('BOTTOMPADDING', (0, 1), (-1, -1), 2),
]))
return table
if __name__ == '__main__':
create_payment_summary_table()
+10 -8
View File
@@ -1,9 +1,9 @@
from reportlab.lib.pagesizes import A4
from create_payment_slip import create_payment_slip
from sustApp.models.database import engine, SessionLocal
from models.database import engine, SessionLocal
from pydantic import BaseModel
from sqlalchemy.orm import Session
import sustApp.models.models as models
import models.models as models
from reportlab.lib.utils import ImageReader
import logging, sys
from sqlalchemy import extract
@@ -17,15 +17,17 @@ logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
db = SessionLocal()
models.Base.metadata.create_all(bind=engine)
#members = db.query(models.Members).filter(models.Members.member_email == None).all()
members = db.query(models.Members).filter(
models.Members.member_active == True,
models.Members.current_state_code == 'A',
models.Members.member_email == None,
models.Members.member_primary == True
).all()
#members = db.query(models.Members).filter(models.Members.id.in_([2,3,9,199]), models.Members.member_active == True, models.Members.member_printed == False).all()
#members = db.query(models.Members).filter(models.Members.id.in_([204,205,206])).all()
# members = db.query(models.Members).filter(models.Members.id.in_([2,3,9,199]), models.Members.member_active == True, models.Members.member_printed == False).all()
# members = db.query(models.Members).filter(models.Members.id.in_([2,3,9,199]), models.Members.member_active == True, models.Members.member_printed == False).all()
# members = db.query(models.Members).filter(models.Members.id.in_([15,207,166,195,52,179])).all()
members = db.query(models.Members).filter(models.Members.id.in_([108])).all()
for member in members:
for member in members:
# TOOD: Add column payment_date to Payments
# create only, if not already delivered/paid
paid = db.query(models.Payments.payment_year)\
@@ -37,7 +39,7 @@ for member in members:
.filter(models.Deliveries.member_id == member.id, extract('year', models.Deliveries.delivery_date) == year)\
.count()
if not delivered:
#create_payment_slip(member=member)
create_payment_slip(member=member)
logging.debug(f'Member: {member.member_surname}')
#logging.info('We processed %d records', len(processed_records))
else:
Binary file not shown.

After

Width:  |  Height:  |  Size: 848 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,334 @@
import React, { useState, useEffect } from 'react';
import { BarChart3, Users, CreditCard, FileText, Settings, LogOut } from 'lucide-react';
interface Member {
id: number;
member_firstname: string;
member_surname: string;
member_email: string;
member_active: boolean;
member_count: number;
}
interface Payment {
id: number;
member_id: number;
payment_date: string;
payment_amount: number;
payment_year: string;
}
interface DashboardStats {
total_members: number;
active_members: number;
total_payments: number;
total_amount: number;
}
const FastAPIFrontend: React.FC = () => {
const [activeTab, setActiveTab] = useState<'dashboard' | 'members' | 'payments' | 'settings'>('dashboard');
const [members, setMembers] = useState<Member[]>([]);
const [payments, setPayments] = useState<Payment[]>([]);
const [stats, setStats] = useState<DashboardStats>({
total_members: 0,
active_members: 0,
total_payments: 0,
total_amount: 0,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const apiUrl = 'http://localhost:8000';
useEffect(() => {
fetchDashboardData();
}, []);
const fetchDashboardData = async () => {
setLoading(true);
setError(null);
try {
const [membersRes, paymentsRes] = await Promise.all([
fetch(`${apiUrl}/members/`),
fetch(`${apiUrl}/payments/`),
]);
if (membersRes.ok && paymentsRes.ok) {
const membersData = await membersRes.json();
const paymentsData = await paymentsRes.json();
setMembers(membersData);
setPayments(paymentsData);
// Calculate stats
const activeCount = membersData.filter((m: Member) => m.member_active).length;
const totalAmount = paymentsData.reduce((sum: number, p: Payment) => sum + p.payment_amount, 0);
setStats({
total_members: membersData.length,
active_members: activeCount,
total_payments: paymentsData.length,
total_amount: totalAmount,
});
} else {
setError('Failed to fetch data from server');
}
} catch (err) {
setError('Error connecting to server: ' + (err instanceof Error ? err.message : 'Unknown error'));
} finally {
setLoading(false);
}
};
const DashboardView = () => (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatCard
title="Total Members"
value={stats.total_members}
icon={<Users className="w-8 h-8" />}
color="blue"
/>
<StatCard
title="Active Members"
value={stats.active_members}
icon={<Users className="w-8 h-8" />}
color="green"
/>
<StatCard
title="Total Payments"
value={stats.total_payments}
icon={<CreditCard className="w-8 h-8" />}
color="purple"
/>
<StatCard
title="Total Amount"
value={`${stats.total_amount.toFixed(2)}`}
icon={<BarChart3 className="w-8 h-8" />}
color="orange"
/>
</div>
);
const MembersView = () => (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Members List</h2>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Count</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{members.map((member) => (
<tr key={member.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{member.member_firstname} {member.member_surname}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{member.member_email}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
member.member_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{member.member_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{member.member_count}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
const PaymentsView = () => (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Payments List</h2>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Member ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Year</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{payments.map((payment) => (
<tr key={payment.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{payment.member_id}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(payment.payment_date).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{payment.payment_amount.toFixed(2)}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{payment.payment_year}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
const SettingsView = () => (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Settings</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">API Server</label>
<input
type="text"
value={apiUrl}
disabled
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-500"
/>
</div>
<button
onClick={fetchDashboardData}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700"
>
Refresh Data
</button>
</div>
</div>
);
return (
<div className="min-h-screen bg-gray-900">
{/* Header */}
<header className="bg-gray-800 border-b border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<BarChart3 className="w-8 h-8 text-blue-500" />
<h1 className="text-2xl font-bold text-white">SUST Dashboard</h1>
</div>
<button className="inline-flex items-center px-3 py-2 border border-gray-600 rounded-md text-sm font-medium text-gray-300 hover:bg-gray-700">
<LogOut className="w-4 h-4 mr-2" />
Logout
</button>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Error Alert */}
{error && (
<div className="mb-6 p-4 bg-red-900 border border-red-700 rounded-lg text-red-100">
{error}
</div>
)}
{/* Navigation Tabs */}
<div className="flex space-x-4 mb-8 border-b border-gray-700">
<TabButton
label="Dashboard"
icon={<BarChart3 className="w-4 h-4" />}
isActive={activeTab === 'dashboard'}
onClick={() => setActiveTab('dashboard')}
/>
<TabButton
label="Members"
icon={<Users className="w-4 h-4" />}
isActive={activeTab === 'members'}
onClick={() => setActiveTab('members')}
/>
<TabButton
label="Payments"
icon={<CreditCard className="w-4 h-4" />}
isActive={activeTab === 'payments'}
onClick={() => setActiveTab('payments')}
/>
<TabButton
label="Settings"
icon={<Settings className="w-4 h-4" />}
isActive={activeTab === 'settings'}
onClick={() => setActiveTab('settings')}
/>
</div>
{/* Loading State */}
{loading && (
<div className="flex justify-center items-center py-12">
<div className="text-gray-400">Loading...</div>
</div>
)}
{/* Tab Content */}
{!loading && (
<>
{activeTab === 'dashboard' && <DashboardView />}
{activeTab === 'members' && <MembersView />}
{activeTab === 'payments' && <PaymentsView />}
{activeTab === 'settings' && <SettingsView />}
</>
)}
</main>
</div>
);
};
interface StatCardProps {
title: string;
value: string | number;
icon: React.ReactNode;
color: 'blue' | 'green' | 'purple' | 'orange';
}
const StatCard: React.FC<StatCardProps> = ({ title, value, icon, color }) => {
const colorClasses = {
blue: 'bg-blue-500',
green: 'bg-green-500',
purple: 'bg-purple-500',
orange: 'bg-orange-500',
};
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm font-medium">{title}</p>
<p className="text-3xl font-bold text-gray-900 mt-2">{value}</p>
</div>
<div className={`${colorClasses[color]} p-3 rounded-full text-white`}>{icon}</div>
</div>
</div>
);
};
interface TabButtonProps {
label: string;
icon: React.ReactNode;
isActive: boolean;
onClick: () => void;
}
const TabButton: React.FC<TabButtonProps> = ({ label, icon, isActive, onClick }) => (
<button
onClick={onClick}
className={`flex items-center space-x-2 px-4 py-3 border-b-2 font-medium text-sm transition-colors ${
isActive
? 'border-blue-500 text-blue-500'
: 'border-transparent text-gray-400 hover:text-gray-300'
}`}
>
{icon}
<span>{label}</span>
</button>
);
export default FastAPIFrontend;
@@ -0,0 +1,573 @@
import React, { useState, useEffect } from 'react';
import { Search, Plus, Edit3, Trash2, RefreshCw, AlertCircle, CheckCircle, Server, Database, Users, Activity } from 'lucide-react';
const FastAPIFrontend = () => {
const [apiUrl, setApiUrl] = useState('http://localhost:8000');
const [isConnected, setIsConnected] = useState(false);
const [loading, setLoading] = useState(false);
const [data, setData] = useState([]);
const [error, setError] = useState(null);
const [activeTab, setActiveTab] = useState('dashboard');
const [members, setMembers] = useState([]);
const [activeMembersCount, setActiveMembersCount] = useState(0);
const [inactiveMembersCount, setInactiveMembersCount] = useState(0);
const [deactivatedMembersCount, setDeactivatedMembersCount] = useState(0);
const [formData, setFormData] = useState({});
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState(null);
// Test API connection
const testConnection = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`${apiUrl}/health`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
setIsConnected(true);
setError(null);
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (err) {
setIsConnected(false);
setError(err.message || 'Connection failed');
} finally {
setLoading(false);
}
};
// Fetch member counts
const fetchMemberCounts = async () => {
if (!isConnected) return;
try {
// Fetch active members count
const activeResponse = await fetch(`${apiUrl}/activememberscount`, {
headers: { 'Content-Type': 'application/json' }
});
if (activeResponse.ok) {
const activeCount = await activeResponse.json();
setActiveMembersCount(activeCount);
}
// Fetch inactive members count
const inactiveResponse = await fetch(`${apiUrl}/inactivememberscount`, {
headers: { 'Content-Type': 'application/json' }
});
if (inactiveResponse.ok) {
const inactiveCount = await inactiveResponse.json();
setInactiveMembersCount(inactiveCount);
}
// Fetch deactivated members count
const deactivatedResponse = await fetch(`${apiUrl}/deactivatedmemberscount`, {
headers: { 'Content-Type': 'application/json' }
});
if (deactivatedResponse.ok) {
const deactivatedCount = await deactivatedResponse.json();
setDeactivatedMembersCount(deactivatedCount);
}
} catch (err) {
console.error('Error fetching member counts:', err);
}
};
// Fetch data from API
const fetchData = async (endpoint = 'items') => {
if (!isConnected) return;
setLoading(true);
try {
const response = await fetch(`${apiUrl}/${endpoint}`, {
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
const result = await response.json();
setData(Array.isArray(result) ? result : [result]);
} else {
throw new Error(`Failed to fetch data: ${response.statusText}`);
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// Load members data
const loadMembers = async () => {
setLoading(true);
try {
const response = await fetch(`${apiUrl}/members/`);
if (response.ok) {
const data = await response.json();
setMembers(data);
} else {
console.error('Failed to fetch members');
setMembers([]);
}
} catch (error) {
console.error('Error loading members:', error);
setMembers([]);
} finally {
setLoading(false);
}
};
// Create new item
const createItem = async (itemData) => {
try {
const response = await fetch(`${apiUrl}/items`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(itemData),
});
if (response.ok) {
await fetchData();
setShowModal(false);
setFormData({});
} else {
throw new Error('Failed to create item');
}
} catch (err) {
setError(err.message);
}
};
// Update item
const updateItem = async (id, itemData) => {
try {
const response = await fetch(`${apiUrl}/items/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(itemData),
});
if (response.ok) {
await fetchData();
setShowModal(false);
setEditingItem(null);
setFormData({});
} else {
throw new Error('Failed to update item');
}
} catch (err) {
setError(err.message);
}
};
// Delete item
const deleteItem = async (id) => {
if (!window.confirm('Are you sure you want to delete this item?')) return;
try {
const response = await fetch(`${apiUrl}/items/${id}`, {
method: 'DELETE',
});
if (response.ok) {
await fetchData();
} else {
throw new Error('Failed to delete item');
}
} catch (err) {
setError(err.message);
}
};
// Handle form submission
const handleSubmit = (e) => {
e.preventDefault();
if (editingItem) {
updateItem(editingItem.id, formData);
} else {
createItem(formData);
}
};
// Open edit modal
const startEdit = (item) => {
setEditingItem(item);
setFormData(item);
setShowModal(true);
};
useEffect(() => {
testConnection();
}, [apiUrl]);
useEffect(() => {
if (isConnected) {
fetchMemberCounts();
if (activeTab === 'data') {
fetchData();
}
}
}, [isConnected, activeTab]);
const ConnectionStatus = () => (
<div className="flex items-center space-x-2 mb-6">
<div className={`w-3 h-3 rounded-full ${isConnected ? 'bg-green-400' : 'bg-red-400'}`}></div>
<span className={`text-sm font-medium ${isConnected ? 'text-green-600' : 'text-red-600'}`}>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
<span className="text-gray-500"></span>
<span className="text-sm text-gray-600">{apiUrl}</span>
</div>
);
const Dashboard = () => (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-gradient-to-r from-blue-500 to-blue-600 p-6 rounded-xl text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-blue-100">API Status</p>
<p className="text-2xl font-bold">{isConnected ? 'Online' : 'Offline'}</p>
</div>
<Server className="w-8 h-8 text-blue-200" />
</div>
</div>
<div className="bg-gradient-to-r from-green-500 to-green-600 p-6 rounded-xl text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-green-100">Active Members</p>
<p className="text-2xl font-bold">{activeMembersCount}</p>
</div>
<Users className="w-8 h-8 text-green-200" />
</div>
</div>
<div className="bg-gradient-to-r from-yellow-500 to-yellow-600 p-6 rounded-xl text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-yellow-100">Inactive Members</p>
<p className="text-2xl font-bold">{inactiveMembersCount}</p>
</div>
<Users className="w-8 h-8 text-yellow-200" />
</div>
</div>
<div className="bg-gradient-to-r from-red-500 to-red-600 p-6 rounded-xl text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-red-100">Deactivated Members</p>
<p className="text-2xl font-bold">{deactivatedMembersCount}</p>
</div>
<Users className="w-8 h-8 text-red-200" />
</div>
</div>
<div className="bg-gradient-to-r from-purple-500 to-purple-600 p-6 rounded-xl text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-purple-100">Total Members</p>
<p className="text-2xl font-bold">{activeMembersCount + inactiveMembersCount + deactivatedMembersCount}</p>
</div>
<Users className="w-8 h-8 text-purple-200" />
</div>
</div>
<div className="bg-gradient-to-r from-orange-500 to-orange-600 p-6 rounded-xl text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-orange-100">Activity</p>
<p className="text-2xl font-bold">Live</p>
</div>
<Activity className="w-8 h-8 text-orange-200" />
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">Member Statistics</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="text-center p-4 bg-green-50 rounded-lg border border-green-200">
<div className="text-2xl font-bold text-green-600">{activeMembersCount}</div>
<div className="text-sm text-green-700">Active Members</div>
<div className="text-xs text-green-600 mt-1">
{activeMembersCount + inactiveMembersCount + deactivatedMembersCount > 0
? `${Math.round((activeMembersCount / (activeMembersCount + inactiveMembersCount + deactivatedMembersCount)) * 100)}%`
: '0%'
} of total
</div>
</div>
<div className="text-center p-4 bg-yellow-50 rounded-lg border border-yellow-200">
<div className="text-2xl font-bold text-yellow-600">{inactiveMembersCount}</div>
<div className="text-sm text-yellow-700">Inactive Members</div>
<div className="text-xs text-yellow-600 mt-1">
{activeMembersCount + inactiveMembersCount + deactivatedMembersCount > 0
? `${Math.round((inactiveMembersCount / (activeMembersCount + inactiveMembersCount + deactivatedMembersCount)) * 100)}%`
: '0%'
} of total
</div>
</div>
<div className="text-center p-4 bg-red-50 rounded-lg border border-red-200">
<div className="text-2xl font-bold text-red-600">{deactivatedMembersCount}</div>
<div className="text-sm text-red-700">Deactivated Members</div>
<div className="text-xs text-red-600 mt-1">
{activeMembersCount + inactiveMembersCount + deactivatedMembersCount > 0
? `${Math.round((deactivatedMembersCount / (activeMembersCount + inactiveMembersCount + deactivatedMembersCount)) * 100)}%`
: '0%'
} of total
</div>
</div>
</div>
<h3 className="text-lg font-semibold text-gray-800 mb-4">Quick Actions</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<button
onClick={() => setActiveTab('data')}
className="p-4 border-2 border-dashed border-gray-200 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-colors"
>
<Database className="w-6 h-6 text-gray-400 mx-auto mb-2" />
<p className="text-sm font-medium text-gray-600">View All Members</p>
</button>
<button
onClick={fetchMemberCounts}
className="p-4 border-2 border-dashed border-gray-200 rounded-lg hover:border-green-300 hover:bg-green-50 transition-colors"
>
<RefreshCw className="w-6 h-6 text-gray-400 mx-auto mb-2" />
<p className="text-sm font-medium text-gray-600">Refresh Counts</p>
</button>
<button
onClick={testConnection}
className="p-4 border-2 border-dashed border-gray-200 rounded-lg hover:border-purple-300 hover:bg-purple-50 transition-colors"
>
<Activity className="w-6 h-6 text-gray-400 mx-auto mb-2" />
<p className="text-sm font-medium text-gray-600">Test Connection</p>
</button>
</div>
</div>
</div>
);
const DataView = () => (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold text-gray-800">Data Management</h2>
<button
onClick={() => {
setFormData({});
setEditingItem(null);
setShowModal(true);
}}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2"
>
<Plus className="w-4 h-4" />
<span>Add New</span>
</button>
</div>
{loading ? (
<div className="flex justify-center items-center py-12">
<RefreshCw className="w-6 h-6 animate-spin text-blue-600" />
<span className="ml-2 text-gray-600">Loading...</span>
</div>
) : (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{data.length === 0 ? (
<tr>
<td colSpan="4" className="px-6 py-12 text-center text-gray-500">
No data available. Add some items to get started.
</td>
</tr>
) : (
data.map((item, index) => (
<tr key={item.id || index} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.id || index + 1}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{item.name || item.title || 'N/A'}
</td>
<td className="px-6 py-4 text-sm text-gray-600 max-w-xs truncate">
{item.description || item.content || 'No description'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
<button
onClick={() => startEdit(item)}
className="text-blue-600 hover:text-blue-900 p-1 rounded"
>
<Edit3 className="w-4 h-4" />
</button>
<button
onClick={() => deleteItem(item.id)}
className="text-red-600 hover:text-red-900 p-1 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
</div>
);
const Modal = () => (
showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl max-w-md w-full p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
{editingItem ? 'Edit Item' : 'Add New Item'}
</h3>
<div onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={formData.name || ''}
onChange={(e) => setFormData({...formData, name: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
value={formData.description || ''}
onChange={(e) => setFormData({...formData, description: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows="3"
placeholder="Enter description"
/>
</div>
<div className="flex space-x-3 pt-4">
<button
type="button"
onClick={() => {
setShowModal(false);
setEditingItem(null);
setFormData({});
}}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
{editingItem ? 'Update' : 'Create'}
</button>
</div>
</div>
</div>
</div>
)
);
return (
<div className="min-h-screen bg-gray-100">
<div className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">FastAPI Dashboard</h1>
<p className="text-sm text-gray-600">Manage your backend data and monitor API status</p>
</div>
<div className="flex items-center space-x-4">
<input
type="text"
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="API URL"
/>
<button
onClick={testConnection}
disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center space-x-2"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
<span>Test</span>
</button>
</div>
</div>
<div className="flex space-x-8">
<button
onClick={() => setActiveTab('dashboard')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'dashboard'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Dashboard
</button>
<button
onClick={() => setActiveTab('data')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'data'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Data
</button>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<ConnectionStatus />
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6 flex items-center space-x-2">
<AlertCircle className="w-5 h-5 text-red-600" />
<span className="text-red-700">{error}</span>
</div>
)}
{activeTab === 'dashboard' ? <Dashboard /> : <DataView />}
</div>
<Modal />
</div>
);
};
export default FastAPIFrontend;
@@ -0,0 +1,835 @@
import React, { useState, useEffect } from 'react';
import {
Server,
Database,
Users,
Activity,
RefreshCw,
Plus,
Edit3,
Trash2,
Eye,
AlertCircle,
CheckCircle,
XCircle
} from 'lucide-react';
import { useEffect } from 'react';
function App() {
useEffect(() => {
const ws = new WebSocket('ws://localhost:8000/ws/logs');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'log') {
console.log(`[Backend Log]: ${data.message}`);
}
};
ws.onclose = () => {
console.log('WebSocket connection closed');
};
return () => {
ws.close();
};
}, []);
return <div>Your React App</div>;
}
const Dashboard = () => {
const [apiUrl, setApiUrl] = useState('http://127.0.0.1:8000');
const [connectionStatus, setConnectionStatus] = useState('disconnected');
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState('dashboard');
const [members, setMembers] = useState([]);
const [totalMembers, setTotalMembers] = useState(0);
const [activeMembersCount, setActiveMembersCount] = useState(0);
const [inactiveMembersCount, setInactiveMembersCount] = useState(0);
const [deactivatedMembersCount, setDeactivatedMembersCount] = useState(0);
const [loadingData, setLoadingData] = useState(false);
const testConnection = async () => {
setIsLoading(true);
try {
console.log('Testing connection to:', apiUrl);
// Test with a simple endpoint first - try /member/1 since /member/100 works
const response = await fetch(`${apiUrl}/member/100`);
if (response.ok) {
setConnectionStatus('connected');
console.log('Connection successful!');
// Load members data when connection is successful
await loadMembersCount();
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error) {
console.error('Connection error:', error);
setConnectionStatus('error');
}
setIsLoading(false);
};
const loadMembersCount = async () => {
if (connectionStatus !== 'connected' && !isLoading) return;
setLoadingData(true);
console.log('Attempting to load active members count...');
// const fetchActiveMembersCount = async () => {
try {
const response = await fetch(`${apiUrl}/activememberscount`);
if (response.ok) {
const count = await response.json(); // Single number return
setTotalMembers(count);
console.log('Total active members count:', count);
} else {
console.log('Failed to fetch active members count:', response.statusText);
setTotalMembers(0);
}
} catch (error) {
console.log('Error fetching active members count:', error);
setMembers([]);
setTotalMembers(0);
}
//}
setLoadingData(false);
};
// Fetch member counts
const fetchMemberCounts = async () => {
if (connectionStatus !== 'connected' && !isLoading) return;
try {
// Fetch active members count
const activeResponse = await fetch(`${apiUrl}/activememberscount`, {
headers: { 'Content-Type': 'application/json' }
});
if (activeResponse.ok) {
const activeCount = await activeResponse.json();
setActiveMembersCount(activeCount);
console.log('activememberscount: ', activeCount);
}
// Fetch inactive members count
const inactiveResponse = await fetch(`${apiUrl}/inactivememberscount`, {
headers: { 'Content-Type': 'application/json' }
});
if (inactiveResponse.ok) {
const inactiveCount = await inactiveResponse.json();
setInactiveMembersCount(inactiveCount);
}
// Fetch deactivated members count
const deactivatedResponse = await fetch(`${apiUrl}/deactivatedmemberscount`, {
headers: { 'Content-Type': 'application/json' }
});
if (deactivatedResponse.ok) {
const deactivatedCount = await deactivatedResponse.json();
setDeactivatedMembersCount(deactivatedCount);
}
} catch (err) {
console.error('Error fetching member counts:', err);
}
};
// Fetch data from API
const fetchData = async (endpoint = 'items') => {
if (connectionStatus !== 'connected' && !isLoading) return;
setLoadingData(true);
try {
const response = await fetch(`${apiUrl}/${endpoint}`, {
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
const result = await response.json();
setData(Array.isArray(result) ? result : [result]);
} else {
throw new Error(`Failed to fetch data: ${response.statusText}`);
}
} catch (err) {
setError(err.message);
} finally {
setLoadingData(false);
}
};
const loadMembers = async () => {
if (connectionStatus !== 'connected' && !isLoading) return;
setLoadingData(true);
try {
// Since /members/ gives 500 error, let's try different approaches
console.log('Attempting to load active members count...');
// Option 1: Try individual member requests (if you know IDs)
// For now, let's try to get a few members by ID
const memberIds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; // Adjust these IDs as needed
const memberPromises = memberIds.map(async (id) => {
try {
const response = await fetch(`${apiUrl}/member/${id}`);
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.log(`Member ${id} not found or error:`, error);
return null;
}
});
const results = await Promise.all(memberPromises);
const validMembers = results.filter(member => member !== null);
console.log('Found members:', validMembers);
setMembers(validMembers);
setTotalMembers(validMembers.length);
if (validMembers.length === 0) {
console.warn('No members found with IDs 1-10. You may need to adjust the ID range or fix the /members/ endpoint.');
}
} catch (error) {
console.error('Error loading members:', error);
setMembers([]);
setTotalMembers(0);
}
setLoadingData(false);
};
useEffect(() => {
if (connectionStatus === 'connected') {
// loadMembersCount();
fetchMemberCounts();
if (activeTab === 'data') {
fetchData();
}
}
}, [connectionStatus]);
const getStatusColor = () => {
switch (connectionStatus) {
case 'connected': return '#10b981';
case 'error': return '#ef4444';
default: return '#f59e0b';
}
};
const getStatusIcon = () => {
switch (connectionStatus) {
case 'connected': return <CheckCircle size={16} />;
case 'error': return <XCircle size={16} />;
default: return <AlertCircle size={16} />;
}
};
const getStatusText = () => {
switch (connectionStatus) {
case 'connected': return 'Connected';
case 'error': return 'Connection Error';
default: return 'Disconnected';
}
};
const getFullName = (member) => {
const firstName = member.member_firstname || '';
const lastName = member.member_surname || '';
const memberSuffix = member.member_suffix || '';
const fullName = `${firstName} ${lastName} ${memberSuffix}`.trim();
return fullName || 'N/A';
};
const MEMBER_STATE_COLORS = {
'A': '#10B981', // Green - Active
'I': '#F59E0B', // Amber/Orange - Inactive
'D': '#EF4444' // Red - Deactivated
};
const getMemberStateColor = (stateCode) => {
return MEMBER_STATE_COLORS[stateCode] || '#6B7280'; // Gray as fallback
};
const styles = {
container: {
minHeight: '100vh',
backgroundColor: '#f9fafb',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
},
header: {
backgroundColor: 'white',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)',
borderBottom: '1px solid #e5e7eb'
},
headerContent: {
maxWidth: '1280px',
margin: '0 auto',
padding: '0 24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: '24px',
paddingBottom: '24px'
},
title: {
fontSize: '30px',
fontWeight: 'bold',
color: '#111827',
margin: 0
},
subtitle: {
color: '#6b7280',
marginTop: '4px',
fontSize: '16px'
},
statusContainer: {
display: 'flex',
alignItems: 'center',
gap: '8px',
color: getStatusColor(),
fontWeight: '500'
},
mainContent: {
maxWidth: '1280px',
margin: '0 auto',
padding: '32px 24px'
},
card: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)',
border: '1px solid #e5e7eb',
padding: '24px',
marginBottom: '32px'
},
cardTitle: {
fontSize: '18px',
fontWeight: '600',
color: '#111827',
marginBottom: '16px'
},
connectionForm: {
display: 'flex',
alignItems: 'center',
gap: '16px'
},
input: {
flex: 1,
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
outline: 'none'
},
button: {
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '8px 16px',
backgroundColor: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
transition: 'background-color 0.2s'
},
buttonDisabled: {
opacity: 0.5,
cursor: 'not-allowed'
},
errorAlert: {
marginTop: '16px',
padding: '16px',
backgroundColor: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
gap: '8px'
},
errorText: {
color: '#991b1b'
},
tabs: {
borderBottom: '1px solid #e5e7eb',
marginBottom: '32px'
},
tabsNav: {
display: 'flex',
gap: '32px'
},
tab: {
padding: '8px 4px',
fontSize: '14px',
fontWeight: '500',
border: 'none',
background: 'none',
cursor: 'pointer',
borderBottom: '2px solid transparent',
transition: 'all 0.2s'
},
tabActive: {
borderBottom: '2px solid #2563eb',
color: '#2563eb'
},
tabInactive: {
color: '#6b7280'
},
statsGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '24px',
marginBottom: '32px'
},
statCard: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)',
border: '1px solid #e5e7eb',
padding: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
},
statContent: {
flex: 1
},
statLabel: {
fontSize: '14px',
fontWeight: '500',
color: '#6b7280'
},
statValue: {
fontSize: '24px',
fontWeight: 'bold',
color: '#111827',
marginTop: '8px'
},
statIcon: {
padding: '12px',
borderRadius: '50%'
},
iconOrange: {
backgroundColor: '#fed7aa',
color: '#ea580c'
},
iconBlue: {
backgroundColor: '#dbeafe',
color: '#2563eb'
},
iconGreen: {
backgroundColor: '#dcfce7',
color: '#16a34a'
},
iconPurple: {
backgroundColor: '#e9d5ff',
color: '#9333ea'
},
actionButtons: {
display: 'flex',
flexWrap: 'wrap',
gap: '16px'
},
actionButton: {
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '8px 16px',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
transition: 'background-color 0.2s'
},
actionButtonGray: {
backgroundColor: '#f3f4f6',
color: '#374151'
},
actionButtonGreen: {
backgroundColor: '#dcfce7',
color: '#166534'
},
actionButtonBlue: {
backgroundColor: '#dbeafe',
color: '#1e40af'
},
emptyState: {
textAlign: 'center',
padding: '32px'
},
emptyStateIcon: {
margin: '0 auto 16px',
color: '#9ca3af'
},
emptyStateText: {
color: '#6b7280'
},
cardHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px'
},
refreshButton: {
display: 'flex',
alignItems: 'center',
padding: '8px 16px',
backgroundColor: '#3B82F6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
},
loadingState: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '40px',
color: '#6B7280'
},
loadingIcon: {
marginBottom: '10px',
color: '#3B82F6'
},
dataContainer: {
marginTop: '20px'
},
dataHeader: {
marginBottom: '16px',
padding: '12px',
backgroundColor: '#F3F4F6',
borderRadius: '6px'
},
dataCount: {
margin: 0,
fontWeight: '600',
color: '#374151'
},
tableContainer: {
overflowX: 'auto',
border: '1px solid #E5E7EB',
borderRadius: '8px'
},
table: {
width: '100%',
borderCollapse: 'collapse'
},
tableHeaderRow: {
backgroundColor: '#F9FAFB'
},
tableHeader: {
padding: '12px',
textAlign: 'left',
fontWeight: '600',
color: '#374151',
borderBottom: '1px solid #E5E7EB'
},
tableRow: {
borderBottom: '1px solid #F3F4F6'
},
tableCell: {
padding: '12px',
color: '#6B7280'
},
statusBadge: {
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '500'
},
};
return (
<div style={styles.container}>
{/* Header */}
<div style={styles.header}>
<div style={styles.headerContent}>
<div>
<h1 style={styles.title}>FastAPI Dashboard</h1>
<p style={styles.subtitle}>Manage your backend data and monitor API status</p>
</div>
<div style={styles.statusContainer}>
{getStatusIcon()}
<span>{getStatusText()}</span>
</div>
</div>
</div>
{/* Main Content */}
<div style={styles.mainContent}>
{/* Connection Section */}
<div style={styles.card}>
<h2 style={styles.cardTitle}>API Connection</h2>
<div style={styles.connectionForm}>
<input
type="text"
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
style={styles.input}
placeholder="Enter API URL"
/>
<button
onClick={testConnection}
disabled={isLoading}
style={{
...styles.button,
...(isLoading ? styles.buttonDisabled : {}),
':hover': { backgroundColor: '#1d4ed8' }
}}
onMouseEnter={(e) => {
if (!isLoading) e.target.style.backgroundColor = '#1d4ed8';
}}
onMouseLeave={(e) => {
if (!isLoading) e.target.style.backgroundColor = '#2563eb';
}}
>
<RefreshCw size={16} style={isLoading ? { animation: 'spin 1s linear infinite' } : {}} />
Test Connection
</button>
</div>
{connectionStatus === 'error' && (
<div style={styles.errorAlert}>
<XCircle size={20} style={{ color: '#dc2626' }} />
<span style={styles.errorText}>NetworkError when attempting to fetch resource.</span>
</div>
)}
</div>
{/* Navigation Tabs */}
<div style={styles.tabs}>
<div style={styles.tabsNav}>
<button
onClick={() => setActiveTab('dashboard')}
style={{
...styles.tab,
...(activeTab === 'dashboard' ? styles.tabActive : styles.tabInactive)
}}
>
Dashboard
</button>
<button
onClick={() => setActiveTab('data')}
style={{
...styles.tab,
...(activeTab === 'data' ? styles.tabActive : styles.tabInactive)
}}
>
Data
</button>
</div>
</div>
{/* Stats Grid */}
<div style={styles.statsGrid}>
{/* API Status */}
<div style={styles.statCard}>
<div style={styles.statContent}>
<p style={styles.statLabel}>API Status</p>
<p style={styles.statValue}>
{connectionStatus === 'connected' ? 'Online' : 'Offline'}
</p>
</div>
<div style={{...styles.statIcon, ...styles.iconOrange}}>
<Server size={24} />
</div>
</div>
{/* Active Users */}
<div style={styles.statCard}>
<div style={styles.statContent}>
<p style={styles.statLabel}>Active Users</p>
<p style={styles.statValue}>1</p>
</div>
<div style={{...styles.statIcon, ...styles.iconGreen}}>
<Users size={24} />
</div>
</div>
{/* Activity */}
<div style={styles.statCard}>
<div style={styles.statContent}>
<p style={styles.statLabel}>Activity</p>
<p style={styles.statValue}>Live</p>
</div>
<div style={{...styles.statIcon, ...styles.iconPurple}}>
<Activity size={24} />
</div>
</div>
<div className="bg-gradient-to-r from-green-500 to-green-600 p-6 rounded-xl text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-green-100">Active Members</p>
<p className="text-2xl font-bold">{activeMembersCount}</p>
</div>
<Users className="w-8 h-8 text-green-200" />
</div>
</div>
<div className="bg-gradient-to-r from-yellow-500 to-yellow-600 p-6 rounded-xl text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-yellow-100">Inactive Members</p>
<p className="text-2xl font-bold">{inactiveMembersCount}</p>
</div>
<Users className="w-8 h-8 text-yellow-200" />
</div>
</div>
<div className="bg-gradient-to-r from-red-500 to-red-600 p-6 rounded-xl text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-red-100">Deactivated Members</p>
<p className="text-2xl font-bold">{deactivatedMembersCount}</p>
</div>
<Users className="w-8 h-8 text-red-200" />
</div>
</div>
</div>
{/* Quick Actions */}
<div style={styles.card}>
<h2 style={styles.cardTitle}>Quick Actions</h2>
<div style={styles.actionButtons}>
<button
style={{...styles.actionButton, ...styles.actionButtonGray}}
onMouseEnter={(e) => e.target.style.backgroundColor = '#e5e7eb'}
onMouseLeave={(e) => e.target.style.backgroundColor = '#f3f4f6'}
>
<Eye size={16} />
View Data
</button>
<button
style={{...styles.actionButton, ...styles.actionButtonGreen}}
onMouseEnter={(e) => e.target.style.backgroundColor = '#bbf7d0'}
onMouseLeave={(e) => e.target.style.backgroundColor = '#dcfce7'}
>
<Plus size={16} />
Add Item
</button>
<button
style={{...styles.actionButton, ...styles.actionButtonBlue}}
onMouseEnter={(e) => e.target.style.backgroundColor = '#bfdbfe'}
onMouseLeave={(e) => e.target.style.backgroundColor = '#dbeafe'}
>
<RefreshCw size={16} />
Refresh
</button>
</div>
</div>
{/* Content based on active tab */}
{activeTab === 'data' && (
<div style={styles.card}>
<div style={styles.cardHeader}>
<h2 style={styles.cardTitle}>Data Management</h2>
<button
onClick={loadMembers}
style={styles.refreshButton}
disabled={isLoading}
>
<RefreshCw size={16} style={{ marginRight: '8px' }} />
{isLoading ? 'Loading...' : 'Load Members'}
</button>
</div>
{isLoading ? (
<div style={styles.loadingState}>
<RefreshCw size={24} style={{ ...styles.loadingIcon, animation: 'spin 1s linear infinite' }} />
<p>Loading members...</p>
</div>
) : members.length === 0 ? (
<div style={styles.emptyState}>
<Database size={48} style={styles.emptyStateIcon} />
<p style={styles.emptyStateText}>No members found. Click "Load Members" to fetch data.</p>
</div>
) : (
<div style={styles.dataContainer}>
<div style={styles.dataHeader}>
<p style={styles.dataCount}>Total Members: {members.length}</p>
</div>
<div style={styles.tableContainer}>
<table style={styles.table}>
<thead>
<tr style={styles.tableHeaderRow}>
<th style={styles.tableHeader}>ID</th>
<th style={styles.tableHeader}>Name</th>
<th style={styles.tableHeader}>Email</th>
<th style={styles.tableHeader}>Active</th>
<th style={styles.tableHeader}>Actions</th>
</tr>
</thead>
<tbody>
{members.map((member, index) => (
<tr key={member.id || index} style={styles.tableRow}>
<td style={styles.tableCell}>{member.id}</td>
<td style={styles.tableCell}>{getFullName(member)}</td>
<td style={styles.tableCell}>{member.member_email || 'N/A'}</td>
<td style={styles.tableCell}>
<span style={{
...styles.statusBadge,
backgroundColor: getMemberStateColor(member.current_state.state_code),
color: 'white'
}}>
{member.current_state.state_name}
</span>
</td>
<td style={styles.tableCell}>
<button style={styles.actionButton}>
<Edit3 size={14} />
</button>
<button style={styles.actionButton}>
<Trash2 size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</div>
<style>
{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}
</style>
</div>
);
};
export default Dashboard;
@@ -0,0 +1,762 @@
import React, { useState, useEffect } from 'react';
import { Save, X, User, Calendar, Phone, Mail, MapPin, Users, Edit, Plus, Trash2 } from 'lucide-react';
// Member Edit Form Component
const MemberEditForm = ({ member = null, onSave, onCancel, isOpen }) => {
const [formData, setFormData] = useState({
id: '',
member_count: 1,
member_firstname: '',
member_firstname_spouse: '',
member_surname: '',
member_suffix: '',
member_street: '',
member_streetnumber: '',
member_email: '',
member_zip: '',
member_city: '',
member_gender: '',
member_died: '',
member_membership_start: '',
member_phone: '',
current_state_code: '',
current_state_changed_at: ''
});
const [errors, setErrors] = useState({});
const [isLoading, setIsLoading] = useState(false);
const genderOptions = [
{ value: '', label: 'Select Gender' },
{ value: 'M', label: 'Male' },
{ value: 'F', label: 'Female' },
{ value: 'O', label: 'Other' }
];
const stateCodes = [
'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY'
];
useEffect(() => {
if (member) {
setFormData({
id: member.id || '',
member_count: member.member_count || 1,
member_firstname: member.member_firstname || '',
member_firstname_spouse: member.member_firstname_spouse || '',
member_surname: member.member_surname || '',
member_suffix: member.member_suffix || '',
member_street: member.member_street || '',
member_streetnumber: member.member_streetnumber || '',
member_email: member.member_email || '',
member_zip: member.member_zip || '',
member_city: member.member_city || '',
member_gender: member.member_gender || '',
member_died: member.member_died ? new Date(member.member_died).toISOString().split('T')[0] : '',
member_membership_start: member.member_membership_start ? new Date(member.member_membership_start).toISOString().split('T')[0] : '',
member_phone: member.member_phone || '',
current_state_code: member.current_state_code || '',
current_state_changed_at: member.current_state_changed_at ? new Date(member.current_state_changed_at).toISOString().slice(0, 16) : ''
});
} else {
// Reset form for new member
setFormData({
id: '',
member_count: 1,
member_firstname: '',
member_firstname_spouse: '',
member_surname: '',
member_suffix: '',
member_street: '',
member_streetnumber: '',
member_email: '',
member_zip: '',
member_city: '',
member_gender: '',
member_died: '',
member_membership_start: '',
member_phone: '',
current_state_code: '',
current_state_changed_at: ''
});
}
setErrors({});
}, [member]);
const handleInputChange = (e) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'number' ? (value === '' ? '' : parseInt(value)) : value
}));
if (errors[name]) {
setErrors(prev => ({
...prev,
[name]: ''
}));
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.member_firstname.trim()) {
newErrors.member_firstname = 'First name is required';
}
if (!formData.member_surname.trim()) {
newErrors.member_surname = 'Surname is required';
}
if (formData.member_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.member_email)) {
newErrors.member_email = 'Please enter a valid email address';
}
if (formData.member_zip && (formData.member_zip < 10000 || formData.member_zip > 99999)) {
newErrors.member_zip = 'Please enter a valid 5-digit ZIP code';
}
if (formData.member_phone && formData.member_phone.length < 10) {
newErrors.member_phone = 'Please enter a valid phone number';
}
if (formData.member_count < 1) {
newErrors.member_count = 'Member count must be at least 1';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateForm()) {
return;
}
setIsLoading(true);
try {
const submitData = {
...formData,
updated_date: new Date().toISOString(),
member_died: formData.member_died ? new Date(formData.member_died).toISOString() : null,
member_membership_start: formData.member_membership_start ? new Date(formData.member_membership_start).toISOString() : null,
current_state_changed_at: formData.current_state_changed_at ? new Date(formData.current_state_changed_at).toISOString() : null
};
if (onSave) {
await onSave(submitData);
}
} catch (error) {
console.error('Error saving member:', error);
setErrors({ submit: 'Failed to save member. Please try again.' });
} finally {
setIsLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-2">
<User className="h-6 w-6" />
{member ? 'Edit Member' : 'Add New Member'}
</h2>
<button
onClick={onCancel}
className="p-2 text-gray-500 hover:text-gray-700 rounded-full hover:bg-gray-100"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-6">
{errors.submit && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{errors.submit}
</div>
)}
{/* Personal Information */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
<User className="h-5 w-5" />
Personal Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
First Name *
</label>
<input
type="text"
name="member_firstname"
value={formData.member_firstname}
onChange={handleInputChange}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.member_firstname ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Enter first name"
/>
{errors.member_firstname && (
<p className="text-red-500 text-xs mt-1">{errors.member_firstname}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Spouse First Name
</label>
<input
type="text"
name="member_firstname_spouse"
value={formData.member_firstname_spouse}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter spouse first name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Surname *
</label>
<input
type="text"
name="member_surname"
value={formData.member_surname}
onChange={handleInputChange}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.member_surname ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Enter surname"
/>
{errors.member_surname && (
<p className="text-red-500 text-xs mt-1">{errors.member_surname}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Suffix
</label>
<input
type="text"
name="member_suffix"
value={formData.member_suffix}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Jr., Sr., III, etc."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Gender
</label>
<select
name="member_gender"
value={formData.member_gender}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{genderOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1">
<Users className="h-4 w-4" />
Member Count
</label>
<input
type="number"
name="member_count"
value={formData.member_count}
onChange={handleInputChange}
min="1"
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.member_count ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.member_count && (
<p className="text-red-500 text-xs mt-1">{errors.member_count}</p>
)}
</div>
</div>
</div>
{/* Contact Information */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
<Mail className="h-5 w-5" />
Contact Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1">
<Mail className="h-4 w-4" />
Email
</label>
<input
type="email"
name="member_email"
value={formData.member_email}
onChange={handleInputChange}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.member_email ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="member@example.com"
/>
{errors.member_email && (
<p className="text-red-500 text-xs mt-1">{errors.member_email}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1">
<Phone className="h-4 w-4" />
Phone
</label>
<input
type="tel"
name="member_phone"
value={formData.member_phone}
onChange={handleInputChange}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.member_phone ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="(555) 123-4567"
/>
{errors.member_phone && (
<p className="text-red-500 text-xs mt-1">{errors.member_phone}</p>
)}
</div>
</div>
</div>
{/* Address Information */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
<MapPin className="h-5 w-5" />
Address Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Street Number
</label>
<input
type="text"
name="member_streetnumber"
value={formData.member_streetnumber}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="123"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Street
</label>
<input
type="text"
name="member_street"
value={formData.member_street}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Main Street"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
ZIP Code
</label>
<input
type="number"
name="member_zip"
value={formData.member_zip}
onChange={handleInputChange}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.member_zip ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="12345"
/>
{errors.member_zip && (
<p className="text-red-500 text-xs mt-1">{errors.member_zip}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
City
</label>
<input
type="text"
name="member_city"
value={formData.member_city}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Anytown"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
State
</label>
<select
name="current_state_code"
value={formData.current_state_code}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select State</option>
{stateCodes.map(code => (
<option key={code} value={code}>{code}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
State Changed At
</label>
<input
type="datetime-local"
name="current_state_changed_at"
value={formData.current_state_changed_at}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
{/* Membership Information */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
<Calendar className="h-5 w-5" />
Membership Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Membership Start Date
</label>
<input
type="date"
name="member_membership_start"
value={formData.member_membership_start}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Date of Death
</label>
<input
type="date"
name="member_died"
value={formData.member_died}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
{/* Form Actions */}
<div className="flex justify-end gap-3 pt-6 border-t">
<button
type="button"
onClick={onCancel}
className="px-6 py-2 text-gray-700 bg-gray-200 hover:bg-gray-300 rounded-md transition-colors"
disabled={isLoading}
>
Cancel
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isLoading}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Save className="h-4 w-4" />
{isLoading ? 'Saving...' : 'Save Member'}
</button>
</div>
</div>
</div>
</div>
</div>
);
};
// Main FastAPI Frontend Component
const FastAPIFrontend = () => {
const [members, setMembers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showMemberForm, setShowMemberForm] = useState(false);
const [editingMember, setEditingMember] = useState(null);
// Your existing API base URL - adjust this to match your FastAPI server
const API_BASE_URL = 'http://localhost:8000'; // Change this to your FastAPI URL
// Fetch members from your FastAPI backend
const fetchMembers = async () => {
setLoading(true);
setError('');
try {
const response = await fetch(`${API_BASE_URL}/members`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setMembers(data);
} catch (err) {
setError('Failed to fetch members: ' + err.message);
} finally {
setLoading(false);
}
};
// Save member (create or update)
const saveMember = async (memberData) => {
try {
const isEdit = memberData.id && memberData.id !== '';
const url = isEdit ? `${API_BASE_URL}/members/${memberData.id}` : `${API_BASE_URL}/members`;
const method = isEdit ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(memberData),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const savedMember = await response.json();
// Update local state
if (isEdit) {
setMembers(prev => prev.map(m => m.id === savedMember.id ? savedMember : m));
} else {
setMembers(prev => [...prev, savedMember]);
}
setShowMemberForm(false);
setEditingMember(null);
alert(isEdit ? 'Member updated successfully!' : 'Member created successfully!');
} catch (err) {
throw new Error('Failed to save member: ' + err.message);
}
};
// Delete member
const deleteMember = async (memberId) => {
if (!confirm('Are you sure you want to delete this member?')) {
return;
}
try {
const response = await fetch(`${API_BASE_URL}/members/${memberId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
setMembers(prev => prev.filter(m => m.id !== memberId));
alert('Member deleted successfully!');
} catch (err) {
alert('Failed to delete member: ' + err.message);
}
};
// Handle edit member
const handleEditMember = (member) => {
setEditingMember(member);
setShowMemberForm(true);
};
// Handle add new member
const handleAddMember = () => {
setEditingMember(null);
setShowMemberForm(true);
};
// Handle close form
const handleCloseForm = () => {
setShowMemberForm(false);
setEditingMember(null);
};
// Load members on component mount
useEffect(() => {
fetchMembers();
}, []);
return (
<div className="min-h-screen bg-gray-100">
<div className="container mx-auto px-4 py-8">
<div className="bg-white shadow-lg rounded-lg p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-800">Member Management</h1>
<button
onClick={handleAddMember}
className="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors flex items-center gap-2"
>
<Plus className="h-5 w-5" />
Add New Member
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{loading ? (
<div className="text-center py-8">
<div className="text-lg text-gray-600">Loading members...</div>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full table-auto">
<thead>
<tr className="bg-gray-50">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Phone
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
City
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Member Count
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{members.map((member) => (
<tr key={member.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{member.member_firstname} {member.member_surname} {member.member_suffix}
</div>
{member.member_firstname_spouse && (
<div className="text-sm text-gray-500">
Spouse: {member.member_firstname_spouse}
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{member.member_email || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{member.member_phone || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{member.member_city || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{member.member_count}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
<button
onClick={() => handleEditMember(member)}
className="text-blue-600 hover:text-blue-900 p-1 rounded hover:bg-blue-100"
title="Edit Member"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => deleteMember(member.id)}
className="text-red-600 hover:text-red-900 p-1 rounded hover:bg-red-100"
title="Delete Member"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
{members.length === 0 && !loading && (
<tr>
<td colSpan="6" className="text-center py-8 text-gray-500">
No members found. Click "Add New Member" to get started.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
<div className="mt-6 flex justify-between items-center">
<div className="text-sm text-gray-700">
Total Members: {members.length}
</div>
<button
onClick={fetchMembers}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm"
>
Refresh
</button>
</div>
</div>
</div>
{/* Member Edit Form Modal */}
<MemberEditForm
member={editingMember}
onSave={saveMember}
onCancel={handleCloseForm}
isOpen={showMemberForm}
/>
</div>
);
};
export default FastAPIFrontend;
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import FastAPIFrontend from './FastAPIFrontend';
import FastAPIFrontend from './FastAPIFrontend.tsx';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
@@ -0,0 +1,599 @@
import React, { useState, useEffect } from 'react';
import { Save, X, User, Calendar, Phone, Mail, MapPin, Users } from 'lucide-react';
const MemberEditForm = ({ member = null, onSave, onCancel }) => {
const [formData, setFormData] = useState({
id: '',
member_count: 1,
member_firstname: '',
member_firstname_spouse: '',
member_surname: '',
member_suffix: '',
member_street: '',
member_streetnumber: '',
member_email: '',
member_zip: '',
member_city: '',
member_gender: '',
member_died: '',
member_membership_start: '',
member_phone: '',
current_state_code: '',
current_state_changed_at: ''
});
const [errors, setErrors] = useState({});
const [isLoading, setIsLoading] = useState(false);
// Gender options based on your enum
const genderOptions = [
{ value: '', label: 'Select Gender' },
{ value: 'M', label: 'Male' },
{ value: 'F', label: 'Female' },
{ value: 'O', label: 'Other' }
];
// US State codes
const stateCodes = [
'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY'
];
// Load member data when component mounts or member prop changes
useEffect(() => {
if (member) {
setFormData({
id: member.id || '',
member_count: member.member_count || 1,
member_firstname: member.member_firstname || '',
member_firstname_spouse: member.member_firstname_spouse || '',
member_surname: member.member_surname || '',
member_suffix: member.member_suffix || '',
member_street: member.member_street || '',
member_streetnumber: member.member_streetnumber || '',
member_email: member.member_email || '',
member_zip: member.member_zip || '',
member_city: member.member_city || '',
member_gender: member.member_gender || '',
member_died: member.member_died ? new Date(member.member_died).toISOString().split('T')[0] : '',
member_membership_start: member.member_membership_start ? new Date(member.member_membership_start).toISOString().split('T')[0] : '',
member_phone: member.member_phone || '',
current_state_code: member.current_state_code || '',
current_state_changed_at: member.current_state_changed_at ? new Date(member.current_state_changed_at).toISOString().slice(0, 16) : ''
});
}
}, [member]);
const handleInputChange = (e) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'number' ? (value === '' ? '' : parseInt(value)) : value
}));
// Clear error when user starts typing
if (errors[name]) {
setErrors(prev => ({
...prev,
[name]: ''
}));
}
};
const validateForm = () => {
const newErrors = {};
// Required field validations
if (!formData.member_firstname.trim()) {
newErrors.member_firstname = 'First name is required';
}
if (!formData.member_surname.trim()) {
newErrors.member_surname = 'Surname is required';
}
// Email validation
if (formData.member_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.member_email)) {
newErrors.member_email = 'Please enter a valid email address';
}
// ZIP code validation
if (formData.member_zip && (formData.member_zip < 10000 || formData.member_zip > 99999)) {
newErrors.member_zip = 'Please enter a valid 5-digit ZIP code';
}
// Phone validation (basic)
if (formData.member_phone && formData.member_phone.length < 10) {
newErrors.member_phone = 'Please enter a valid phone number';
}
// Member count validation
if (formData.member_count < 1) {
newErrors.member_count = 'Member count must be at least 1';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateForm()) {
return;
}
setIsLoading(true);
try {
// Prepare data for submission
const submitData = {
...formData,
updated_date: new Date().toISOString(),
// Convert date strings back to proper format for database
member_died: formData.member_died ? new Date(formData.member_died).toISOString() : null,
member_membership_start: formData.member_membership_start ? new Date(formData.member_membership_start).toISOString() : null,
current_state_changed_at: formData.current_state_changed_at ? new Date(formData.current_state_changed_at).toISOString() : null
};
if (onSave) {
await onSave(submitData);
}
} catch (error) {
console.error('Error saving member:', error);
setErrors({ submit: 'Failed to save member. Please try again.' });
} finally {
setIsLoading(false);
}
};
const formatDateTime = (dateString) => {
if (!dateString) return '';
return new Date(dateString).toISOString().slice(0, 16);
};
return (
<div className="max-w-4xl mx-auto bg-white shadow-lg rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-2">
<User className="h-6 w-6" />
{member ? 'Edit Member' : 'Add New Member'}
</h2>
{onCancel && (
<button
onClick={onCancel}
className="p-2 text-gray-500 hover:text-gray-700 rounded-full hover:bg-gray-100"
>
<X className="h-5 w-5" />
</button>
)}
</div>
<div className="space-y-6">
{errors.submit && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{errors.submit}
</div>
)}
{/* Personal Information */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
<User className="h-5 w-5" />
Personal Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
First Name *
</label>
<input
type="text"
name="member_firstname"
value={formData.member_firstname}
onChange={handleInputChange}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.member_firstname ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Enter first name"
/>
{errors.member_firstname && (
<p className="text-red-500 text-xs mt-1">{errors.member_firstname}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Spouse First Name
</label>
<input
type="text"
name="member_firstname_spouse"
value={formData.member_firstname_spouse}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter spouse first name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Surname *
</label>
<input
type="text"
name="member_surname"
value={formData.member_surname}
onChange={handleInputChange}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.member_surname ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Enter surname"
/>
{errors.member_surname && (
<p className="text-red-500 text-xs mt-1">{errors.member_surname}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Suffix
</label>
<input
type="text"
name="member_suffix"
value={formData.member_suffix}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Jr., Sr., III, etc."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Gender
</label>
<select
name="member_gender"
value={formData.member_gender}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{genderOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1">
<Users className="h-4 w-4" />
Member Count
</label>
<input
type="number"
name="member_count"
value={formData.member_count}
onChange={handleInputChange}
min="1"
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.member_count ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.member_count && (
<p className="text-red-500 text-xs mt-1">{errors.member_count}</p>
)}
</div>
</div>
</div>
{/* Contact Information */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
<Mail className="h-5 w-5" />
Contact Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1">
<Mail className="h-4 w-4" />
Email
</label>
<input
type="email"
name="member_email"
value={formData.member_email}
onChange={handleInputChange}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.member_email ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="member@example.com"
/>
{errors.member_email && (
<p className="text-red-500 text-xs mt-1">{errors.member_email}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1">
<Phone className="h-4 w-4" />
Phone
</label>
<input
type="tel"
name="member_phone"
value={formData.member_phone}
onChange={handleInputChange}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.member_phone ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="(555) 123-4567"
/>
{errors.member_phone && (
<p className="text-red-500 text-xs mt-1">{errors.member_phone}</p>
)}
</div>
</div>
</div>
{/* Address Information */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
<MapPin className="h-5 w-5" />
Address Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Street Number
</label>
<input
type="text"
name="member_streetnumber"
value={formData.member_streetnumber}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="123"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Street
</label>
<input
type="text"
name="member_street"
value={formData.member_street}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Main Street"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
ZIP Code
</label>
<input
type="number"
name="member_zip"
value={formData.member_zip}
onChange={handleInputChange}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.member_zip ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="12345"
/>
{errors.member_zip && (
<p className="text-red-500 text-xs mt-1">{errors.member_zip}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
City
</label>
<input
type="text"
name="member_city"
value={formData.member_city}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Anytown"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
State
</label>
<select
name="current_state_code"
value={formData.current_state_code}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select State</option>
{stateCodes.map(code => (
<option key={code} value={code}>{code}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
State Changed At
</label>
<input
type="datetime-local"
name="current_state_changed_at"
value={formData.current_state_changed_at}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
{/* Membership Information */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="text-lg font-semibold text-gray-700 mb-4 flex items-center gap-2">
<Calendar className="h-5 w-5" />
Membership Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Membership Start Date
</label>
<input
type="date"
name="member_membership_start"
value={formData.member_membership_start}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Date of Death
</label>
<input
type="date"
name="member_died"
value={formData.member_died}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
{/* Form Actions */}
<div className="flex justify-end gap-3 pt-6 border-t">
{onCancel && (
<button
type="button"
onClick={onCancel}
className="px-6 py-2 text-gray-700 bg-gray-200 hover:bg-gray-300 rounded-md transition-colors"
disabled={isLoading}
>
Cancel
</button>
)}
<button
type="button"
onClick={handleSubmit}
disabled={isLoading}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Save className="h-4 w-4" />
{isLoading ? 'Saving...' : 'Save Member'}
</button>
</div>
</form>
</div>
);
};
// Example usage component
const App = () => {
const [currentMember, setCurrentMember] = useState(null);
const [showForm, setShowForm] = useState(false);
// Example member data
const exampleMember = {
id: 1,
member_count: 2,
member_firstname: 'John',
member_firstname_spouse: 'Jane',
member_surname: 'Doe',
member_suffix: 'Jr.',
member_street: 'Main Street',
member_streetnumber: '123',
member_email: 'john.doe@example.com',
member_zip: 12345,
member_city: 'Anytown',
member_gender: 'M',
member_died: null,
member_membership_start: '2020-01-15',
member_phone: '(555) 123-4567',
current_state_code: 'CA',
current_state_changed_at: '2020-01-15T10:00:00'
};
const handleSave = async (memberData) => {
console.log('Saving member data:', memberData);
// Here you would typically make an API call to save the data
// await saveMemberToAPI(memberData);
alert('Member saved successfully!');
setShowForm(false);
};
const handleCancel = () => {
setShowForm(false);
setCurrentMember(null);
};
const handleEdit = () => {
setCurrentMember(exampleMember);
setShowForm(true);
};
const handleAddNew = () => {
setCurrentMember(null);
setShowForm(true);
};
if (showForm) {
return (
<MemberEditForm
member={currentMember}
onSave={handleSave}
onCancel={handleCancel}
/>
);
}
return (
<div className="max-w-4xl mx-auto p-6">
<div className="bg-white shadow-lg rounded-lg p-6">
<h1 className="text-3xl font-bold text-gray-800 mb-6">Member Management</h1>
<div className="space-y-4">
<div className="p-4 border border-gray-200 rounded-lg">
<h3 className="text-lg font-semibold text-gray-700 mb-2">John Doe Jr.</h3>
<p className="text-gray-600">Email: john.doe@example.com</p>
<p className="text-gray-600">Phone: (555) 123-4567</p>
<p className="text-gray-600">Address: 123 Main Street, Anytown, CA 12345</p>
<button
onClick={handleEdit}
className="mt-3 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Edit Member
</button>
</div>
<button
onClick={handleAddNew}
className="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
>
Add New Member
</button>
</div>
</div>
</div>
);
};
export default App;
+51
View File
@@ -0,0 +1,51 @@
"""Salutation and pronoun helpers for German language text generation."""
def get_gender_salutation(gender: str, capitalize: bool = False) -> str:
"""Return appropriate salutation based on gender.
Args:
gender: 'female' or any other value defaults to male
capitalize: Whether to capitalize the first letter
Returns:
'liebe' (if female) or 'lieber' (if male), optionally capitalized
"""
if gender == "female":
salutation = "liebe"
else:
salutation = "lieber"
return salutation.capitalize() if capitalize else salutation
def get_pronouns(is_plural: bool = False) -> dict:
"""Return gender-neutral pronoun set for singular or plural forms.
Args:
is_plural: If True, returns plural pronouns (euer, euch, etc.),
otherwise returns singular pronouns (Dein, Dich, etc.)
Returns:
Dictionary with pronoun variations:
- 'possessive': possessive form (Deine/eure)
- 'neutral': neutral form (Dein/euer)
- 'accusative': accusative form (Dich/euch)
- 'conditional': conditional form (Solltest Du/Solltet ihr)
- 'verb': verb form (sende/sendet)
"""
if is_plural:
return {
'possessive': 'eure',
'neutral': 'euer',
'accusative': 'euch',
'conditional': 'Solltet ihr',
'verb': 'sendet'
}
else:
return {
'possessive': 'Deine',
'neutral': 'Dein',
'accusative': 'Dich',
'conditional': 'Solltest Du',
'verb': 'sende'
}