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:
@@ -4,6 +4,16 @@ function getToken(): string | null {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
status: number;
|
||||
data: any;
|
||||
constructor(message: string, status: number, data: any) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const token = getToken();
|
||||
@@ -17,10 +27,12 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||
if (!res.ok) throw new ApiError(data.error || data.message || 'Request failed', res.status, data);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export { ApiError };
|
||||
|
||||
export const api = {
|
||||
// Setup
|
||||
getSetupStatus: () =>
|
||||
@@ -67,9 +79,16 @@ export const api = {
|
||||
|
||||
// Time off
|
||||
listTimeOff: () => request<TimeOffRequest[]>('GET', '/timeoff'),
|
||||
createTimeOff: (data: CreateTimeOffInput) => request<TimeOffRequest>('POST', '/timeoff', data),
|
||||
createTimeOff: (data: CreateTimeOffInput & { volunteer_id?: number; confirm_conflicts?: boolean }) =>
|
||||
request<TimeOffRequest | TimeOffConflictResponse>('POST', '/timeoff', data),
|
||||
updateTimeOff: (id: number, data: { starts_at: string; ends_at: string; reason?: string }) =>
|
||||
request<TimeOffRequest>('PUT', `/timeoff/${id}`, data),
|
||||
deleteTimeOff: (id: number) =>
|
||||
request<{ deleted: boolean; restored_shifts: ConflictingShift[] }>('DELETE', `/timeoff/${id}`),
|
||||
reviewTimeOff: (id: number, status: 'approved' | 'rejected') =>
|
||||
request<TimeOffRequest>('PUT', `/timeoff/${id}/review`, { status }),
|
||||
getRemovedShifts: (id: number) =>
|
||||
request<ConflictingShift[]>('GET', `/timeoff/${id}/shifts`),
|
||||
|
||||
// Check-in / out
|
||||
checkIn: (schedule_id?: number, notes?: string) =>
|
||||
@@ -206,6 +225,19 @@ export interface CreateTimeOffInput {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ConflictingShift {
|
||||
instance_id: number;
|
||||
name: string;
|
||||
date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
}
|
||||
|
||||
export interface TimeOffConflictResponse {
|
||||
message: string;
|
||||
conflicts: ConflictingShift[];
|
||||
}
|
||||
|
||||
export interface CheckIn {
|
||||
id: number;
|
||||
volunteer_id: number;
|
||||
|
||||
235
web/src/pages/TimeOff.test.tsx
Normal file
235
web/src/pages/TimeOff.test.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import TimeOff from './TimeOff';
|
||||
import { api, TimeOffRequest, ApiError } from '../api';
|
||||
import { AuthProvider } from '../auth';
|
||||
|
||||
jest.mock('../api', () => {
|
||||
class MockApiError extends Error {
|
||||
status: number;
|
||||
data: any;
|
||||
constructor(message: string, status: number, data: any) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
return {
|
||||
api: {
|
||||
listTimeOff: jest.fn(),
|
||||
createTimeOff: jest.fn(),
|
||||
updateTimeOff: jest.fn(),
|
||||
deleteTimeOff: jest.fn(),
|
||||
reviewTimeOff: jest.fn(),
|
||||
getRemovedShifts: jest.fn(),
|
||||
listVolunteers: jest.fn(),
|
||||
},
|
||||
ApiError: MockApiError,
|
||||
};
|
||||
});
|
||||
|
||||
const mockListTimeOff = api.listTimeOff as jest.Mock;
|
||||
const mockCreateTimeOff = api.createTimeOff as jest.Mock;
|
||||
const mockDeleteTimeOff = api.deleteTimeOff as jest.Mock;
|
||||
const mockReviewTimeOff = api.reviewTimeOff as jest.Mock;
|
||||
const mockGetRemovedShifts = api.getRemovedShifts as jest.Mock;
|
||||
const mockListVolunteers = api.listVolunteers as jest.Mock;
|
||||
|
||||
function buildFakeJWT(payload: object): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
||||
const body = btoa(JSON.stringify(payload));
|
||||
return `${header}.${body}.fakesig`;
|
||||
}
|
||||
|
||||
const ADMIN_TOKEN = buildFakeJWT({ volunteer_id: 1, role: 'admin', exp: 9999999999 });
|
||||
const VOL_TOKEN = buildFakeJWT({ volunteer_id: 10, role: 'volunteer', exp: 9999999999 });
|
||||
|
||||
const futureRequest: TimeOffRequest = {
|
||||
id: 1,
|
||||
volunteer_id: 10,
|
||||
starts_at: '2026-06-01T00:00:00Z',
|
||||
ends_at: '2026-06-03T00:00:00Z',
|
||||
reason: 'vacation',
|
||||
status: 'approved',
|
||||
created_at: '2026-04-01T00:00:00Z',
|
||||
updated_at: '2026-04-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const pastRequest: TimeOffRequest = {
|
||||
id: 2,
|
||||
volunteer_id: 10,
|
||||
starts_at: '2020-01-01T00:00:00Z',
|
||||
ends_at: '2020-01-03T00:00:00Z',
|
||||
reason: 'sick',
|
||||
status: 'approved',
|
||||
created_at: '2020-01-01T00:00:00Z',
|
||||
updated_at: '2020-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
function renderAsVolunteer() {
|
||||
localStorage.setItem('token', VOL_TOKEN);
|
||||
return render(
|
||||
<AuthProvider>
|
||||
<MemoryRouter>
|
||||
<TimeOff />
|
||||
</MemoryRouter>
|
||||
</AuthProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function renderAsAdmin() {
|
||||
localStorage.setItem('token', ADMIN_TOKEN);
|
||||
return render(
|
||||
<AuthProvider>
|
||||
<MemoryRouter>
|
||||
<TimeOff />
|
||||
</MemoryRouter>
|
||||
</AuthProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockListVolunteers.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe('TimeOff page', () => {
|
||||
it('renders empty state', async () => {
|
||||
mockListTimeOff.mockResolvedValue([]);
|
||||
renderAsVolunteer();
|
||||
await waitFor(() => expect(screen.getByText('No time off requests.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders request list for volunteer', async () => {
|
||||
mockListTimeOff.mockResolvedValue([futureRequest]);
|
||||
renderAsVolunteer();
|
||||
await waitFor(() => expect(screen.getByText('vacation')).toBeInTheDocument());
|
||||
expect(screen.getByText('approved')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows edit and delete buttons for own future time off', async () => {
|
||||
mockListTimeOff.mockResolvedValue([futureRequest]);
|
||||
renderAsVolunteer();
|
||||
await waitFor(() => expect(screen.getByText('Edit')).toBeInTheDocument());
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show edit/delete for past time off as volunteer', async () => {
|
||||
mockListTimeOff.mockResolvedValue([pastRequest]);
|
||||
renderAsVolunteer();
|
||||
await waitFor(() => expect(screen.getByText('sick')).toBeInTheDocument());
|
||||
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows create form when button clicked', async () => {
|
||||
mockListTimeOff.mockResolvedValue([]);
|
||||
renderAsVolunteer();
|
||||
await waitFor(() => expect(screen.getByText('Request Time Off')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Request Time Off'));
|
||||
expect(screen.getByText('New Request')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates time off request successfully', async () => {
|
||||
mockListTimeOff.mockResolvedValue([]);
|
||||
mockCreateTimeOff.mockResolvedValue(futureRequest);
|
||||
renderAsVolunteer();
|
||||
|
||||
fireEvent.click(screen.getByText('Request Time Off'));
|
||||
fireEvent.change(screen.getByLabelText('From'), { target: { value: '2026-06-01' } });
|
||||
fireEvent.change(screen.getByLabelText('To'), { target: { value: '2026-06-03' } });
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
|
||||
await waitFor(() => expect(mockCreateTimeOff).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('shows conflict warning on 409 and allows confirmation', async () => {
|
||||
mockListTimeOff.mockResolvedValue([]);
|
||||
const { ApiError: MockApiError } = jest.requireMock('../api');
|
||||
mockCreateTimeOff
|
||||
.mockRejectedValueOnce(
|
||||
new MockApiError('conflict', 409, {
|
||||
message: 'Time off conflicts with assigned shifts.',
|
||||
conflicts: [
|
||||
{ instance_id: 100, name: 'Morning Walk', date: '2026-06-01', start_time: '08:00', end_time: '12:00' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(futureRequest);
|
||||
|
||||
renderAsVolunteer();
|
||||
fireEvent.click(screen.getByText('Request Time Off'));
|
||||
fireEvent.change(screen.getByLabelText('From'), { target: { value: '2026-06-01' } });
|
||||
fireEvent.change(screen.getByLabelText('To'), { target: { value: '2026-06-03' } });
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/conflicts with 1 assigned shift/)).toBeInTheDocument());
|
||||
expect(screen.getByText(/Morning Walk/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Confirm & Submit')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Confirm & Submit'));
|
||||
await waitFor(() =>
|
||||
expect(mockCreateTimeOff).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ confirm_conflicts: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('admin sees volunteer column and approve/reject buttons', async () => {
|
||||
const pendingReq: TimeOffRequest = { ...futureRequest, status: 'pending', volunteer_id: 10 };
|
||||
mockListTimeOff.mockResolvedValue([pendingReq]);
|
||||
mockListVolunteers.mockResolvedValue([{ id: 10, name: 'Alice' }]);
|
||||
renderAsAdmin();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Volunteer')).toBeInTheDocument());
|
||||
expect(screen.getByText('Approve')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reject')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('admin can approve a request', async () => {
|
||||
const pendingReq: TimeOffRequest = { ...futureRequest, status: 'pending' };
|
||||
mockListTimeOff.mockResolvedValue([pendingReq]);
|
||||
mockReviewTimeOff.mockResolvedValue({ ...pendingReq, status: 'approved' });
|
||||
mockListVolunteers.mockResolvedValue([]);
|
||||
renderAsAdmin();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Approve')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Approve'));
|
||||
await waitFor(() => expect(mockReviewTimeOff).toHaveBeenCalledWith(1, 'approved'));
|
||||
});
|
||||
|
||||
it('admin sees shift restoration preview when deleting', async () => {
|
||||
mockListTimeOff.mockResolvedValue([futureRequest]);
|
||||
mockGetRemovedShifts.mockResolvedValue([
|
||||
{ instance_id: 100, name: 'Morning Walk', date: '2026-06-01', start_time: '08:00', end_time: '12:00' },
|
||||
]);
|
||||
mockDeleteTimeOff.mockResolvedValue({ deleted: true, restored_shifts: [] });
|
||||
mockListVolunteers.mockResolvedValue([{ id: 10, name: 'Alice' }]);
|
||||
renderAsAdmin();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Delete')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Delete Time Off — Shift Restoration Preview')).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText(/Morning Walk/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('admin sees volunteer picker in create form', async () => {
|
||||
mockListTimeOff.mockResolvedValue([]);
|
||||
mockListVolunteers.mockResolvedValue([
|
||||
{ id: 10, name: 'Alice' },
|
||||
{ id: 20, name: 'Bob' },
|
||||
]);
|
||||
renderAsAdmin();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Request Time Off')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Request Time Off'));
|
||||
expect(screen.getByText('Volunteer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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