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>
255 lines
9.0 KiB
TypeScript
255 lines
9.0 KiB
TypeScript
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 & 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>
|
||
);
|
||
}
|