Scaffold full-stack volunteer scheduling application

Go backend with domain-based packages (volunteer, schedule, timeoff,
checkin, notification), SQLite storage, JWT auth, and chi router.
React TypeScript frontend with routing, auth context, and pages for
all core features. Multi-stage Dockerfile and docker-compose included.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 11:25:02 -04:00
parent 64f4563bfa
commit 4989ff1061
49 changed files with 19996 additions and 12 deletions

136
web/src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,136 @@
import React, { useEffect, useState } from 'react';
import { api, CheckIn, Notification, Schedule } from '../api';
import { useAuth } from '../auth';
export default function Dashboard() {
const { volunteerID } = useAuth();
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [activeCheckIn, setActiveCheckIn] = useState<CheckIn | null>(null);
const [history, setHistory] = useState<CheckIn[]>([]);
const [error, setError] = useState('');
useEffect(() => {
api.listSchedules().then(setSchedules).catch(() => {});
api.listNotifications().then(setNotifications).catch(() => {});
api.getHistory().then(data => {
setHistory(data);
const active = data.find(c => !c.checked_out_at && c.volunteer_id === volunteerID);
setActiveCheckIn(active ?? null);
}).catch(() => {});
}, [volunteerID]);
async function handleCheckIn() {
try {
const ci = await api.checkIn();
setActiveCheckIn(ci);
setHistory(prev => [ci, ...prev]);
} catch (err: any) {
setError(err.message);
}
}
async function handleCheckOut() {
try {
const ci = await api.checkOut();
setActiveCheckIn(null);
setHistory(prev => prev.map(c => c.id === ci.id ? ci : c));
} catch (err: any) {
setError(err.message);
}
}
async function handleMarkRead(id: number) {
try {
await api.markRead(id);
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
} catch {}
}
const upcomingSchedules = schedules
.filter(s => new Date(s.starts_at) >= new Date())
.slice(0, 5);
const unreadNotifications = notifications.filter(n => !n.read);
return (
<div className="page">
<h2>Dashboard</h2>
{error && <p className="error">{error}</p>}
<section className="card">
<h3>Check-In Status</h3>
{activeCheckIn ? (
<div>
<p>Checked in at {new Date(activeCheckIn.checked_in_at).toLocaleTimeString()}</p>
<button onClick={handleCheckOut}>Check Out</button>
</div>
) : (
<button onClick={handleCheckIn}>Check In</button>
)}
</section>
<section className="card">
<h3>Upcoming Shifts</h3>
{upcomingSchedules.length === 0 ? (
<p>No upcoming shifts.</p>
) : (
<ul>
{upcomingSchedules.map(s => (
<li key={s.id}>
<strong>{s.title}</strong> {new Date(s.starts_at).toLocaleString()} to {new Date(s.ends_at).toLocaleString()}
</li>
))}
</ul>
)}
</section>
<section className="card">
<h3>Notifications {unreadNotifications.length > 0 && <span className="badge">{unreadNotifications.length}</span>}</h3>
{notifications.length === 0 ? (
<p>No notifications.</p>
) : (
<ul>
{notifications.map(n => (
<li key={n.id} className={n.read ? 'read' : 'unread'}>
{n.message}
{!n.read && (
<button className="btn-small" onClick={() => handleMarkRead(n.id)}>Mark read</button>
)}
</li>
))}
</ul>
)}
</section>
<section className="card">
<h3>Recent Check-In History</h3>
{history.length === 0 ? (
<p>No history yet.</p>
) : (
<table>
<thead>
<tr><th>In</th><th>Out</th><th>Duration</th></tr>
</thead>
<tbody>
{history.slice(0, 10).map(c => {
const inTime = new Date(c.checked_in_at);
const outTime = c.checked_out_at ? new Date(c.checked_out_at) : null;
const duration = outTime
? `${Math.round((outTime.getTime() - inTime.getTime()) / 60000)} min`
: 'Active';
return (
<tr key={c.id}>
<td>{inTime.toLocaleString()}</td>
<td>{outTime ? outTime.toLocaleString() : '—'}</td>
<td>{duration}</td>
</tr>
);
})}
</tbody>
</table>
)}
</section>
</div>
);
}

43
web/src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,43 @@
import React, { useState, FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api';
import { useAuth } from '../auth';
export default function Login() {
const { login } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
try {
const { token } = await api.login(email, password);
login(token);
navigate('/');
} catch (err: any) {
setError(err.message);
}
}
return (
<div className="auth-page">
<h1>Walkies</h1>
<h2>Sign In</h2>
<form onSubmit={handleSubmit}>
{error && <p className="error">{error}</p>}
<label>
Email
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
</label>
<label>
Password
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</label>
<button type="submit">Sign In</button>
</form>
</div>
);
}

106
web/src/pages/Schedules.tsx Normal file
View File

@@ -0,0 +1,106 @@
import React, { useEffect, useState, FormEvent } from 'react';
import { api, Schedule } from '../api';
import { useAuth } from '../auth';
export default function Schedules() {
const { role } = useAuth();
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ title: '', starts_at: '', ends_at: '', notes: '' });
useEffect(() => {
api.listSchedules().then(setSchedules).catch(() => setError('Could not load schedules.'));
}, []);
async function handleCreate(e: FormEvent) {
e.preventDefault();
setError('');
try {
const sc = await api.createSchedule(form);
setSchedules(prev => [...prev, sc]);
setForm({ title: '', starts_at: '', ends_at: '', notes: '' });
setShowForm(false);
} catch (err: any) {
setError(err.message);
}
}
async function handleDelete(id: number) {
if (!window.confirm('Delete this schedule?')) return;
try {
await api.deleteSchedule(id);
setSchedules(prev => prev.filter(s => s.id !== id));
} catch (err: any) {
setError(err.message);
}
}
return (
<div className="page">
<div className="page-header">
<h2>Schedules</h2>
{role === 'admin' && (
<button onClick={() => setShowForm(v => !v)}>
{showForm ? 'Cancel' : 'Add Shift'}
</button>
)}
</div>
{error && <p className="error">{error}</p>}
{showForm && (
<form className="card" onSubmit={handleCreate}>
<h3>New Shift</h3>
<label>
Title
<input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} required />
</label>
<label>
Starts At
<input type="datetime-local" value={form.starts_at} onChange={e => setForm(f => ({ ...f, starts_at: e.target.value }))} required />
</label>
<label>
Ends At
<input type="datetime-local" value={form.ends_at} onChange={e => setForm(f => ({ ...f, ends_at: e.target.value }))} required />
</label>
<label>
Notes
<textarea value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} />
</label>
<button type="submit">Create</button>
</form>
)}
{schedules.length === 0 ? (
<p>No schedules found.</p>
) : (
<table>
<thead>
<tr>
<th>Title</th>
<th>Starts</th>
<th>Ends</th>
<th>Notes</th>
{role === 'admin' && <th>Actions</th>}
</tr>
</thead>
<tbody>
{schedules.map(s => (
<tr key={s.id}>
<td>{s.title}</td>
<td>{new Date(s.starts_at).toLocaleString()}</td>
<td>{new Date(s.ends_at).toLocaleString()}</td>
<td>{s.notes ?? '—'}</td>
{role === 'admin' && (
<td>
<button className="btn-danger btn-small" onClick={() => handleDelete(s.id)}>Delete</button>
</td>
)}
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

110
web/src/pages/TimeOff.tsx Normal file
View File

@@ -0,0 +1,110 @@
import React, { useEffect, useState, FormEvent } from 'react';
import { api, TimeOffRequest } from '../api';
import { useAuth } from '../auth';
export default function TimeOff() {
const { role } = useAuth();
const [requests, setRequests] = useState<TimeOffRequest[]>([]);
const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ starts_at: '', ends_at: '', reason: '' });
useEffect(() => {
api.listTimeOff().then(setRequests).catch(() => setError('Could not load requests.'));
}, []);
async function handleCreate(e: FormEvent) {
e.preventDefault();
setError('');
try {
const req = await api.createTimeOff(form);
setRequests(prev => [req, ...prev]);
setForm({ starts_at: '', ends_at: '', reason: '' });
setShowForm(false);
} catch (err: any) {
setError(err.message);
}
}
async function handleReview(id: number, status: 'approved' | 'rejected') {
try {
const req = await api.reviewTimeOff(id, status);
setRequests(prev => prev.map(r => r.id === id ? req : r));
} catch (err: any) {
setError(err.message);
}
}
const statusClass = (status: string) => {
if (status === 'approved') return 'status-approved';
if (status === 'rejected') return 'status-rejected';
return 'status-pending';
};
return (
<div className="page">
<div className="page-header">
<h2>Time Off Requests</h2>
<button onClick={() => setShowForm(v => !v)}>
{showForm ? 'Cancel' : 'Request Time Off'}
</button>
</div>
{error && <p className="error">{error}</p>}
{showForm && (
<form className="card" onSubmit={handleCreate}>
<h3>New Request</h3>
<label>
From
<input type="date" value={form.starts_at} onChange={e => setForm(f => ({ ...f, starts_at: e.target.value }))} required />
</label>
<label>
To
<input type="date" value={form.ends_at} onChange={e => setForm(f => ({ ...f, ends_at: e.target.value }))} required />
</label>
<label>
Reason
<textarea value={form.reason} onChange={e => setForm(f => ({ ...f, reason: e.target.value }))} />
</label>
<button type="submit">Submit</button>
</form>
)}
{requests.length === 0 ? (
<p>No time off requests.</p>
) : (
<table>
<thead>
<tr>
<th>From</th>
<th>To</th>
<th>Reason</th>
<th>Status</th>
{role === 'admin' && <th>Actions</th>}
</tr>
</thead>
<tbody>
{requests.map(r => (
<tr key={r.id}>
<td>{new Date(r.starts_at).toLocaleDateString()}</td>
<td>{new Date(r.ends_at).toLocaleDateString()}</td>
<td>{r.reason ?? '—'}</td>
<td><span className={statusClass(r.status)}>{r.status}</span></td>
{role === 'admin' && (
<td>
{r.status === 'pending' && (
<>
<button className="btn-small" onClick={() => handleReview(r.id, 'approved')}>Approve</button>
<button className="btn-small btn-danger" onClick={() => handleReview(r.id, 'rejected')}>Reject</button>
</>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import React, { useEffect, useState } from 'react';
import { api, Volunteer } from '../api';
export default function Volunteers() {
const [volunteers, setVolunteers] = useState<Volunteer[]>([]);
const [error, setError] = useState('');
useEffect(() => {
api.listVolunteers().then(setVolunteers).catch(() => setError('Could not load volunteers.'));
}, []);
async function handleToggleActive(v: Volunteer) {
try {
const updated = await api.updateVolunteer(v.id, { active: !v.active });
setVolunteers(prev => prev.map(vol => vol.id === v.id ? updated : vol));
} catch (err: any) {
setError(err.message);
}
}
return (
<div className="page">
<h2>Volunteers</h2>
{error && <p className="error">{error}</p>}
{volunteers.length === 0 ? (
<p>No volunteers found.</p>
) : (
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{volunteers.map(v => (
<tr key={v.id}>
<td>{v.name}</td>
<td>{v.email}</td>
<td>{v.role}</td>
<td>{v.active ? 'Active' : 'Inactive'}</td>
<td>
<button className="btn-small" onClick={() => handleToggleActive(v)}>
{v.active ? 'Deactivate' : 'Activate'}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}