Implement time off management (Issue #3)
Add full time-off lifecycle: create/edit/delete with shift conflict detection, auto-removal from conflicting shifts with admin notification, shift restoration on admin delete, and hard block on assigning volunteers with approved time off to shifts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,31 +1,96 @@
|
||||
import React, { useEffect, useState, FormEvent } from 'react';
|
||||
import { api, TimeOffRequest } from '../api';
|
||||
import { api, ApiError, TimeOffRequest, ConflictingShift, Volunteer } from '../api';
|
||||
import { useAuth } from '../auth';
|
||||
|
||||
export default function TimeOff() {
|
||||
const { role } = useAuth();
|
||||
const { role, volunteerID } = useAuth();
|
||||
const [requests, setRequests] = useState<TimeOffRequest[]>([]);
|
||||
const [volunteers, setVolunteers] = useState<Volunteer[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({ starts_at: '', ends_at: '', reason: '' });
|
||||
const [form, setForm] = useState({ starts_at: '', ends_at: '', reason: '', volunteer_id: 0 });
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [conflicts, setConflicts] = useState<ConflictingShift[] | null>(null);
|
||||
const [deletePreview, setDeletePreview] = useState<{ id: number; shifts: ConflictingShift[] } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.listTimeOff().then(setRequests).catch(() => setError('Could not load requests.'));
|
||||
}, []);
|
||||
if (role === 'admin') {
|
||||
api.listVolunteers().then(vols => setVolunteers(vols as Volunteer[]));
|
||||
}
|
||||
}, [role]);
|
||||
|
||||
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);
|
||||
const payload: any = { starts_at: form.starts_at, ends_at: form.ends_at, reason: form.reason };
|
||||
if (role === 'admin' && form.volunteer_id > 0) {
|
||||
payload.volunteer_id = form.volunteer_id;
|
||||
}
|
||||
if (conflicts) {
|
||||
payload.confirm_conflicts = true;
|
||||
}
|
||||
const result = await api.createTimeOff(payload);
|
||||
setRequests(prev => [result as TimeOffRequest, ...prev]);
|
||||
resetForm();
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError && err.status === 409 && err.data?.conflicts) {
|
||||
setConflicts(err.data.conflicts);
|
||||
return;
|
||||
}
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!editingId) return;
|
||||
setError('');
|
||||
try {
|
||||
const req = await api.updateTimeOff(editingId, {
|
||||
starts_at: form.starts_at,
|
||||
ends_at: form.ends_at,
|
||||
reason: form.reason,
|
||||
});
|
||||
setRequests(prev => prev.map(r => r.id === editingId ? req : r));
|
||||
resetForm();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
setError('');
|
||||
try {
|
||||
const result = await api.deleteTimeOff(id);
|
||||
setRequests(prev => prev.filter(r => r.id !== id));
|
||||
setDeletePreview(null);
|
||||
if (result.restored_shifts?.length > 0) {
|
||||
setError(`Restored volunteer to ${result.restored_shifts.length} shift(s).`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteClick(id: number) {
|
||||
if (role === 'admin') {
|
||||
try {
|
||||
const shifts = await api.getRemovedShifts(id);
|
||||
if (shifts.length > 0) {
|
||||
setDeletePreview({ id, shifts });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// If we can't fetch preview, proceed with confirm
|
||||
}
|
||||
}
|
||||
if (window.confirm('Delete this time off request?')) {
|
||||
handleDelete(id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReview(id: number, status: 'approved' | 'rejected') {
|
||||
try {
|
||||
const req = await api.reviewTimeOff(id, status);
|
||||
@@ -35,71 +100,150 @@ export default function TimeOff() {
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(r: TimeOffRequest) {
|
||||
setEditingId(r.id);
|
||||
setForm({
|
||||
starts_at: r.starts_at.split('T')[0],
|
||||
ends_at: r.ends_at.split('T')[0],
|
||||
reason: r.reason ?? '',
|
||||
volunteer_id: 0,
|
||||
});
|
||||
setShowForm(true);
|
||||
setConflicts(null);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setForm({ starts_at: '', ends_at: '', reason: '', volunteer_id: 0 });
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setConflicts(null);
|
||||
}
|
||||
|
||||
function canEditOrDelete(r: TimeOffRequest): boolean {
|
||||
if (role === 'admin') return true;
|
||||
if (r.volunteer_id !== volunteerID) return false;
|
||||
return new Date(r.starts_at) > new Date();
|
||||
}
|
||||
|
||||
const statusClass = (status: string) => {
|
||||
if (status === 'approved') return 'status-approved';
|
||||
if (status === 'rejected') return 'status-rejected';
|
||||
return 'status-pending';
|
||||
};
|
||||
|
||||
const volunteerName = (vid: number) => {
|
||||
const v = volunteers.find(v => v.id === vid);
|
||||
return v ? v.name : `#${vid}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h2>Time Off Requests</h2>
|
||||
<button onClick={() => setShowForm(v => !v)}>
|
||||
<button onClick={() => { if (showForm) resetForm(); else setShowForm(true); }}>
|
||||
{showForm ? 'Cancel' : 'Request Time Off'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
{showForm && (
|
||||
<form className="card" onSubmit={handleCreate}>
|
||||
<h3>New Request</h3>
|
||||
<form className="card" onSubmit={editingId ? handleUpdate : handleCreate}>
|
||||
<h3>{editingId ? 'Edit Request' : 'New Request'}</h3>
|
||||
{role === 'admin' && !editingId && (
|
||||
<label>
|
||||
Volunteer
|
||||
<select
|
||||
value={form.volunteer_id}
|
||||
onChange={e => setForm(f => ({ ...f, volunteer_id: Number(e.target.value) }))}
|
||||
>
|
||||
<option value={0}>Myself</option>
|
||||
{volunteers.map(v => (
|
||||
<option key={v.id} value={v.id}>{v.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
From
|
||||
<input type="date" value={form.starts_at} onChange={e => setForm(f => ({ ...f, starts_at: e.target.value }))} required />
|
||||
<input type="date" value={form.starts_at} onChange={e => { setForm(f => ({ ...f, starts_at: e.target.value })); setConflicts(null); }} required />
|
||||
</label>
|
||||
<label>
|
||||
To
|
||||
<input type="date" value={form.ends_at} onChange={e => setForm(f => ({ ...f, ends_at: e.target.value }))} required />
|
||||
<input type="date" value={form.ends_at} onChange={e => { setForm(f => ({ ...f, ends_at: e.target.value })); setConflicts(null); }} required />
|
||||
</label>
|
||||
<label>
|
||||
Reason
|
||||
<textarea value={form.reason} onChange={e => setForm(f => ({ ...f, reason: e.target.value }))} />
|
||||
</label>
|
||||
<button type="submit">Submit</button>
|
||||
|
||||
{conflicts && (
|
||||
<div className="card" style={{ background: '#fff3cd', border: '1px solid #ffc107', marginBottom: '1rem' }}>
|
||||
<p><strong>Warning:</strong> This time off conflicts with {conflicts.length} assigned shift(s):</p>
|
||||
<ul>
|
||||
{conflicts.map(c => (
|
||||
<li key={c.instance_id}>{c.name} on {c.date} ({c.start_time}–{c.end_time})</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>You will be removed from these shifts. Continue?</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit">
|
||||
{conflicts ? 'Confirm & Submit' : editingId ? 'Save Changes' : 'Submit'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{deletePreview && (
|
||||
<div className="card" style={{ background: '#d4edda', border: '1px solid #28a745', marginBottom: '1rem' }}>
|
||||
<h3>Delete Time Off — Shift Restoration Preview</h3>
|
||||
<p>Deleting this time off will restore the volunteer to {deletePreview.shifts.length} shift(s):</p>
|
||||
<ul>
|
||||
{deletePreview.shifts.map(s => (
|
||||
<li key={s.instance_id}>{s.name} on {s.date} ({s.start_time}–{s.end_time})</li>
|
||||
))}
|
||||
</ul>
|
||||
<button onClick={() => handleDelete(deletePreview.id)}>Confirm Delete & Restore</button>
|
||||
<button onClick={() => setDeletePreview(null)} style={{ marginLeft: '0.5rem' }}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requests.length === 0 ? (
|
||||
<p>No time off requests.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{role === 'admin' && <th>Volunteer</th>}
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>Reason</th>
|
||||
<th>Status</th>
|
||||
{role === 'admin' && <th>Actions</th>}
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{requests.map(r => (
|
||||
<tr key={r.id}>
|
||||
{role === 'admin' && <td>{volunteerName(r.volunteer_id)}</td>}
|
||||
<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>
|
||||
)}
|
||||
<td>
|
||||
{role === 'admin' && 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>
|
||||
</>
|
||||
)}
|
||||
{canEditOrDelete(r) && (
|
||||
<>
|
||||
<button className="btn-small" onClick={() => startEdit(r)}>Edit</button>
|
||||
<button className="btn-small btn-danger" onClick={() => handleDeleteClick(r.id)}>Delete</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user