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:
136
web/src/pages/Dashboard.tsx
Normal file
136
web/src/pages/Dashboard.tsx
Normal 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
43
web/src/pages/Login.tsx
Normal 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
106
web/src/pages/Schedules.tsx
Normal 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
110
web/src/pages/TimeOff.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
web/src/pages/Volunteers.tsx
Normal file
57
web/src/pages/Volunteers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user