Compare commits
12 Commits
b1b9a05d4f
...
templates
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f71931d4a7 | ||
|
|
97cc5bdedf | ||
|
|
8011913c4d | ||
|
|
40a0e3f342 | ||
|
|
968eaccac2 | ||
|
|
11de3d1eed | ||
|
|
11142ec50e | ||
|
|
0249cad261 | ||
|
|
1d70ac4ec7 | ||
|
|
6d777ed583 | ||
|
|
99d65c917f | ||
|
|
0f16264656 |
3
.env.development
Normal file
3
.env.development
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
REACT_APP_BACKEND_URL=http://localhost:8000
|
||||||
|
REACT_APP_BASENAME=/membership
|
||||||
|
PUBLIC_URL=/membership
|
||||||
@@ -23,6 +23,7 @@ import AdminMembers from './pages/admin/AdminMembers';
|
|||||||
import AdminPermissions from './pages/admin/AdminPermissions';
|
import AdminPermissions from './pages/admin/AdminPermissions';
|
||||||
import AdminRoles from './pages/admin/AdminRoles';
|
import AdminRoles from './pages/admin/AdminRoles';
|
||||||
import AdminEvents from './pages/admin/AdminEvents';
|
import AdminEvents from './pages/admin/AdminEvents';
|
||||||
|
import AdminEventAttendance from './pages/admin/AdminEventAttendance';
|
||||||
import AdminValidations from './pages/admin/AdminValidations';
|
import AdminValidations from './pages/admin/AdminValidations';
|
||||||
import AdminPlans from './pages/admin/AdminPlans';
|
import AdminPlans from './pages/admin/AdminPlans';
|
||||||
import AdminSubscriptions from './pages/admin/AdminSubscriptions';
|
import AdminSubscriptions from './pages/admin/AdminSubscriptions';
|
||||||
@@ -218,6 +219,13 @@ function App() {
|
|||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/admin/events/:eventId/attendance" element={
|
||||||
|
<PrivateRoute adminOnly>
|
||||||
|
<AdminLayout>
|
||||||
|
<AdminEventAttendance />
|
||||||
|
</AdminLayout>
|
||||||
|
</PrivateRoute>
|
||||||
|
} />
|
||||||
<Route path="/admin/validations" element={
|
<Route path="/admin/validations" element={
|
||||||
<PrivateRoute adminOnly>
|
<PrivateRoute adminOnly>
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
Scale,
|
Scale,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Repeat,
|
Repeat,
|
||||||
Heart
|
Heart,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||||
@@ -204,8 +204,8 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
${item.disabled
|
${item.disabled
|
||||||
? 'opacity-50 cursor-not-allowed text-[#664fa3]'
|
? 'opacity-50 cursor-not-allowed text-[#664fa3]'
|
||||||
: active
|
: active
|
||||||
? 'bg-[#ff9e77]/10 text-[#ff9e77]'
|
? 'bg-[#ff9e77]/10 text-[#ff9e77]'
|
||||||
: 'text-[#422268] hover:bg-[#DDD8EB]/20'
|
: 'text-[#422268] hover:bg-[#DDD8EB]/20'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
@@ -265,20 +265,24 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-[#ddd8eb]">
|
<div className="flex items-center justify-between p-4 border-b border-[#ddd8eb]">
|
||||||
<div className="flex items-center gap-3">
|
<Link to="/" className="flex items-center gap-3 group flex-1 min-w-0">
|
||||||
<img
|
<img
|
||||||
src={`${process.env.PUBLIC_URL}/loaf-logo.png`}
|
src={`${process.env.PUBLIC_URL}/loaf-logo.png`}
|
||||||
alt="LOAF Logo"
|
alt="LOAF Logo"
|
||||||
className={`object-contain transition-all duration-200 ${
|
className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8'
|
||||||
isOpen ? 'h-10 w-10' : 'h-8 w-8'
|
}`}
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<div className="flex-1 min-w-0">
|
||||||
Admin
|
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
</h2>
|
Admin
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[#664fa3] group-hover:text-[#ff9e77] transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
View Public Site
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className="p-2 rounded-lg hover:bg-[#DDD8EB]/20 transition-colors"
|
className="p-2 rounded-lg hover:bg-[#DDD8EB]/20 transition-colors"
|
||||||
@@ -295,7 +299,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 overflow-y-auto p-4">
|
<nav className="flex-1 overflow-y-auto p-4 scrollbar-dashboard scrollbar-x-dashboard">
|
||||||
{/* Dashboard - Standalone */}
|
{/* Dashboard - Standalone */}
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))}
|
{renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))}
|
||||||
|
|
||||||
@@ -365,7 +369,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
{/* User Section */}
|
{/* User Section */}
|
||||||
<div className="border-t border-[#ddd8eb] p-4 space-y-2">
|
<div className="border-t border-[#ddd8eb] p-4 space-y-2">
|
||||||
{isOpen && user && (
|
{isOpen && user && (
|
||||||
<div className="px-4 py-3 mb-2">
|
<div className="px-4 py-3 mb-2 flex justify-between items-center">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-10 w-10 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold">
|
<div className="h-10 w-10 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold">
|
||||||
{user.first_name?.[0]}{user.last_name?.[0]}
|
{user.first_name?.[0]}{user.last_name?.[0]}
|
||||||
@@ -379,6 +383,8 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Link to='/profile'><Settings size={16} />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -392,11 +398,10 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-[#ddd8eb] rounded-full h-2">
|
<div className="w-full bg-[#ddd8eb] rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className={`h-2 rounded-full transition-all ${
|
className={`h-2 rounded-full transition-all ${storagePercentage > 90 ? 'bg-red-500' :
|
||||||
storagePercentage > 90 ? 'bg-red-500' :
|
|
||||||
storagePercentage > 75 ? 'bg-yellow-500' :
|
storagePercentage > 75 ? 'bg-yellow-500' :
|
||||||
'bg-[#81B29A]'
|
'bg-[#81B29A]'
|
||||||
}`}
|
}`}
|
||||||
style={{ width: `${storagePercentage}%` }}
|
style={{ width: `${storagePercentage}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -407,11 +412,10 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<HardDrive className={`h-5 w-5 ${
|
<HardDrive className={`h-5 w-5 ${storagePercentage > 90 ? 'text-red-500' :
|
||||||
storagePercentage > 90 ? 'text-red-500' :
|
|
||||||
storagePercentage > 75 ? 'text-yellow-500' :
|
storagePercentage > 75 ? 'text-yellow-500' :
|
||||||
'text-[#664fa3]'
|
'text-[#664fa3]'
|
||||||
}`} />
|
}`} />
|
||||||
{storagePercentage > 75 && (
|
{storagePercentage > 75 && (
|
||||||
<div className="absolute -top-1 -right-1 bg-red-500 h-2 w-2 rounded-full" />
|
<div className="absolute -top-1 -right-1 bg-red-500 h-2 w-2 rounded-full" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -40,15 +40,14 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
|||||||
const fetchRoles = async () => {
|
const fetchRoles = async () => {
|
||||||
setLoadingRoles(true);
|
setLoadingRoles(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/admin/roles');
|
// New endpoint returns roles based on user's permission level
|
||||||
// Filter to show only admin-type roles (not guest or member)
|
// Superadmin: all roles
|
||||||
const staffRoles = response.data.filter(role =>
|
// Admin: admin, finance, and non-elevated custom roles
|
||||||
['admin', 'superadmin', 'finance'].includes(role.code) || !role.is_system_role
|
const response = await api.get('/admin/roles/assignable');
|
||||||
);
|
setRoles(response.data);
|
||||||
setRoles(staffRoles);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch roles:', error);
|
console.error('Failed to fetch assignable roles:', error);
|
||||||
toast.error('Failed to load roles');
|
toast.error('Failed to load roles. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingRoles(false);
|
setLoadingRoles(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
|||||||
<label htmlFor="accepts_tos" className="text-sm text-gray-700" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<label htmlFor="accepts_tos" className="text-sm text-gray-700" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
I agree to the{' '}
|
I agree to the{' '}
|
||||||
<a
|
<a
|
||||||
href="/membership/terms-of-service"
|
href="/become-a-member/terms-of-service"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
|
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
|
||||||
@@ -95,7 +95,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
|||||||
</a>
|
</a>
|
||||||
{' '}and{' '}
|
{' '}and{' '}
|
||||||
<a
|
<a
|
||||||
href="/membership/privacy-policy"
|
href="become-a-member/privacy-policy"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
|
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
|
||||||
|
|||||||
@@ -1,31 +1,33 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Tabs = TabsPrimitive.Root
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
"inline-flex h-full items-center justify-center rounded-lg gap-6 p-1 text-muted-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props} />
|
{...props}
|
||||||
))
|
/>
|
||||||
TabsList.displayName = TabsPrimitive.List.displayName
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
"inline-flex items-center justify-center whitespace-nowrap hover:bg-[#f1eef9] border-2 border-[#664fa3] rounded-2xl px-3 py-1 text-[#664fa3] text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-foreground data-[state=active]:text-background data-[state=active]:border-foreground data-[state=active]:shadow",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props} />
|
{...props}
|
||||||
))
|
/>
|
||||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
@@ -34,8 +36,9 @@ const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props} />
|
{...props}
|
||||||
))
|
/>
|
||||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
|
|||||||
@@ -116,3 +116,27 @@ code {
|
|||||||
border-bottom-color: inherit;
|
border-bottom-color: inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
@supports selector(::-webkit-scrollbar) {
|
||||||
|
.scrollbar-dashboard::-webkit-scrollbar {
|
||||||
|
width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-dashboard::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #ddd8eb;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
.scrollbar-x-dashboard::-webkit-scrollbar:horizontal {
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-x-dashboard::-webkit-scrollbar-thumb:horizontal {
|
||||||
|
background-color: #ddd8eb;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
.hide-scrollbar-x::-webkit-scrollbar:horizontal {
|
||||||
|
height: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ const BoardOfDirectors = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const boardMembers = [
|
const boardMembers = [
|
||||||
{ name: 'Danita Cole' },
|
{ name: 'Danita Cole', title: 'Director' },
|
||||||
{ name: 'Roxanne Cherico' },
|
{ name: 'Roxanne Cherico', title: 'Director' },
|
||||||
{ name: 'Lucretia Copeland' },
|
{ name: 'Lucretia Copeland', title: 'Director' },
|
||||||
{ name: 'Julie Fischer' }
|
{ name: 'Julie Fischer', title: 'Director' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
@@ -112,50 +112,50 @@ const BoardOfDirectors = () => {
|
|||||||
Our elections take place at our December holiday social. Here are some things to know if you are thinking about serving on the Board of Directors.
|
Our elections take place at our December holiday social. Here are some things to know if you are thinking about serving on the Board of Directors.
|
||||||
</p>
|
</p>
|
||||||
{/* card */}
|
{/* card */}
|
||||||
<Card className="bg-[#eeebf4] p-8 rounded-2xl shadow-lg mx-auto border border-white/70">
|
<Card className="bg-[#eeebf4] p-8 rounded-2xl shadow-lg mx-auto border border-white/70">
|
||||||
<ol className="list-decimal list-inside space-y-4 text-lg text-[#48286e]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<ol className="list-decimal list-inside space-y-4 text-lg text-[#48286e]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
<li>
|
<li>
|
||||||
Nominations are due by November 1. Nomination Form:{' '}
|
Nominations are due by November 1. Nomination Form:{' '}
|
||||||
<a href="https://docs.google.com/forms/d/e/1FAIpQLSfNomination" target="_blank" rel="noopener noreferrer"
|
<a href="https://docs.google.com/forms/d/e/1FAIpQLSfNomination" target="_blank" rel="noopener noreferrer"
|
||||||
className="text-[#664fa3] underline hover:text-[#48286e] transition-colors">
|
className="text-[#664fa3] underline hover:text-[#48286e] transition-colors">
|
||||||
Click Here
|
Click Here
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>Nominees must have been a member for at least 1 year, however it is possible to be elected prior to 1 year, but start the term on the 1 year anniversary.</li>
|
<li>Nominees must have been a member for at least 1 year, however it is possible to be elected prior to 1 year, but start the term on the 1 year anniversary.</li>
|
||||||
<li>Officer positions are only available to current directors.</li>
|
<li>Officer positions are only available to current directors.</li>
|
||||||
|
|
||||||
<li>Each director shall serve a 2-year term.</li>
|
<li>Each director shall serve a 2-year term.</li>
|
||||||
|
|
||||||
<li>The time commitment is approximately 1–2 hours per week.</li>
|
<li>The time commitment is approximately 1–2 hours per week.</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
The tasks that directors perform depend on individual interests. Recent
|
The tasks that directors perform depend on individual interests. Recent
|
||||||
tasks include researching how to obtain an extra PO Box key, ordering
|
tasks include researching how to obtain an extra PO Box key, ordering
|
||||||
Welcome Team name tags, taking pictures at events, researching new venues
|
Welcome Team name tags, taking pictures at events, researching new venues
|
||||||
for holiday socials, and monitoring Facebook posts. For more information
|
for holiday socials, and monitoring Facebook posts. For more information
|
||||||
about director duties, see Article 2 of the bylaws in the Members Only
|
about director duties, see Article 2 of the bylaws in the Members Only
|
||||||
section of the website:
|
section of the website:
|
||||||
<a
|
<a
|
||||||
href="https://loaftx.org/bylaws/"
|
href="https://loaftx.org/bylaws/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-[#48286e] underline"
|
className="text-[#48286e] underline"
|
||||||
>
|
>
|
||||||
https://loaftx.org/bylaws/
|
https://loaftx.org/bylaws/
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
Directors must attend Board meetings held on the second Thursday of each
|
Directors must attend Board meetings held on the second Thursday of each
|
||||||
month at 6:30pm via Zoom.
|
month at 6:30pm via Zoom.
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
We are a fun group, and we would love for you to join us in providing this
|
We are a fun group, and we would love for you to join us in providing this
|
||||||
service for our community.
|
service for our community.
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -253,7 +253,13 @@ const Plans = () => {
|
|||||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
|
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
|
||||||
</div>
|
</div>
|
||||||
) : plans.length > 0 ? (
|
) : plans.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8 max-w-5xl mx-auto">
|
<div className={`grid gap-6 sm:gap-8 mx-auto ${
|
||||||
|
plans.length === 1
|
||||||
|
? 'grid-cols-1 max-w-md'
|
||||||
|
: plans.length === 2
|
||||||
|
? 'grid-cols-1 sm:grid-cols-2 max-w-3xl'
|
||||||
|
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 max-w-5xl'
|
||||||
|
}`}>
|
||||||
{plans.map((plan) => {
|
{plans.map((plan) => {
|
||||||
const minimumPrice = plan.minimum_price_cents || plan.price_cents || 3000;
|
const minimumPrice = plan.minimum_price_cents || plan.price_cents || 3000;
|
||||||
const suggestedPrice = plan.suggested_price_cents || minimumPrice;
|
const suggestedPrice = plan.suggested_price_cents || minimumPrice;
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import React from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import PublicNavbar from '../components/PublicNavbar';
|
import PublicNavbar from '../components/PublicNavbar';
|
||||||
import PublicFooter from '../components/PublicFooter';
|
import PublicFooter from '../components/PublicFooter';
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
|
|
||||||
|
|
||||||
export default function TermsOfService() {
|
export default function TermsOfService() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const AdminBylaws = () => {
|
const AdminBylaws = () => {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
const [bylaws, setBylaws] = useState([]);
|
const [bylaws, setBylaws] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
@@ -184,13 +186,15 @@ const AdminBylaws = () => {
|
|||||||
Manage LOAF governing bylaws and version history
|
Manage LOAF governing bylaws and version history
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{hasPermission('bylaws.create') && (
|
||||||
onClick={handleCreate}
|
<Button
|
||||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
onClick={handleCreate}
|
||||||
>
|
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||||
<Plus className="h-4 w-4" />
|
>
|
||||||
Add Version
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
Add Version
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current Bylaws */}
|
{/* Current Bylaws */}
|
||||||
@@ -226,22 +230,26 @@ const AdminBylaws = () => {
|
|||||||
<ExternalLink className="h-4 w-4 mr-1" />
|
<ExternalLink className="h-4 w-4 mr-1" />
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{hasPermission('bylaws.edit') && (
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => handleEdit(currentBylaws)}
|
size="sm"
|
||||||
className="border-[#664fa3] text-[#664fa3]"
|
onClick={() => handleEdit(currentBylaws)}
|
||||||
>
|
className="border-[#664fa3] text-[#664fa3]"
|
||||||
<Edit className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<Edit className="h-4 w-4" />
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
)}
|
||||||
size="sm"
|
{hasPermission('bylaws.delete') && (
|
||||||
onClick={() => handleDelete(currentBylaws)}
|
<Button
|
||||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
variant="outline"
|
||||||
>
|
size="sm"
|
||||||
<Trash2 className="h-4 w-4" />
|
onClick={() => handleDelete(currentBylaws)}
|
||||||
</Button>
|
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm text-[#664fa3]">
|
<div className="flex items-center gap-4 text-sm text-[#664fa3]">
|
||||||
@@ -254,10 +262,12 @@ const AdminBylaws = () => {
|
|||||||
<Card className="p-12 text-center">
|
<Card className="p-12 text-center">
|
||||||
<Scale className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
<Scale className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||||
<p className="text-[#664fa3] text-lg mb-4">No current bylaws set</p>
|
<p className="text-[#664fa3] text-lg mb-4">No current bylaws set</p>
|
||||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
{hasPermission('bylaws.create') && (
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||||
Create Bylaws
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Create Bylaws
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -290,22 +300,26 @@ const AdminBylaws = () => {
|
|||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{hasPermission('bylaws.edit') && (
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => handleEdit(bylawsDoc)}
|
size="sm"
|
||||||
className="border-[#664fa3] text-[#664fa3]"
|
onClick={() => handleEdit(bylawsDoc)}
|
||||||
>
|
className="border-[#664fa3] text-[#664fa3]"
|
||||||
<Edit className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<Edit className="h-4 w-4" />
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
)}
|
||||||
size="sm"
|
{hasPermission('bylaws.delete') && (
|
||||||
onClick={() => handleDelete(bylawsDoc)}
|
<Button
|
||||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
variant="outline"
|
||||||
>
|
size="sm"
|
||||||
<Trash2 className="h-4 w-4" />
|
onClick={() => handleDelete(bylawsDoc)}
|
||||||
</Button>
|
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import api from '../../utils/api';
|
|||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
import { Badge } from '../../components/ui/badge';
|
import { Badge } from '../../components/ui/badge';
|
||||||
import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle } from 'lucide-react';
|
import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle,Globe } from 'lucide-react';
|
||||||
|
|
||||||
const AdminDashboard = () => {
|
const AdminDashboard = () => {
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
@@ -42,8 +42,8 @@ const AdminDashboard = () => {
|
|||||||
}).map(u => ({
|
}).map(u => ({
|
||||||
...u,
|
...u,
|
||||||
totalReminders: (u.email_verification_reminders_sent || 0) +
|
totalReminders: (u.email_verification_reminders_sent || 0) +
|
||||||
(u.event_attendance_reminders_sent || 0) +
|
(u.event_attendance_reminders_sent || 0) +
|
||||||
(u.payment_reminders_sent || 0)
|
(u.payment_reminders_sent || 0)
|
||||||
})).sort((a, b) => b.totalReminders - a.totalReminders).slice(0, 5); // Top 5
|
})).sort((a, b) => b.totalReminders - a.totalReminders).slice(0, 5); // Top 5
|
||||||
|
|
||||||
setUsersNeedingAttention(needingAttention);
|
setUsersNeedingAttention(needingAttention);
|
||||||
@@ -56,173 +56,183 @@ const AdminDashboard = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-8">
|
<div className='flex justify-between items-center'>
|
||||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<div className="mb-8">
|
||||||
Admin Dashboard
|
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
</h1>
|
Admin Dashboard
|
||||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
</h1>
|
||||||
Manage users, events, and membership applications.
|
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
</p>
|
Manage users, events, and membership applications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link to={'/'}>
|
||||||
|
<Button
|
||||||
|
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Globe />
|
||||||
|
View Public Site
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
|
||||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-total-users">
|
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-total-users">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg">
|
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg">
|
||||||
<Users className="h-6 w-6 text-[#664fa3]" />
|
<Users className="h-6 w-6 text-[#664fa3]" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
</div>
|
||||||
{loading ? '-' : stats.totalMembers}
|
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
</p>
|
{loading ? '-' : stats.totalMembers}
|
||||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p>
|
</p>
|
||||||
</Card>
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-pending-validations">
|
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-pending-validations">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="bg-orange-100 p-3 rounded-lg">
|
<div className="bg-orange-100 p-3 rounded-lg">
|
||||||
<Clock className="h-6 w-6 text-orange-600" />
|
<Clock className="h-6 w-6 text-orange-600" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
</div>
|
||||||
{loading ? '-' : stats.pendingValidations}
|
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
</p>
|
{loading ? '-' : stats.pendingValidations}
|
||||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validations</p>
|
</p>
|
||||||
</Card>
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validations</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-active-members">
|
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-active-members">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="bg-[#81B29A]/20 p-3 rounded-lg">
|
<div className="bg-[#81B29A]/20 p-3 rounded-lg">
|
||||||
<CheckCircle className="h-6 w-6 text-[#81B29A]" />
|
<CheckCircle className="h-6 w-6 text-[#81B29A]" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
</div>
|
||||||
{loading ? '-' : stats.activeMembers}
|
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
{loading ? '-' : stats.activeMembers}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active Members</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
|
<Link to="/admin/members">
|
||||||
|
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-users">
|
||||||
|
<Users className="h-12 w-12 text-[#664fa3] mb-4" />
|
||||||
|
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Manage Members
|
||||||
|
</h3>
|
||||||
|
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
View and manage paying members and their subscription status.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active Members</p>
|
<Button
|
||||||
|
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
|
||||||
|
data-testid="manage-users-button"
|
||||||
|
>
|
||||||
|
Go to Members
|
||||||
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</Link>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
<Link to="/admin/validations">
|
||||||
<div className="grid md:grid-cols-2 gap-8">
|
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-validations">
|
||||||
<Link to="/admin/members">
|
<Clock className="h-12 w-12 text-orange-600 mb-4" />
|
||||||
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-users">
|
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
<Users className="h-12 w-12 text-[#664fa3] mb-4" />
|
Validation Queue
|
||||||
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
</h3>
|
||||||
Manage Members
|
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
</h3>
|
Review and validate pending membership applications.
|
||||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
</p>
|
||||||
View and manage paying members and their subscription status.
|
<Button
|
||||||
</p>
|
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
|
||||||
<Button
|
data-testid="manage-validations-button"
|
||||||
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
|
>
|
||||||
data-testid="manage-users-button"
|
View Validations
|
||||||
>
|
</Button>
|
||||||
Go to Members
|
</Card>
|
||||||
</Button>
|
</Link>
|
||||||
</Card>
|
</div>
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/admin/validations">
|
{/* Users Needing Attention Widget */}
|
||||||
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-validations">
|
{usersNeedingAttention.length > 0 && (
|
||||||
<Clock className="h-12 w-12 text-orange-600 mb-4" />
|
<div className="mt-12">
|
||||||
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<Card className="p-8 bg-white rounded-2xl border-2 border-[#ff9e77] shadow-lg">
|
||||||
Validation Queue
|
<div className="flex items-center gap-3 mb-6">
|
||||||
</h3>
|
<div className="bg-[#ff9e77]/20 p-3 rounded-lg">
|
||||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<AlertCircle className="h-6 w-6 text-[#ff9e77]" />
|
||||||
Review and validate pending membership applications.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
|
|
||||||
data-testid="manage-validations-button"
|
|
||||||
>
|
|
||||||
View Validations
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Users Needing Attention Widget */}
|
|
||||||
{usersNeedingAttention.length > 0 && (
|
|
||||||
<div className="mt-12">
|
|
||||||
<Card className="p-8 bg-white rounded-2xl border-2 border-[#ff9e77] shadow-lg">
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<div className="bg-[#ff9e77]/20 p-3 rounded-lg">
|
|
||||||
<AlertCircle className="h-6 w-6 text-[#ff9e77]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Members Needing Personal Outreach
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
These members have received multiple reminder emails. Consider calling them directly.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<div className="space-y-4">
|
<h3 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
{usersNeedingAttention.map(user => (
|
Members Needing Personal Outreach
|
||||||
<Link key={user.id} to={`/admin/users/${user.id}`}>
|
</h3>
|
||||||
<div className="p-4 bg-[#F8F7FB] rounded-xl border border-[#ddd8eb] hover:border-[#ff9e77] hover:shadow-md transition-all cursor-pointer">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<h4 className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
{user.first_name} {user.last_name}
|
|
||||||
</h4>
|
|
||||||
<Badge className="bg-[#ff9e77] text-white px-3 py-1 rounded-full text-xs">
|
|
||||||
{user.totalReminders} reminder{user.totalReminders !== 1 ? 's' : ''}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
<p>Email: {user.email}</p>
|
|
||||||
<p>Phone: {user.phone || 'N/A'}</p>
|
|
||||||
<p className="capitalize">Status: {user.status.replace('_', ' ')}</p>
|
|
||||||
{user.email_verification_reminders_sent > 0 && (
|
|
||||||
<p>
|
|
||||||
<Mail className="inline h-3 w-3 mr-1" />
|
|
||||||
{user.email_verification_reminders_sent} email verification reminder{user.email_verification_reminders_sent !== 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{user.event_attendance_reminders_sent > 0 && (
|
|
||||||
<p>
|
|
||||||
<Calendar className="inline h-3 w-3 mr-1" />
|
|
||||||
{user.event_attendance_reminders_sent} event reminder{user.event_attendance_reminders_sent !== 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{user.payment_reminders_sent > 0 && (
|
|
||||||
<p>
|
|
||||||
<Clock className="inline h-3 w-3 mr-1" />
|
|
||||||
{user.payment_reminders_sent} payment reminder{user.payment_reminders_sent !== 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full text-sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
window.location.href = `tel:${user.phone}`;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Call Member
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 p-4 bg-[#DDD8EB]/20 rounded-lg border border-[#ddd8eb]">
|
|
||||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
<strong>💡 Tip for helping older members:</strong> Many of our members are older ladies who may struggle with email.
|
These members have received multiple reminder emails. Consider calling them directly.
|
||||||
A friendly phone call can help them complete the registration process and feel more welcomed to the community.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
<div className="space-y-4">
|
||||||
|
{usersNeedingAttention.map(user => (
|
||||||
|
<Link key={user.id} to={`/admin/users/${user.id}`}>
|
||||||
|
<div className="p-4 bg-[#F8F7FB] rounded-xl border border-[#ddd8eb] hover:border-[#ff9e77] hover:shadow-md transition-all cursor-pointer">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h4 className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
{user.first_name} {user.last_name}
|
||||||
|
</h4>
|
||||||
|
<Badge className="bg-[#ff9e77] text-white px-3 py-1 rounded-full text-xs">
|
||||||
|
{user.totalReminders} reminder{user.totalReminders !== 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
<p>Email: {user.email}</p>
|
||||||
|
<p>Phone: {user.phone || 'N/A'}</p>
|
||||||
|
<p className="capitalize">Status: {user.status.replace('_', ' ')}</p>
|
||||||
|
{user.email_verification_reminders_sent > 0 && (
|
||||||
|
<p>
|
||||||
|
<Mail className="inline h-3 w-3 mr-1" />
|
||||||
|
{user.email_verification_reminders_sent} email verification reminder{user.email_verification_reminders_sent !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{user.event_attendance_reminders_sent > 0 && (
|
||||||
|
<p>
|
||||||
|
<Calendar className="inline h-3 w-3 mr-1" />
|
||||||
|
{user.event_attendance_reminders_sent} event reminder{user.event_attendance_reminders_sent !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{user.payment_reminders_sent > 0 && (
|
||||||
|
<p>
|
||||||
|
<Clock className="inline h-3 w-3 mr-1" />
|
||||||
|
{user.payment_reminders_sent} payment reminder{user.payment_reminders_sent !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full text-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
window.location.href = `tel:${user.phone}`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Call Member
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 p-4 bg-[#DDD8EB]/20 rounded-lg border border-[#ddd8eb]">
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
<strong>💡 Tip for helping older members:</strong> Many of our members are older ladies who may struggle with email.
|
||||||
|
A friendly phone call can help them complete the registration process and feel more welcomed to the community.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
import { Input } from '../../components/ui/input';
|
import { Input } from '../../components/ui/input';
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const AdminDonations = () => {
|
const AdminDonations = () => {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
const [donations, setDonations] = useState([]);
|
const [donations, setDonations] = useState([]);
|
||||||
const [filteredDonations, setFilteredDonations] = useState([]);
|
const [filteredDonations, setFilteredDonations] = useState([]);
|
||||||
const [stats, setStats] = useState({});
|
const [stats, setStats] = useState({});
|
||||||
@@ -269,33 +271,35 @@ const AdminDonations = () => {
|
|||||||
className="pl-10 rounded-full border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
className="pl-10 rounded-full border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
{hasPermission('donations.export') && (
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button
|
<DropdownMenuTrigger asChild>
|
||||||
disabled={exporting}
|
<Button
|
||||||
className="bg-[#81B29A] text-white hover:bg-[#6a9680] rounded-full px-6 py-3 flex items-center gap-2"
|
disabled={exporting}
|
||||||
>
|
className="bg-[#81B29A] text-white hover:bg-[#6a9680] rounded-full px-6 py-3 flex items-center gap-2"
|
||||||
<Download className="h-4 w-4" />
|
>
|
||||||
{exporting ? 'Exporting...' : 'Export'}
|
<Download className="h-4 w-4" />
|
||||||
</Button>
|
{exporting ? 'Exporting...' : 'Export'}
|
||||||
</DropdownMenuTrigger>
|
</Button>
|
||||||
<DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg">
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem
|
<DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg">
|
||||||
onClick={() => handleExport('all')}
|
<DropdownMenuItem
|
||||||
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
onClick={() => handleExport('all')}
|
||||||
>
|
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
||||||
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
>
|
||||||
<span className="text-[#422268]">Export All Donations</span>
|
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
||||||
</DropdownMenuItem>
|
<span className="text-[#422268]">Export All Donations</span>
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
onClick={() => handleExport('current')}
|
<DropdownMenuItem
|
||||||
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
onClick={() => handleExport('current')}
|
||||||
>
|
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
||||||
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
>
|
||||||
<span className="text-[#422268]">Export Current View</span>
|
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
||||||
</DropdownMenuItem>
|
<span className="text-[#422268]">Export Current View</span>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters Row */}
|
{/* Filters Row */}
|
||||||
|
|||||||
548
src/pages/admin/AdminEventAttendance.js
Normal file
548
src/pages/admin/AdminEventAttendance.js
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import api from '../../utils/api';
|
||||||
|
import { Card } from '../../components/ui/card';
|
||||||
|
import { Button } from '../../components/ui/button';
|
||||||
|
import { Input } from '../../components/ui/input';
|
||||||
|
import { Badge } from '../../components/ui/badge';
|
||||||
|
import { Checkbox } from '../../components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Calendar,
|
||||||
|
MapPin,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Search,
|
||||||
|
Users,
|
||||||
|
UserCheck,
|
||||||
|
UserX,
|
||||||
|
HelpCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
const AdminEventAttendance = () => {
|
||||||
|
const { eventId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [event, setEvent] = useState(null);
|
||||||
|
const [rsvps, setRsvps] = useState([]);
|
||||||
|
const [filteredRsvps, setFilteredRsvps] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Filters and search
|
||||||
|
const [activeTab, setActiveTab] = useState('all');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Bulk selection
|
||||||
|
const [selectedRsvps, setSelectedRsvps] = useState(new Set());
|
||||||
|
const [selectAll, setSelectAll] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEventAndRsvps();
|
||||||
|
}, [eventId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
filterRsvps();
|
||||||
|
}, [rsvps, activeTab, searchQuery]);
|
||||||
|
|
||||||
|
const fetchEventAndRsvps = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [eventRes, rsvpsRes] = await Promise.all([
|
||||||
|
api.get(`/admin/events/${eventId}`),
|
||||||
|
api.get(`/admin/events/${eventId}/rsvps`)
|
||||||
|
]);
|
||||||
|
setEvent(eventRes.data);
|
||||||
|
setRsvps(rsvpsRes.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch event data:', error);
|
||||||
|
toast.error('Failed to load event data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterRsvps = () => {
|
||||||
|
let filtered = [...rsvps];
|
||||||
|
|
||||||
|
// Filter by RSVP status tab
|
||||||
|
if (activeTab !== 'all') {
|
||||||
|
filtered = filtered.filter(rsvp => rsvp.rsvp_status === activeTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.filter(rsvp =>
|
||||||
|
rsvp.user_name?.toLowerCase().includes(query) ||
|
||||||
|
rsvp.user_email?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredRsvps(filtered);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (selectAll) {
|
||||||
|
setSelectedRsvps(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedRsvps(new Set(filteredRsvps.map(rsvp => rsvp.user_id)));
|
||||||
|
}
|
||||||
|
setSelectAll(!selectAll);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectRsvp = (userId) => {
|
||||||
|
const newSelected = new Set(selectedRsvps);
|
||||||
|
if (newSelected.has(userId)) {
|
||||||
|
newSelected.delete(userId);
|
||||||
|
} else {
|
||||||
|
newSelected.add(userId);
|
||||||
|
}
|
||||||
|
setSelectedRsvps(newSelected);
|
||||||
|
setSelectAll(newSelected.size === filteredRsvps.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkAttendance = async (attended) => {
|
||||||
|
if (selectedRsvps.size === 0) {
|
||||||
|
toast.error('Please select at least one RSVP');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const updates = Array.from(selectedRsvps).map(userId => ({
|
||||||
|
user_id: userId,
|
||||||
|
attended
|
||||||
|
}));
|
||||||
|
|
||||||
|
await api.put(`/admin/events/${eventId}/attendance`, { updates });
|
||||||
|
|
||||||
|
toast.success(`Marked ${selectedRsvps.size} ${selectedRsvps.size === 1 ? 'person' : 'people'} as ${attended ? 'attended' : 'not attended'}`);
|
||||||
|
|
||||||
|
// Refresh data
|
||||||
|
await fetchEventAndRsvps();
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
setSelectedRsvps(new Set());
|
||||||
|
setSelectAll(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update attendance:', error);
|
||||||
|
toast.error('Failed to update attendance');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIndividualAttendance = async (userId, attended) => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const updates = [{
|
||||||
|
user_id: userId,
|
||||||
|
attended
|
||||||
|
}];
|
||||||
|
|
||||||
|
await api.put(`/admin/events/${eventId}/attendance`, { updates });
|
||||||
|
|
||||||
|
toast.success(`Attendance ${attended ? 'confirmed' : 'removed'}`);
|
||||||
|
|
||||||
|
// Refresh data
|
||||||
|
await fetchEventAndRsvps();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update attendance:', error);
|
||||||
|
toast.error('Failed to update attendance');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportToCSV = () => {
|
||||||
|
if (filteredRsvps.length === 0) {
|
||||||
|
toast.error('No RSVPs to export');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV header
|
||||||
|
const headers = ['Name', 'Email', 'RSVP Status', 'Attended', 'Attended At'];
|
||||||
|
|
||||||
|
// CSV rows
|
||||||
|
const rows = filteredRsvps.map(rsvp => [
|
||||||
|
`"${rsvp.user_name}"`,
|
||||||
|
`"${rsvp.user_email}"`,
|
||||||
|
`"${rsvp.rsvp_status.toUpperCase()}"`,
|
||||||
|
rsvp.attended ? 'Yes' : 'No',
|
||||||
|
rsvp.attended_at ? `"${moment(rsvp.attended_at).format('YYYY-MM-DD HH:mm A')}"` : ''
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Combine headers and rows
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
...rows.map(row => row.join(','))
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// Create blob and download
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', `${event?.title.replace(/\s+/g, '_')}_RSVPs_${moment().format('YYYY-MM-DD')}.csv`);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
toast.success('CSV exported successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStats = () => {
|
||||||
|
const total = rsvps.length;
|
||||||
|
const yesCount = rsvps.filter(r => r.rsvp_status === 'yes').length;
|
||||||
|
const noCount = rsvps.filter(r => r.rsvp_status === 'no').length;
|
||||||
|
const maybeCount = rsvps.filter(r => r.rsvp_status === 'maybe').length;
|
||||||
|
const attendedCount = rsvps.filter(r => r.attended).length;
|
||||||
|
|
||||||
|
return { total, yesCount, noCount, maybeCount, attendedCount };
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = getStats();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-[#664fa3]">Loading event data...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-[#664fa3] mb-4">Event not found</p>
|
||||||
|
<Button onClick={() => navigate('/admin/events')} className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl">
|
||||||
|
Back to Events
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate('/admin/events')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-[#ddd8eb] text-[#664fa3] rounded-xl"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Back to Events
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Event Attendance
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#664fa3] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Manage RSVPs and track attendance for this event
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={exportToCSV}
|
||||||
|
className="bg-[#81B29A] hover:bg-[#6a9a83] text-white rounded-xl"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Export to CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event Details Card */}
|
||||||
|
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
{event.title}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-4 text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>{moment(event.start_at).format('MMMM D, YYYY [at] h:mm A')}</span>
|
||||||
|
</div>
|
||||||
|
{event.location && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
<span>{event.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className={`${event.published ? 'bg-[#81B29A]' : 'bg-[#ddd8eb]'} text-white px-3 py-1`}>
|
||||||
|
{event.published ? 'Published' : 'Draft'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Statistics Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Users className="h-8 w-8 text-[#664fa3]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total RSVPs</p>
|
||||||
|
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserCheck className="h-8 w-8 text-[#81B29A]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Yes</p>
|
||||||
|
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.yesCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserX className="h-8 w-8 text-[#E07A5F]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No</p>
|
||||||
|
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.noCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<HelpCircle className="h-8 w-8 text-[#F2CC8F]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Maybe</p>
|
||||||
|
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.maybeCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Check className="h-8 w-8 text-[#664fa3]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Attended</p>
|
||||||
|
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.attendedCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters and Actions */}
|
||||||
|
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Tab Filters */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
variant={activeTab === 'all' ? 'default' : 'outline'}
|
||||||
|
className={`rounded-xl ${
|
||||||
|
activeTab === 'all'
|
||||||
|
? 'bg-[#664fa3] hover:bg-[#422268] text-white'
|
||||||
|
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
All ({stats.total})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('yes')}
|
||||||
|
variant={activeTab === 'yes' ? 'default' : 'outline'}
|
||||||
|
className={`rounded-xl ${
|
||||||
|
activeTab === 'yes'
|
||||||
|
? 'bg-[#81B29A] hover:bg-[#6a9a83] text-white'
|
||||||
|
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
Yes ({stats.yesCount})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('no')}
|
||||||
|
variant={activeTab === 'no' ? 'default' : 'outline'}
|
||||||
|
className={`rounded-xl ${
|
||||||
|
activeTab === 'no'
|
||||||
|
? 'bg-[#E07A5F] hover:bg-[#d16b54] text-white'
|
||||||
|
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
No ({stats.noCount})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('maybe')}
|
||||||
|
variant={activeTab === 'maybe' ? 'default' : 'outline'}
|
||||||
|
className={`rounded-xl ${
|
||||||
|
activeTab === 'maybe'
|
||||||
|
? 'bg-[#F2CC8F] hover:bg-[#e8bf7a] text-[#422268]'
|
||||||
|
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
Maybe ({stats.maybeCount})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Bulk Actions */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||||
|
<div className="flex-1 min-w-[200px] max-w-md relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#664fa3]" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 border-[#ddd8eb] rounded-xl"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRsvps.size > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Badge className="bg-[#664fa3] text-white px-3 py-1">
|
||||||
|
{selectedRsvps.size} selected
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleBulkAttendance(true)}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-[#81B29A] hover:bg-[#6a9a83] text-white rounded-xl"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 mr-1" />
|
||||||
|
Mark Attended
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleBulkAttendance(false)}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-[#E07A5F] hover:bg-[#d16b54] text-white rounded-xl"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Mark Not Attended
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* RSVP Table */}
|
||||||
|
<Card className="bg-white border-[#ddd8eb] rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-[#F8F7FB] border-b border-[#ddd8eb]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectAll}
|
||||||
|
onCheckedChange={handleSelectAll}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
RSVP Status
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-center text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Attendance
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Attended At
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredRsvps.length > 0 ? (
|
||||||
|
filteredRsvps.map((rsvp) => (
|
||||||
|
<tr key={rsvp.user_id} className="border-b border-[#ddd8eb] hover:bg-[#F8F7FB] transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRsvps.has(rsvp.user_id)}
|
||||||
|
onCheckedChange={() => handleSelectRsvp(rsvp.user_id)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{rsvp.user_name}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{rsvp.user_email}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Badge
|
||||||
|
className={`${
|
||||||
|
rsvp.rsvp_status === 'yes'
|
||||||
|
? 'bg-[#81B29A]'
|
||||||
|
: rsvp.rsvp_status === 'no'
|
||||||
|
? 'bg-[#E07A5F]'
|
||||||
|
: 'bg-[#F2CC8F] text-[#422268]'
|
||||||
|
} text-white text-xs px-2 py-1`}
|
||||||
|
>
|
||||||
|
{rsvp.rsvp_status.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{rsvp.attended ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleIndividualAttendance(rsvp.user_id, false)}
|
||||||
|
disabled={saving}
|
||||||
|
size="sm"
|
||||||
|
className="bg-[#81B29A] hover:bg-[#6a9a83] text-white rounded-lg min-w-[120px]"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
Attended
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleIndividualAttendance(rsvp.user_id, true)}
|
||||||
|
disabled={saving}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-[#ddd8eb] text-[#664fa3] hover:bg-[#81B29A] hover:text-white hover:border-[#81B29A] rounded-lg min-w-[120px]"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 mr-1" />
|
||||||
|
Not Attended
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{rsvp.attended_at ? moment(rsvp.attended_at).format('MMM D, YYYY h:mm A') : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="6" className="px-4 py-12 text-center">
|
||||||
|
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{searchQuery ? 'No RSVPs match your search' : 'No RSVPs for this filter'}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminEventAttendance;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
@@ -8,16 +9,14 @@ import { Input } from '../../components/ui/input';
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../components/ui/dialog';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide-react';
|
import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide-react';
|
||||||
import { AttendanceDialog } from '../../components/AttendanceDialog';
|
|
||||||
|
|
||||||
const AdminEvents = () => {
|
const AdminEvents = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const [events, setEvents] = useState([]);
|
const [events, setEvents] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
const [editingEvent, setEditingEvent] = useState(null);
|
const [editingEvent, setEditingEvent] = useState(null);
|
||||||
const [attendanceDialogOpen, setAttendanceDialogOpen] = useState(false);
|
|
||||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
@@ -342,19 +341,16 @@ const AdminEvents = () => {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="space-y-2 pt-4 border-t border-[#ddd8eb]">
|
<div className="space-y-2 pt-4 border-t border-[#ddd8eb]">
|
||||||
{/* Mark Attendance Button */}
|
{/* Manage Attendance Button */}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => navigate(`/admin/events/${event.id}/attendance`)}
|
||||||
setSelectedEvent(event);
|
|
||||||
setAttendanceDialogOpen(true);
|
|
||||||
}}
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
|
className="w-full border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
|
||||||
data-testid={`mark-attendance-${event.id}`}
|
data-testid={`mark-attendance-${event.id}`}
|
||||||
>
|
>
|
||||||
<Users className="h-4 w-4 mr-2" />
|
<Users className="h-4 w-4 mr-2" />
|
||||||
Mark Attendance ({event.rsvp_count || 0} RSVPs)
|
Manage Attendance ({event.rsvp_count || 0} RSVPs)
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Other Actions */}
|
{/* Other Actions */}
|
||||||
@@ -419,14 +415,6 @@ const AdminEvents = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Attendance Dialog */}
|
|
||||||
<AttendanceDialog
|
|
||||||
event={selectedEvent}
|
|
||||||
open={attendanceDialogOpen}
|
|
||||||
onOpenChange={setAttendanceDialogOpen}
|
|
||||||
onSuccess={fetchEvents}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const AdminFinancials = () => {
|
const AdminFinancials = () => {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
const [reports, setReports] = useState([]);
|
const [reports, setReports] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
@@ -162,13 +164,15 @@ const AdminFinancials = () => {
|
|||||||
Manage annual financial reports
|
Manage annual financial reports
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{hasPermission('financials.create') && (
|
||||||
onClick={handleCreate}
|
<Button
|
||||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
onClick={handleCreate}
|
||||||
>
|
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||||
<Plus className="h-4 w-4" />
|
>
|
||||||
Add Report
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
Add Report
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reports List */}
|
{/* Reports List */}
|
||||||
@@ -176,10 +180,12 @@ const AdminFinancials = () => {
|
|||||||
<Card className="p-12 text-center">
|
<Card className="p-12 text-center">
|
||||||
<TrendingUp className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
<TrendingUp className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||||
<p className="text-[#664fa3] text-lg mb-4">No financial reports yet</p>
|
<p className="text-[#664fa3] text-lg mb-4">No financial reports yet</p>
|
||||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
{hasPermission('financials.create') && (
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||||
Create First Report
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Create First Report
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -209,24 +215,30 @@ const AdminFinancials = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
{(hasPermission('financials.edit') || hasPermission('financials.delete')) && (
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
variant="outline"
|
{hasPermission('financials.edit') && (
|
||||||
size="sm"
|
<Button
|
||||||
onClick={() => handleEdit(report)}
|
variant="outline"
|
||||||
className="border-[#664fa3] text-[#664fa3]"
|
size="sm"
|
||||||
>
|
onClick={() => handleEdit(report)}
|
||||||
<Edit className="h-4 w-4" />
|
className="border-[#664fa3] text-[#664fa3]"
|
||||||
</Button>
|
>
|
||||||
<Button
|
<Edit className="h-4 w-4" />
|
||||||
variant="outline"
|
</Button>
|
||||||
size="sm"
|
)}
|
||||||
onClick={() => handleDelete(report)}
|
{hasPermission('financials.delete') && (
|
||||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
<Button
|
||||||
>
|
variant="outline"
|
||||||
<Trash2 className="h-4 w-4" />
|
size="sm"
|
||||||
</Button>
|
onClick={() => handleDelete(report)}
|
||||||
</div>
|
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
@@ -14,11 +16,12 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '../../components/ui/select';
|
} from '../../components/ui/select';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../components/ui/dialog';
|
||||||
import { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin } from 'lucide-react';
|
import { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin, AlertCircle } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
|
||||||
const AdminGallery = () => {
|
const AdminGallery = () => {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
const [events, setEvents] = useState([]);
|
const [events, setEvents] = useState([]);
|
||||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||||
const [galleryImages, setGalleryImages] = useState([]);
|
const [galleryImages, setGalleryImages] = useState([]);
|
||||||
@@ -179,7 +182,33 @@ const AdminGallery = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedEvent && (
|
{/* Empty State Message */}
|
||||||
|
{events.length === 0 && (
|
||||||
|
<div className="mt-4 p-4 bg-[#f1eef9] border-2 border-[#DDD8EB] rounded-xl">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="h-5 w-5 text-[#664fa3] flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-sm font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
No Events Available
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-[#664fa3] mb-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
You need to create an event before uploading gallery images. Events help organize photos by occasion.
|
||||||
|
</p>
|
||||||
|
<Link to="/admin/events">
|
||||||
|
<Button
|
||||||
|
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl text-sm"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<Calendar className="h-4 w-4 mr-2" />
|
||||||
|
Create Your First Event
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedEvent && hasPermission('gallery.upload') && (
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -240,26 +269,32 @@ const AdminGallery = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Overlay with Actions */}
|
{/* Overlay with Actions */}
|
||||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex flex-col items-center justify-center gap-2">
|
{(hasPermission('gallery.edit') || hasPermission('gallery.delete')) && (
|
||||||
<Button
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex flex-col items-center justify-center gap-2">
|
||||||
onClick={() => openEditCaption(image)}
|
{hasPermission('gallery.edit') && (
|
||||||
size="sm"
|
<Button
|
||||||
className="bg-white/90 hover:bg-white text-[#422268] rounded-lg"
|
onClick={() => openEditCaption(image)}
|
||||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
size="sm"
|
||||||
>
|
className="bg-white/90 hover:bg-white text-[#422268] rounded-lg"
|
||||||
<Edit className="h-4 w-4 mr-1" />
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
Caption
|
>
|
||||||
</Button>
|
<Edit className="h-4 w-4 mr-1" />
|
||||||
<Button
|
Caption
|
||||||
onClick={() => handleDeleteImage(image.id)}
|
</Button>
|
||||||
size="sm"
|
)}
|
||||||
className="bg-red-500 hover:bg-red-600 text-white rounded-lg"
|
{hasPermission('gallery.delete') && (
|
||||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
<Button
|
||||||
>
|
onClick={() => handleDeleteImage(image.id)}
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
size="sm"
|
||||||
Delete
|
className="bg-red-500 hover:bg-red-600 text-white rounded-lg"
|
||||||
</Button>
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
</div>
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Caption Preview */}
|
{/* Caption Preview */}
|
||||||
{image.caption && (
|
{image.caption && (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const AdminNewsletters = () => {
|
const AdminNewsletters = () => {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
const [newsletters, setNewsletters] = useState([]);
|
const [newsletters, setNewsletters] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
@@ -190,13 +192,15 @@ const AdminNewsletters = () => {
|
|||||||
Create and manage newsletter archive
|
Create and manage newsletter archive
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{hasPermission('newsletters.create') && (
|
||||||
onClick={handleCreate}
|
<Button
|
||||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
onClick={handleCreate}
|
||||||
>
|
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||||
<Plus className="h-4 w-4" />
|
>
|
||||||
Add Newsletter
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
Add Newsletter
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Newsletters List */}
|
{/* Newsletters List */}
|
||||||
@@ -204,10 +208,12 @@ const AdminNewsletters = () => {
|
|||||||
<Card className="p-12 text-center">
|
<Card className="p-12 text-center">
|
||||||
<FileText className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
<FileText className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||||
<p className="text-[#664fa3] text-lg mb-4">No newsletters yet</p>
|
<p className="text-[#664fa3] text-lg mb-4">No newsletters yet</p>
|
||||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
{hasPermission('newsletters.create') && (
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||||
Create First Newsletter
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Create First Newsletter
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -246,24 +252,30 @@ const AdminNewsletters = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
{(hasPermission('newsletters.edit') || hasPermission('newsletters.delete')) && (
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
variant="outline"
|
{hasPermission('newsletters.edit') && (
|
||||||
size="sm"
|
<Button
|
||||||
onClick={() => handleEdit(newsletter)}
|
variant="outline"
|
||||||
className="border-[#664fa3] text-[#664fa3]"
|
size="sm"
|
||||||
>
|
onClick={() => handleEdit(newsletter)}
|
||||||
<Edit className="h-4 w-4" />
|
className="border-[#664fa3] text-[#664fa3]"
|
||||||
</Button>
|
>
|
||||||
<Button
|
<Edit className="h-4 w-4" />
|
||||||
variant="outline"
|
</Button>
|
||||||
size="sm"
|
)}
|
||||||
onClick={() => handleDelete(newsletter)}
|
{hasPermission('newsletters.delete') && (
|
||||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
<Button
|
||||||
>
|
variant="outline"
|
||||||
<Trash2 className="h-4 w-4" />
|
size="sm"
|
||||||
</Button>
|
onClick={() => handleDelete(newsletter)}
|
||||||
</div>
|
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const AdminPlans = () => {
|
const AdminPlans = () => {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
const [plans, setPlans] = useState([]);
|
const [plans, setPlans] = useState([]);
|
||||||
const [filteredPlans, setFilteredPlans] = useState([]);
|
const [filteredPlans, setFilteredPlans] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -136,13 +138,15 @@ const AdminPlans = () => {
|
|||||||
Manage membership plans and pricing.
|
Manage membership plans and pricing.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{hasPermission('subscriptions.plans') && (
|
||||||
onClick={handleCreatePlan}
|
<Button
|
||||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6"
|
onClick={handleCreatePlan}
|
||||||
>
|
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6"
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
>
|
||||||
Create Plan
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Create Plan
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -286,27 +290,29 @@ const AdminPlans = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex flex-col sm:flex-row gap-2 pt-4 border-t border-[#ddd8eb]">
|
{hasPermission('subscriptions.plans') && (
|
||||||
<Button
|
<div className="flex flex-col sm:flex-row gap-2 pt-4 border-t border-[#ddd8eb]">
|
||||||
onClick={() => handleEditPlan(plan)}
|
<Button
|
||||||
variant="outline"
|
onClick={() => handleEditPlan(plan)}
|
||||||
size="sm"
|
variant="outline"
|
||||||
className="flex-1 border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white rounded-full"
|
size="sm"
|
||||||
>
|
className="flex-1 border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white rounded-full"
|
||||||
<Edit className="h-4 w-4 mr-1" />
|
>
|
||||||
Edit
|
<Edit className="h-4 w-4 mr-1" />
|
||||||
</Button>
|
Edit
|
||||||
<Button
|
</Button>
|
||||||
onClick={() => handleDeleteClick(plan)}
|
<Button
|
||||||
variant="outline"
|
onClick={() => handleDeleteClick(plan)}
|
||||||
size="sm"
|
variant="outline"
|
||||||
className="flex-1 border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-full"
|
size="sm"
|
||||||
disabled={plan.subscriber_count > 0}
|
className="flex-1 border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-full"
|
||||||
>
|
disabled={plan.subscriber_count > 0}
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
>
|
||||||
Delete
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
</Button>
|
Delete
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Warning for plans with subscribers */}
|
{/* Warning for plans with subscribers */}
|
||||||
{plan.subscriber_count > 0 && (
|
{plan.subscriber_count > 0 && (
|
||||||
@@ -328,7 +334,7 @@ const AdminPlans = () => {
|
|||||||
? 'Try adjusting your filters'
|
? 'Try adjusting your filters'
|
||||||
: 'Create your first subscription plan to get started'}
|
: 'Create your first subscription plan to get started'}
|
||||||
</p>
|
</p>
|
||||||
{!searchQuery && activeFilter === 'all' && (
|
{!searchQuery && activeFilter === 'all' && hasPermission('subscriptions.plans') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreatePlan}
|
onClick={handleCreatePlan}
|
||||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8"
|
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye, Trash2, UserCheck,
|
|||||||
|
|
||||||
const AdminStaff = () => {
|
const AdminStaff = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission, user } = useAuth();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -118,7 +118,7 @@ const AdminStaff = () => {
|
|||||||
const getStatusBadge = (status) => {
|
const getStatusBadge = (status) => {
|
||||||
const config = {
|
const config = {
|
||||||
active: { label: 'Active', className: 'bg-[#81B29A] text-white' },
|
active: { label: 'Active', className: 'bg-[#81B29A] text-white' },
|
||||||
inactive: { label: 'Inactive', className: 'bg-gray-400 text-white' }
|
inactive: { label: 'Inactive', className: 'bg-gray-400 text-white ' }
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusConfig = config[status] || config.inactive;
|
const statusConfig = config[status] || config.inactive;
|
||||||
@@ -142,7 +142,7 @@ const AdminStaff = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{hasPermission('users.invite') && (
|
{hasPermission('users.create') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setInviteDialogOpen(true)}
|
onClick={() => setInviteDialogOpen(true)}
|
||||||
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"
|
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
import { Input } from '../../components/ui/input';
|
import { Input } from '../../components/ui/input';
|
||||||
@@ -44,6 +45,7 @@ import {
|
|||||||
} from '../../components/ui/dropdown-menu';
|
} from '../../components/ui/dropdown-menu';
|
||||||
|
|
||||||
const AdminSubscriptions = () => {
|
const AdminSubscriptions = () => {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
const [subscriptions, setSubscriptions] = useState([]);
|
const [subscriptions, setSubscriptions] = useState([]);
|
||||||
const [filteredSubscriptions, setFilteredSubscriptions] = useState([]);
|
const [filteredSubscriptions, setFilteredSubscriptions] = useState([]);
|
||||||
const [plans, setPlans] = useState([]);
|
const [plans, setPlans] = useState([]);
|
||||||
@@ -412,33 +414,35 @@ Proceed with activation?`;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Export Dropdown */}
|
{/* Export Dropdown */}
|
||||||
<DropdownMenu>
|
{hasPermission('subscriptions.export') && (
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button
|
<DropdownMenuTrigger asChild>
|
||||||
disabled={exporting}
|
<Button
|
||||||
className="bg-[#81B29A] text-white hover:bg-[#6a9680] rounded-full px-6 py-2 flex items-center gap-2"
|
disabled={exporting}
|
||||||
>
|
className="bg-[#81B29A] text-white hover:bg-[#6a9680] rounded-full px-6 py-2 flex items-center gap-2"
|
||||||
<Download className="h-4 w-4" />
|
>
|
||||||
{exporting ? 'Exporting...' : 'Export'}
|
<Download className="h-4 w-4" />
|
||||||
</Button>
|
{exporting ? 'Exporting...' : 'Export'}
|
||||||
</DropdownMenuTrigger>
|
</Button>
|
||||||
<DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg">
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem
|
<DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg">
|
||||||
onClick={() => handleExport('all')}
|
<DropdownMenuItem
|
||||||
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
onClick={() => handleExport('all')}
|
||||||
>
|
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
||||||
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
>
|
||||||
<span className="text-[#422268]">Export All Subscriptions</span>
|
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
||||||
</DropdownMenuItem>
|
<span className="text-[#422268]">Export All Subscriptions</span>
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
onClick={() => handleExport('current')}
|
<DropdownMenuItem
|
||||||
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
onClick={() => handleExport('current')}
|
||||||
>
|
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
||||||
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
>
|
||||||
<span className="text-[#422268]">Export Current View</span>
|
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
||||||
</DropdownMenuItem>
|
<span className="text-[#422268]">Export Current View</span>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -503,16 +507,18 @@ Proceed with activation?`;
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button
|
{hasPermission('subscriptions.edit') && (
|
||||||
size="sm"
|
<Button
|
||||||
variant="outline"
|
size="sm"
|
||||||
onClick={() => handleEdit(sub)}
|
variant="outline"
|
||||||
className="flex-1 text-[#664fa3] hover:bg-[#DDD8EB]"
|
onClick={() => handleEdit(sub)}
|
||||||
>
|
className="flex-1 text-[#664fa3] hover:bg-[#DDD8EB]"
|
||||||
<Edit className="h-4 w-4 mr-2" />
|
>
|
||||||
Edit
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Edit
|
||||||
{sub.status === 'active' && (
|
</Button>
|
||||||
|
)}
|
||||||
|
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -607,15 +613,17 @@ Proceed with activation?`;
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<Button
|
{hasPermission('subscriptions.edit') && (
|
||||||
size="sm"
|
<Button
|
||||||
variant="outline"
|
size="sm"
|
||||||
onClick={() => handleEdit(sub)}
|
variant="outline"
|
||||||
className="text-[#664fa3] hover:bg-[#DDD8EB]"
|
onClick={() => handleEdit(sub)}
|
||||||
>
|
className="text-[#664fa3] hover:bg-[#DDD8EB]"
|
||||||
<Edit className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<Edit className="h-4 w-4" />
|
||||||
{sub.status === 'active' && (
|
</Button>
|
||||||
|
)}
|
||||||
|
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
@@ -35,6 +36,7 @@ import ConfirmationDialog from '../../components/ConfirmationDialog';
|
|||||||
import RejectionDialog from '../../components/RejectionDialog';
|
import RejectionDialog from '../../components/RejectionDialog';
|
||||||
|
|
||||||
const AdminValidations = () => {
|
const AdminValidations = () => {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
const [pendingUsers, setPendingUsers] = useState([]);
|
const [pendingUsers, setPendingUsers] = useState([]);
|
||||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -419,66 +421,78 @@ const AdminValidations = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
) : user.status === 'pending_email' ? (
|
) : user.status === 'pending_email' ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
{hasPermission('users.approve') && (
|
||||||
onClick={() => handleBypassAndValidateRequest(user)}
|
<Button
|
||||||
disabled={actionLoading === user.id}
|
onClick={() => handleBypassAndValidateRequest(user)}
|
||||||
size="sm"
|
disabled={actionLoading === user.id}
|
||||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
size="sm"
|
||||||
>
|
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||||
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
|
>
|
||||||
</Button>
|
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
|
||||||
<Button
|
</Button>
|
||||||
onClick={() => handleRejectUser(user)}
|
)}
|
||||||
disabled={actionLoading === user.id}
|
{hasPermission('users.approve') && (
|
||||||
size="sm"
|
<Button
|
||||||
variant="outline"
|
onClick={() => handleRejectUser(user)}
|
||||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
|
disabled={actionLoading === user.id}
|
||||||
>
|
size="sm"
|
||||||
<X className="h-4 w-4 mr-1" />
|
variant="outline"
|
||||||
Reject
|
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
|
||||||
</Button>
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : user.status === 'payment_pending' ? (
|
) : user.status === 'payment_pending' ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
{hasPermission('subscriptions.activate') && (
|
||||||
onClick={() => handleActivatePayment(user)}
|
<Button
|
||||||
size="sm"
|
onClick={() => handleActivatePayment(user)}
|
||||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
size="sm"
|
||||||
>
|
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||||
<CheckCircle className="h-4 w-4 mr-1" />
|
>
|
||||||
Activate Payment
|
<CheckCircle className="h-4 w-4 mr-1" />
|
||||||
</Button>
|
Activate Payment
|
||||||
<Button
|
</Button>
|
||||||
onClick={() => handleRejectUser(user)}
|
)}
|
||||||
disabled={actionLoading === user.id}
|
{hasPermission('users.approve') && (
|
||||||
size="sm"
|
<Button
|
||||||
variant="outline"
|
onClick={() => handleRejectUser(user)}
|
||||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
|
disabled={actionLoading === user.id}
|
||||||
>
|
size="sm"
|
||||||
<X className="h-4 w-4 mr-1" />
|
variant="outline"
|
||||||
Reject
|
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
|
||||||
</Button>
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button
|
{hasPermission('users.approve') && (
|
||||||
onClick={() => handleValidateRequest(user)}
|
<Button
|
||||||
disabled={actionLoading === user.id}
|
onClick={() => handleValidateRequest(user)}
|
||||||
size="sm"
|
disabled={actionLoading === user.id}
|
||||||
className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
|
size="sm"
|
||||||
>
|
className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
|
||||||
{actionLoading === user.id ? 'Validating...' : 'Validate'}
|
>
|
||||||
</Button>
|
{actionLoading === user.id ? 'Validating...' : 'Validate'}
|
||||||
<Button
|
</Button>
|
||||||
onClick={() => handleRejectUser(user)}
|
)}
|
||||||
disabled={actionLoading === user.id}
|
{hasPermission('users.approve') && (
|
||||||
size="sm"
|
<Button
|
||||||
variant="outline"
|
onClick={() => handleRejectUser(user)}
|
||||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
|
disabled={actionLoading === user.id}
|
||||||
>
|
size="sm"
|
||||||
<X className="h-4 w-4 mr-1" />
|
variant="outline"
|
||||||
Reject
|
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
|
||||||
</Button>
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
93
src/pages/members/MemberCalendar.css
Normal file
93
src/pages/members/MemberCalendar.css
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/* Member Calendar Custom Styles */
|
||||||
|
|
||||||
|
.member-calendar .rbc-header {
|
||||||
|
padding: 12px 6px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #422268;
|
||||||
|
background-color: #f9f7fc;
|
||||||
|
border-bottom: 2px solid #ddd8eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-today {
|
||||||
|
background-color: #f1eef9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-off-range-bg {
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-event {
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-event:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-toolbar button {
|
||||||
|
color: #664fa3;
|
||||||
|
border-color: #ddd8eb;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background-color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-toolbar button:hover {
|
||||||
|
background-color: #f1eef9;
|
||||||
|
border-color: #664fa3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-toolbar button:active,
|
||||||
|
.member-calendar .rbc-toolbar button.rbc-active {
|
||||||
|
background-color: #664fa3;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-month-view {
|
||||||
|
border: 1px solid #ddd8eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-day-bg {
|
||||||
|
border-color: #ddd8eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-date-cell {
|
||||||
|
padding: 8px;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure toolbar buttons are clickable */
|
||||||
|
.member-calendar .rbc-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-toolbar button {
|
||||||
|
outline: none;
|
||||||
|
border: 1px solid #ddd8eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-btn-group button {
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-btn-group button:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-btn-group button:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo } from 'react';
|
|||||||
import { Calendar, momentLocalizer } from 'react-big-calendar';
|
import { Calendar, momentLocalizer } from 'react-big-calendar';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
||||||
|
import './MemberCalendar.css';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import Navbar from '../../components/Navbar';
|
import Navbar from '../../components/Navbar';
|
||||||
import MemberFooter from '../../components/MemberFooter';
|
import MemberFooter from '../../components/MemberFooter';
|
||||||
@@ -26,6 +27,8 @@ export default function MemberCalendar() {
|
|||||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [rsvpLoading, setRsvpLoading] = useState(false);
|
const [rsvpLoading, setRsvpLoading] = useState(false);
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
const [currentView, setCurrentView] = useState('month');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEvents();
|
fetchEvents();
|
||||||
@@ -58,6 +61,14 @@ export default function MemberCalendar() {
|
|||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNavigate = (newDate) => {
|
||||||
|
setCurrentDate(newDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewChange = (newView) => {
|
||||||
|
setCurrentView(newView);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRSVP = async (status) => {
|
const handleRSVP = async (status) => {
|
||||||
if (!selectedEvent) return;
|
if (!selectedEvent) return;
|
||||||
|
|
||||||
@@ -171,10 +182,13 @@ export default function MemberCalendar() {
|
|||||||
startAccessor="start"
|
startAccessor="start"
|
||||||
endAccessor="end"
|
endAccessor="end"
|
||||||
style={{ height: 700 }}
|
style={{ height: 700 }}
|
||||||
|
date={currentDate}
|
||||||
|
view={currentView}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
onView={handleViewChange}
|
||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
eventPropGetter={eventStyleGetter}
|
eventPropGetter={eventStyleGetter}
|
||||||
views={['month', 'week', 'day', 'agenda']}
|
views={['month', 'week', 'day', 'agenda']}
|
||||||
defaultView="month"
|
|
||||||
popup
|
popup
|
||||||
className="member-calendar"
|
className="member-calendar"
|
||||||
/>
|
/>
|
||||||
@@ -320,64 +334,6 @@ export default function MemberCalendar() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style jsx global>{`
|
|
||||||
.member-calendar .rbc-header {
|
|
||||||
padding: 12px 6px;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #422268;
|
|
||||||
background-color: #f9f7fc;
|
|
||||||
border-bottom: 2px solid #ddd8eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-today {
|
|
||||||
background-color: #f1eef9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-off-range-bg {
|
|
||||||
background-color: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-event {
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-event:hover {
|
|
||||||
opacity: 0.85;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-toolbar button {
|
|
||||||
color: #664fa3;
|
|
||||||
border-color: #ddd8eb;
|
|
||||||
font-family: 'Nunito Sans', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-toolbar button:hover {
|
|
||||||
background-color: #f1eef9;
|
|
||||||
border-color: #664fa3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-toolbar button.rbc-active {
|
|
||||||
background-color: #664fa3;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-month-view {
|
|
||||||
border: 1px solid #ddd8eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-day-bg {
|
|
||||||
border-color: #ddd8eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-date-cell {
|
|
||||||
padding: 8px;
|
|
||||||
font-family: 'Nunito Sans', sans-serif;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
<MemberFooter />
|
<MemberFooter />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ const MembersDirectory = () => {
|
|||||||
const [selectedMember, setSelectedMember] = useState(null);
|
const [selectedMember, setSelectedMember] = useState(null);
|
||||||
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const pageSize = 12;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMembers();
|
fetchMembers();
|
||||||
@@ -33,6 +35,10 @@ const MembersDirectory = () => {
|
|||||||
filterMembers();
|
filterMembers();
|
||||||
}, [searchQuery, members]);
|
}, [searchQuery, members]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchQuery, members]);
|
||||||
|
|
||||||
const fetchMembers = async () => {
|
const fetchMembers = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/members/directory');
|
const response = await api.get('/members/directory');
|
||||||
@@ -66,6 +72,14 @@ const MembersDirectory = () => {
|
|||||||
setFilteredMembers(filtered);
|
setFilteredMembers(filtered);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredMembers.length / pageSize));
|
||||||
|
|
||||||
|
const pageStart = (currentPage - 1) * pageSize;
|
||||||
|
|
||||||
|
const paginatedMembers = filteredMembers.slice(pageStart, pageStart + pageSize);
|
||||||
|
|
||||||
|
const totalMembers = members.length;
|
||||||
|
|
||||||
const getInitials = (firstName, lastName) => {
|
const getInitials = (firstName, lastName) => {
|
||||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||||||
};
|
};
|
||||||
@@ -97,9 +111,15 @@ const MembersDirectory = () => {
|
|||||||
if (!dateString) return null;
|
if (!dateString) return null;
|
||||||
return new Date(dateString).toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
|
return new Date(dateString).toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
|
||||||
};
|
};
|
||||||
|
const Border = ({ yaxis = false }) => {
|
||||||
|
return (
|
||||||
|
yaxis ?
|
||||||
|
<div className=' border-2 w-full border-[#664FA3] my-24' />
|
||||||
|
: <div className=' border-2 w-full border-[#664FA3] mb-24' />
|
||||||
|
)
|
||||||
|
}
|
||||||
const MemberCard = ({ member }) => (
|
const MemberCard = ({ member }) => (
|
||||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg transition-all h-full">
|
<Card className="p-6 bg-white rounded-3xl border border-[#ddd8eb] hover:shadow-lg transition-all h-full">
|
||||||
{/* Profile Photo */}
|
{/* Profile Photo */}
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
{member.profile_photo_url ? (
|
{member.profile_photo_url ? (
|
||||||
@@ -259,39 +279,48 @@ const MembersDirectory = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-gradient-to-bl from-[#F9FAFB] to-[#DDD8EB]">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
<div className="max-w-7xl mx-auto py-12">
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Members Directory
|
|
||||||
</h1>
|
|
||||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
Connect with fellow LOAF members in our community.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Header and Search bar */}
|
||||||
<div className="mb-8">
|
<div className='px-9'>
|
||||||
<div className="relative max-w-xl">
|
|
||||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
|
{/* Header */}
|
||||||
<Input
|
<div className="m-8 mt-14 flex flex-col sm:flex-row justify-between items-center ">
|
||||||
type="text"
|
<h1 className="text-4xl md:text-5xl font-bold text-[#422268] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
placeholder="Search by name or bio..."
|
LOAF Members
|
||||||
value={searchQuery}
|
</h1>
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
<p className="text-lg " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
className="pl-12 pr-4 py-6 text-lg border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
|
<span className='text-foreground'>Number of current memebers in the directory: </span> <span className='text-[#664fa3] font-medium'>{totalMembers}</span>
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{searchQuery && (
|
|
||||||
<p className="mt-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
Found {filteredMembers.length} {filteredMembers.length === 1 ? 'member' : 'members'}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="mb-24 mx-10">
|
||||||
|
<div className="relative w-full ">
|
||||||
|
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name or bio..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-12 pr-4 py-6 text-3xl font-medium bg-background border-foreground rounded-full focus:border-[#664fa3] focus:ring-[#664fa3]"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{searchQuery && (
|
||||||
|
<p className="mt-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Found {filteredMembers.length} {filteredMembers.length === 1 ? 'member' : 'members'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* Border Decoration */}
|
||||||
|
|
||||||
|
<Border />
|
||||||
|
|
||||||
{/* Members Grid */}
|
{/* Members Grid */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -300,7 +329,7 @@ const MembersDirectory = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : filteredMembers.length > 0 ? (
|
) : filteredMembers.length > 0 ? (
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{filteredMembers.map((member) => (
|
{paginatedMembers.map((member) => (
|
||||||
<MemberCard key={member.id} member={member} />
|
<MemberCard key={member.id} member={member} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -318,6 +347,11 @@ const MembersDirectory = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Border Decoration */}
|
||||||
|
<Border yaxis="true" />
|
||||||
|
|
||||||
{/* Info Card */}
|
{/* Info Card */}
|
||||||
{!loading && members.length > 0 && (
|
{!loading && members.length > 0 && (
|
||||||
<Card className="mt-12 p-6 bg-[#F8F7FB] border-[#ddd8eb]">
|
<Card className="mt-12 p-6 bg-[#F8F7FB] border-[#ddd8eb]">
|
||||||
@@ -465,67 +499,127 @@ const MembersDirectory = () => {
|
|||||||
{/* Social Media */}
|
{/* Social Media */}
|
||||||
{(selectedMember.social_media_facebook || selectedMember.social_media_instagram ||
|
{(selectedMember.social_media_facebook || selectedMember.social_media_instagram ||
|
||||||
selectedMember.social_media_twitter || selectedMember.social_media_linkedin) && (
|
selectedMember.social_media_twitter || selectedMember.social_media_linkedin) && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h3 className="text-lg font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
Connect on Social Media
|
Connect on Social Media
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{selectedMember.social_media_facebook && (
|
{selectedMember.social_media_facebook && (
|
||||||
<a
|
<a
|
||||||
href={getSocialMediaLink(selectedMember.social_media_facebook)}
|
href={getSocialMediaLink(selectedMember.social_media_facebook)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
||||||
title="Facebook"
|
title="Facebook"
|
||||||
>
|
>
|
||||||
<Facebook className="h-6 w-6 text-[#1877F2]" />
|
<Facebook className="h-6 w-6 text-[#1877F2]" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedMember.social_media_instagram && (
|
{selectedMember.social_media_instagram && (
|
||||||
<a
|
<a
|
||||||
href={getSocialMediaLink(selectedMember.social_media_instagram)}
|
href={getSocialMediaLink(selectedMember.social_media_instagram)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
||||||
title="Instagram"
|
title="Instagram"
|
||||||
>
|
>
|
||||||
<Instagram className="h-6 w-6 text-[#E4405F]" />
|
<Instagram className="h-6 w-6 text-[#E4405F]" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedMember.social_media_twitter && (
|
{selectedMember.social_media_twitter && (
|
||||||
<a
|
<a
|
||||||
href={getSocialMediaLink(selectedMember.social_media_twitter)}
|
href={getSocialMediaLink(selectedMember.social_media_twitter)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
||||||
title="Twitter/X"
|
title="Twitter/X"
|
||||||
>
|
>
|
||||||
<Twitter className="h-6 w-6 text-[#1DA1F2]" />
|
<Twitter className="h-6 w-6 text-[#1DA1F2]" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedMember.social_media_linkedin && (
|
{selectedMember.social_media_linkedin && (
|
||||||
<a
|
<a
|
||||||
href={getSocialMediaLink(selectedMember.social_media_linkedin)}
|
href={getSocialMediaLink(selectedMember.social_media_linkedin)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
||||||
title="LinkedIn"
|
title="LinkedIn"
|
||||||
>
|
>
|
||||||
<Linkedin className="h-6 w-6 text-[#0A66C2]" />
|
<Linkedin className="h-6 w-6 text-[#0A66C2]" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{!loading && filteredMembers.length > 0 && (
|
||||||
|
<div className="mt-10 flex flex-col items-center gap-4 pb-12">
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Showing {pageStart + 1}–{Math.min(pageStart + pageSize, filteredMembers.length)} of {filteredMembers.length}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="bg-[#DDD8EB] rounded-full text-[#422268] hover:bg-[#664fa3] hover:text-white"
|
||||||
|
>
|
||||||
|
First Page
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="bg-[#DDD8EB] rounded-full text-[#422268] hover:bg-[#664fa3] hover:text-white"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, index) => {
|
||||||
|
const pageNumber = index + 1;
|
||||||
|
const isActive = pageNumber === currentPage;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={pageNumber}
|
||||||
|
onClick={() => setCurrentPage(pageNumber)}
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "bg-[#664fa3] text-white hover:bg-[#422268] rounded-full"
|
||||||
|
: "bg-[#DDD8EB] text-[#422268] hover:bg-[#664fa3] hover:text-white rounded-full"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pageNumber}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="bg-[#DDD8EB] text-[#422268] hover:bg-[#664fa3] rounded-full hover:text-white"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="bg-[#DDD8EB] text-[#422268] hover:bg-[#664fa3] rounded-full hover:text-white"
|
||||||
|
>
|
||||||
|
Last Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<MemberFooter />
|
<MemberFooter />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user