Files
walkies/web/src/pages/TimeOff.tsx
James Griffin 975225e650
All checks were successful
CI / Go tests & lint (push) Successful in 9s
CI / Frontend tests & type-check (push) Successful in 29s
CI / Go tests & lint (pull_request) Successful in 9s
CI / Frontend tests & type-check (pull_request) Successful in 25s
Fix time-off date display and improve UI
Scan datetime columns directly into time.Time instead of strings in the
timeoff store — the intermediate string parse silently failed with
parseTime=true, producing zero-value dates. Display dates with month
names and filter admin's own entry from the volunteer dropdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 16:07:42 -03:00

255 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState, FormEvent } from 'react';
import { api, ApiError, TimeOffRequest, ConflictingShift, Volunteer } from '../api';
import { useAuth } from '../auth';
export default function TimeOff() {
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: '', 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 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);
setRequests(prev => prev.map(r => r.id === id ? req : r));
} catch (err: any) {
setError(err.message);
}
}
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={() => { if (showForm) resetForm(); else setShowForm(true); }}>
{showForm ? 'Cancel' : 'Request Time Off'}
</button>
</div>
{error && <p className="error">{error}</p>}
{showForm && (
<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.filter(v => v.id !== volunteerID).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 })); setConflicts(null); }} required />
</label>
<label>
To
<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>
{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 &amp; 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>
<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(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}</td>
<td>{new Date(r.ends_at).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}</td>
<td>{r.reason ?? '—'}</td>
<td><span className={statusClass(r.status)}>{r.status}</span></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>
</table>
)}
</div>
);
}