// DEPLOY_CONFIG: {"triggers": [{"name": "volunteer_hours_50_milestone", "on": "collection.update", "collection": "volunteers", "actions": [{"type": "event", "event_type": "volunteer_hours_milestone_50"}]}, {"name": "volunteer_hours_100_milestone", "on": "collection.update", "collection": "volunteers", "actions": [{"type": "event", "event_type": "volunteer_hours_milestone_100"}]}, {"name": "volunteer_hours_500_milestone", "on": "collection.update", "collection": "volunteers", "actions": [{"type": "event", "event_type": "volunteer_hours_milestone_500"}]}, {"name": "monthly_hours_summary_to_supervisors", "on": "cron", "schedule": "0 9 1 * *", "actions": [{"type": "event", "event_type": "monthly_hours_summary_email"}]}]} import { useMemo, useState, useEffect, useRef } from 'react'; import { useAuth, useCollection, renderChart, exportCSV, exportJSON } from '@deplixo/sdk'; import { Dashboard } from './components/Dashboard.jsx'; import { LogHours } from './components/LogHours.jsx'; import { HoursList } from './components/HoursList.jsx'; import { Volunteers } from './components/Volunteers.jsx'; import { Settings } from './components/Settings.jsx'; function App() { const { user, loading, login, logout } = useAuth(); const [activeTab, setActiveTab] = useState('dashboard'); const [manualRole, setManualRole] = useState('auto'); const hours = useCollection('hours', { personal: true }); const volunteers = useCollection('volunteers', { personal: true }); const prefs = useCollection('preferences', { personal: true }); const normalize = value => String(value || '').trim().toLowerCase(); const roleConfig = useMemo(() => { const emails = new Set( (prefs.items || []) .map(item => item?.googleEmail || item?.email || item?.value) .filter(Boolean) .map(normalize) ); const adminDomains = new Set( (prefs.items || []) .flatMap(item => item?.adminDomains || []) .filter(Boolean) .map(normalize) ); const supervisorDomains = new Set( (prefs.items || []) .flatMap(item => item?.supervisorDomains || []) .filter(Boolean) .map(normalize) ); const explicitRole = normalize( (prefs.items || []).find(item => item?.type === 'role' || item?.key === 'defaultRole')?.role || (prefs.items || []).find(item => item?.type === 'role' || item?.key === 'defaultRole')?.value ); return { emails, adminDomains, supervisorDomains, explicitRole }; }, [prefs.items]); const derivedRole = useMemo(() => { if (!user?.email) return 'volunteer'; const email = normalize(user.email); const domain = email.split('@')[1] || ''; if (roleConfig.emails.has(email)) return 'admin'; if (roleConfig.adminDomains.has(domain)) return 'admin'; if (roleConfig.supervisorDomains.has(domain)) return 'supervisor'; if (roleConfig.explicitRole === 'admin' || roleConfig.explicitRole === 'supervisor' || roleConfig.explicitRole === 'volunteer') { return roleConfig.explicitRole; } if (domain.endsWith('.edu') || domain.endsWith('.org')) return 'supervisor'; return 'volunteer'; }, [roleConfig, user]); const effectiveRole = manualRole === 'auto' ? derivedRole : manualRole; const canManageVolunteers = effectiveRole === 'supervisor' || effectiveRole === 'admin'; const canApproveHours = effectiveRole === 'supervisor' || effectiveRole === 'admin'; const canAccessSettings = effectiveRole === 'admin'; const canExportReports = effectiveRole === 'supervisor' || effectiveRole === 'admin'; const tabs = [ { id: 'dashboard', label: 'Dashboard', icon: '📊' }, { id: 'log', label: 'Log Hours', icon: '⏱️', allowed: ['volunteer', 'supervisor', 'admin'] }, { id: 'hours', label: 'Hours', icon: '📋', allowed: ['volunteer', 'supervisor', 'admin'] }, { id: 'volunteers', label: 'Volunteers', icon: '👥', allowed: ['supervisor', 'admin'] }, { id: 'settings', label: 'Settings', icon: '⚙️', allowed: ['admin'] }, ]; const visibleTabs = tabs.filter(tab => !tab.allowed || tab.allowed.includes(effectiveRole)); const chartData = useMemo(() => { const entries = (hours.items || []).filter(Boolean); const toHours = item => Number(item?.hours ?? item?.amount ?? item?.duration ?? 0) || 0; const getVolunteerName = item => item?.volunteerName || item?.volunteer || item?.name || item?.userName || 'Unknown'; const getActivity = item => item?.activity || item?.project || item?.task || 'Other'; const getDate = item => item?.date || item?.createdAt || item?.loggedAt || item?.timestamp; const volunteerTotals = new Map(); const activityTotals = new Map(); const monthlyTotals = new Map(); entries.forEach(item => { const h = toHours(item); const volunteer = getVolunteerName(item); const activity = getActivity(item); const dateValue = getDate(item); const date = dateValue ? new Date(dateValue) : null; const monthKey = date && !Number.isNaN(date.getTime()) ? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` : 'Unknown'; volunteerTotals.set(volunteer, (volunteerTotals.get(volunteer) || 0) + h); activityTotals.set(activity, (activityTotals.get(activity) || 0) + h); monthlyTotals.set(monthKey, (monthlyTotals.get(monthKey) || 0) + h); }); const sortDesc = (a, b) => b.value - a.value; const volunteerChart = [...volunteerTotals.entries()].map(([label, value]) => ({ label, value })).sort(sortDesc).slice(0, 8); const activityChart = [...activityTotals.entries()].map(([label, value]) => ({ label, value })).sort(sortDesc).slice(0, 8); const monthlyChart = [...monthlyTotals.entries()] .map(([label, value]) => ({ label, value })) .sort((a, b) => a.label.localeCompare(b.label)); return { volunteerChart, activityChart, monthlyChart }; }, [hours.items]); const reportData = useMemo(() => { const entries = (hours.items || []).filter(Boolean); const getVolunteerName = item => item?.volunteerName || item?.volunteer || item?.name || item?.userName || 'Unknown'; const getActivity = item => item?.activity || item?.project || item?.task || 'Other'; const getDate = item => item?.date || item?.createdAt || item?.loggedAt || item?.timestamp; const toHours = item => Number(item?.hours ?? item?.amount ?? item?.duration ?? 0) || 0; const normalizeStatus = item => normalize(item?.status || item?.approvalStatus || 'pending'); const dateValues = entries .map(item => { const d = getDate(item) ? new Date(getDate(item)) : null; return d && !Number.isNaN(d.getTime()) ? d : null; }) .filter(Boolean); const defaultStart = dateValues.length ? new Date(Math.min(...dateValues.map(d => d.getTime()))) : null; const defaultEnd = dateValues.length ? new Date(Math.max(...dateValues.map(d => d.getTime()))) : null; const volunteerTotals = new Map(); const activityTotals = new Map(); const monthTotals = new Map(); const statusTotals = new Map(); entries.forEach(item => { const hoursValue = toHours(item); const volunteer = getVolunteerName(item); const activity = getActivity(item); const status = normalizeStatus(item); const dateValue = getDate(item); const date = dateValue ? new Date(dateValue) : null; const monthKey = date && !Number.isNaN(date.getTime()) ? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` : 'Unknown'; volunteerTotals.set(volunteer, (volunteerTotals.get(volunteer) || 0) + hoursValue); activityTotals.set(activity, (activityTotals.get(activity) || 0) + hoursValue); monthTotals.set(monthKey, (monthTotals.get(monthKey) || 0) + hoursValue); statusTotals.set(status, (statusTotals.get(status) || 0) + hoursValue); }); const sortedEntries = [...entries].sort((a, b) => { const aTime = getDate(a) ? new Date(getDate(a)).getTime() : 0; const bTime = getDate(b) ? new Date(getDate(b)).getTime() : 0; return bTime - aTime; }); return { entries: sortedEntries, defaultStart, defaultEnd, volunteerTotals, activityTotals, monthTotals, statusTotals, }; }, [hours.items]); const handleExportGrantReport = (format = 'csv') => { const rows = (reportData.entries || []).map(item => { const dateValue = item?.date || item?.createdAt || item?.loggedAt || item?.timestamp; const date = dateValue ? new Date(dateValue) : null; const volunteer = item?.volunteerName || item?.volunteer || item?.name || item?.userName || 'Unknown'; const activity = item?.activity || item?.project || item?.task || 'Other'; const status = item?.status || item?.approvalStatus || 'pending'; const hoursValue = Number(item?.hours ?? item?.amount ?? item?.duration ?? 0) || 0; return { id: item?.id || '', date: date && !Number.isNaN(date.getTime()) ? date.toISOString().slice(0, 10) : '', volunteer, activity, status, hours: hoursValue, location: item?.location || '', description: item?.description || '', submittedBy: item?.submittedBy || item?.email || '', }; }); const summaryRows = [ ...[...reportData.volunteerTotals.entries()].map(([name, total]) => ({ section: 'Volunteer Breakdown', label: name, totalHours: total })), ...[...reportData.activityTotals.entries()].map(([name, total]) => ({ section: 'Activity Breakdown', label: name, totalHours: total })), ...[...reportData.monthTotals.entries()].map(([name, total]) => ({ section: 'Monthly Breakdown', label: name, totalHours: total })), ...[...reportData.statusTotals.entries()].map(([name, total]) => ({ section: 'Status Breakdown', label: name, totalHours: total })), ]; const baseFilename = `grant-hours-report-${new Date().toISOString().slice(0, 10)}`; if (format === 'json') { exportJSON( { generatedAt: new Date().toISOString(), reportRange: { start: reportData.defaultStart ? reportData.defaultStart.toISOString() : '', end: reportData.defaultEnd ? reportData.defaultEnd.toISOString() : '', }, hours: rows, breakdowns: summaryRows, }, { filename: `${baseFilename}.json` } ); return; } exportCSV(rows, { filename: `${baseFilename}.csv`, columns: ['id', 'date', 'volunteer', 'activity', 'status', 'hours', 'location', 'description', 'submittedBy'], }); }; if (loading) { return
Signing in...
; } if (!user) { return (

🤝 VolunTrack

Volunteer Hour Tracker

Sign in with Google

Use your Google account to access VolunTrack. Your role is assigned automatically based on your email allowlist, domain, or app preference rules.

); } return (

🤝 VolunTrack

Volunteer Hour Tracker

{effectiveRole} {user.email}
Signed in as {user.name}. Role: {effectiveRole}. {manualRole !== 'auto' ? ' Manual role override is active.' : ' Role is derived from Google identity and allowlist rules.'}
{activeTab === 'dashboard' && } {activeTab === 'log' && } {activeTab === 'hours' && ( <> {canExportReports && (

Grant reporting export

Download hours data for grant reports with date range, volunteer, activity, and status breakdowns.

Total entries: {reportData.entries.length} Volunteer breakdown: {[...reportData.volunteerTotals.keys()].length} Activity breakdown: {[...reportData.activityTotals.keys()].length}
)} )} {activeTab === 'volunteers' && canManageVolunteers && } {activeTab === 'volunteers' && !canManageVolunteers && (
🔒

You need supervisor or admin access to manage volunteers.

)} {activeTab === 'settings' && canAccessSettings && } {activeTab === 'settings' && !canAccessSettings && (
🔒

You need admin access to change settings.

)}

Role switching

Default behavior is automatic role derivation from Google identity. Admins can temporarily switch the active role for testing or delegated workflows.

); } function DashboardChart({ title, data, type = 'bar' }) { const canvasRef = useRef(null); useEffect(() => { if (canvasRef.current && data?.length > 0) { renderChart(canvasRef.current, { type, data: { labels: data.map(d => d.label), datasets: [{ label: title, data: data.map(d => d.value) }] } }); } }, [data, title, type]); return (

{title}

{data?.length > 0 ? :
No data yet.
}
); } export default App; ReactDOM.createRoot(document.getElementById('root')).render();