Enhancements
This commit is contained in:
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,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;
|
||||
Reference in New Issue
Block a user