feat(admin): Dispatches tab + user-edit form + extended event form

New Dispatches tab — full CRUD matching the existing pattern (?tab=
querystring, plain HTML POST, hidden action field, redirect-with-?msg=).
Author select is restricted to Fenja-role users (defaults to the current
admin). Create form has a status toggle (draft / publish on save).
publish_dispatch stamps published_at via the existing helper; archive
preserves it. Body is a monospace textarea so admins can see markdown
without proportional kerning confusion.

Participants tab gains a per-row Edit link. When ?tab=participants&edit=ID
is set, the table is replaced by <UserEditTab>: title input, comma-
separated focus_tags input (parsed server-side via parseFocusTags), a
pull_quote textarea with a 200-char live counter, and a read-only
member_number display (set on role transition to cab). The inline role
dropdown + deactivate stay on the table.

EventsTab — adds audience, duration_label, action_label, notes_url
inputs. Kind <select> now labels office_hours as 'Studio hours' and
exposes working_session as the new fifth option.

Admin action-link / action-cell styles were missing on admin/index.astro
(they were defined only inside per-tab components); added to the page
stylesheet so the new Participants Edit link inherits the same look.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-11 16:10:20 +02:00
parent 1bf1993040
commit fd3f433933
4 changed files with 399 additions and 28 deletions

View file

@ -0,0 +1,175 @@
---
import type { DispatchWithAuthor, UserPublic } from '../../lib/db';
import { fmtDateTime } from '../../lib/markdown';
import { dispatchKindLabel } from '../../lib/format';
interface Props {
dispatches: DispatchWithAuthor[];
editing: DispatchWithAuthor | null;
fenjaUsers: UserPublic[];
currentUserId: number;
}
const { dispatches, editing, fenjaUsers, currentUserId } = Astro.props;
const STATUS_LABEL: Record<string, string> = {
draft: 'Draft',
published: 'Published',
archived: 'Archived',
};
const formAction = editing ? 'update_dispatch' : 'create_dispatch';
const defaultAuthorId = editing?.author_id ?? currentUserId;
---
<div class="tab-content">
<section class="section">
<h2 class="label-sm section-heading">{editing ? 'Edit dispatch' : 'New dispatch'}</h2>
<form method="POST" class="invite-form" novalidate>
<input type="hidden" name="action" value={formAction} />
{editing && <input type="hidden" name="dispatch_id" value={editing.id} />}
<div class="field">
<label for="d-title" class="label-sm field-label">Title</label>
<input type="text" id="d-title" name="title" class="input body-md" required value={editing?.title ?? ''} />
</div>
<div class="form-grid">
<div class="field">
<label for="d-kind" class="label-sm field-label">Kind</label>
<select id="d-kind" name="kind" class="select body-md" required>
{(['decision','update','behind_the_scenes','note'] as const).map(k => (
<option value={k} selected={editing?.kind === k}>{dispatchKindLabel(k)}</option>
))}
</select>
</div>
<div class="field">
<label for="d-author" class="label-sm field-label">Author (Fenja team)</label>
<select id="d-author" name="author_id" class="select body-md" required>
{fenjaUsers.map(u => (
<option value={u.id} selected={u.id === defaultAuthorId}>
{u.name}{u.title ? ` — ${u.title}` : ''}
</option>
))}
</select>
</div>
</div>
<div class="field">
<label for="d-excerpt" class="label-sm field-label">Excerpt (optional — falls back to first ~200 chars of body)</label>
<input type="text" id="d-excerpt" name="excerpt" class="input body-md" value={editing?.excerpt ?? ''} />
</div>
<div class="field">
<label for="d-body" class="label-sm field-label">Body (markdown)</label>
<textarea id="d-body" name="body" class="input body-md mono" rows="12" required>{editing?.body ?? ''}</textarea>
</div>
{!editing && (
<div class="form-grid">
<div class="field">
<label for="d-status" class="label-sm field-label">Status on save</label>
<select id="d-status" name="status" class="select body-md">
<option value="draft" selected>Draft (hidden from members)</option>
<option value="published">Published (stamps published_at)</option>
</select>
</div>
</div>
)}
<div class="form-actions">
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Save dispatch'}</button>
{editing && <a href="/admin?tab=dispatches" class="action-link label-sm">Cancel</a>}
</div>
</form>
</section>
<section class="section">
<h2 class="label-sm section-heading">All dispatches</h2>
{dispatches.length === 0 ? (
<p class="body-sm empty-msg">No dispatches yet.</p>
) : (
<table class="data-table">
<thead>
<tr>
<th class="label-sm">Title</th>
<th class="label-sm">Kind</th>
<th class="label-sm">Author</th>
<th class="label-sm">Status</th>
<th class="label-sm">Published</th>
<th class="label-sm">Actions</th>
</tr>
</thead>
<tbody>
{dispatches.map(d => (
<tr>
<td class="body-sm">{d.title}</td>
<td class="body-sm muted">{dispatchKindLabel(d.kind)}</td>
<td class="body-sm">{d.author_name}</td>
<td class="body-sm"><span class:list={['status-pill', `status-${d.status}`]}>{STATUS_LABEL[d.status]}</span></td>
<td class="body-sm muted">{d.published_at ? fmtDateTime(d.published_at) : '—'}</td>
<td class="action-cell">
<a href={`/admin?tab=dispatches&edit=${d.id}`} class="action-link label-sm">Edit</a>
{d.status === 'draft' && (
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="publish_dispatch" />
<input type="hidden" name="dispatch_id" value={d.id} />
<button type="submit" class="action-link label-sm">Publish</button>
</form>
)}
{d.status === 'published' && (
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="archive_dispatch" />
<input type="hidden" name="dispatch_id" value={d.id} />
<button type="submit" class="action-link label-sm">Archive</button>
</form>
)}
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="delete_dispatch" />
<input type="hidden" name="dispatch_id" value={d.id} />
<button type="submit" class="danger-btn label-sm" onclick="return confirm('Delete this dispatch?')">Delete</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
<style>
.mono { font-family: var(--font-mono); font-size: var(--text-body-sm); }
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
.status-pill {
display: inline-block;
padding: 0.15em var(--space-3);
border-radius: var(--radius-full);
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.status-draft { background: var(--surface-container); color: var(--on-surface-muted); }
.status-published { background: rgba(109, 140, 124, 0.18); color: var(--pigment-copper); font-weight: 600; }
.status-archived { background: var(--surface-container-low); color: var(--on-surface-muted); font-style: italic; }
.action-cell { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.action-link {
background: none;
border: none;
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
font-family: var(--font-sans);
transition: color var(--duration-fast) var(--ease-standard);
padding: 0;
}
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
</style>

View file

@ -12,10 +12,11 @@ interface Props {
const { events, editing, viewing, viewingRsvps } = Astro.props; const { events, editing, viewing, viewingRsvps } = Astro.props;
const KIND_LABEL = { const KIND_LABEL = {
dinner: 'Dinner', dinner: 'Dinner',
office_hours: 'Office hours', office_hours: 'Studio hours',
summit: 'Summit', summit: 'Summit',
virtual: 'Virtual', virtual: 'Virtual',
working_session: 'Working session',
} as const; } as const;
function toInputValue(sql: string | null | undefined): string { function toInputValue(sql: string | null | undefined): string {
@ -58,10 +59,11 @@ const formAction = editing ? 'update_event' : 'create_event';
<div class="field"> <div class="field">
<label for="kind" class="label-sm field-label">Kind</label> <label for="kind" class="label-sm field-label">Kind</label>
<select id="kind" name="kind" class="select body-md" required> <select id="kind" name="kind" class="select body-md" required>
<option value="dinner" selected={editing?.kind === 'dinner'}>Dinner</option> <option value="dinner" selected={editing?.kind === 'dinner'}>Dinner</option>
<option value="office_hours" selected={editing?.kind === 'office_hours'}>Office hours</option> <option value="office_hours" selected={editing?.kind === 'office_hours'}>Studio hours</option>
<option value="summit" selected={editing?.kind === 'summit'}>Summit</option> <option value="working_session" selected={editing?.kind === 'working_session'}>Working session</option>
<option value="virtual" selected={editing?.kind === 'virtual'}>Virtual</option> <option value="summit" selected={editing?.kind === 'summit'}>Summit</option>
<option value="virtual" selected={editing?.kind === 'virtual'}>Virtual</option>
</select> </select>
</div> </div>
<div class="field"> <div class="field">
@ -84,6 +86,22 @@ const formAction = editing ? 'update_event' : 'create_event';
<label for="photo_url" class="label-sm field-label">Photo URL (optional, for past events)</label> <label for="photo_url" class="label-sm field-label">Photo URL (optional, for past events)</label>
<input type="text" id="photo_url" name="photo_url" class="input body-md" value={editing?.photo_url ?? ''} /> <input type="text" id="photo_url" name="photo_url" class="input body-md" value={editing?.photo_url ?? ''} />
</div> </div>
<div class="field">
<label for="audience" class="label-sm field-label">Audience (e.g. "Members only")</label>
<input type="text" id="audience" name="audience" class="input body-md" value={editing?.audience ?? ''} />
</div>
<div class="field">
<label for="duration_label" class="label-sm field-label">Duration label</label>
<input type="text" id="duration_label" name="duration_label" class="input body-md" value={editing?.duration_label ?? ''} placeholder="e.g. 30 minutes, 7pm onwards" />
</div>
<div class="field">
<label for="action_label" class="label-sm field-label">Action label (optional)</label>
<input type="text" id="action_label" name="action_label" class="input body-md" value={editing?.action_label ?? ''} placeholder="Override the default for this event kind" />
</div>
<div class="field">
<label for="notes_url" class="label-sm field-label">Notes URL (optional)</label>
<input type="url" id="notes_url" name="notes_url" class="input body-md" value={editing?.notes_url ?? ''} placeholder="https://…" />
</div>
</div> </div>
<div class="field"> <div class="field">

View file

@ -0,0 +1,90 @@
---
import type { UserPublic } from '../../lib/db';
import { readFocusTags } from '../../lib/format';
interface Props {
member: UserPublic;
}
const { member } = Astro.props;
const tagsStr = readFocusTags(member.focus_tags).join(', ');
---
<div class="tab-content">
<section class="section">
<a href="/admin?tab=participants" class="action-link label-sm">← Back to participants</a>
<h2 class="label-sm section-heading">Edit member — {member.name}</h2>
<form method="POST" class="invite-form" novalidate>
<input type="hidden" name="action" value="update_user_admin" />
<input type="hidden" name="user_id" value={member.id} />
<div class="form-grid">
<div class="field">
<label class="label-sm field-label">Name</label>
<input type="text" class="input body-md" value={member.name} disabled />
</div>
<div class="field">
<label class="label-sm field-label">Email</label>
<input type="text" class="input body-md" value={member.email} disabled />
</div>
<div class="field">
<label class="label-sm field-label">Organisation</label>
<input type="text" class="input body-md" value={member.organisation} disabled />
</div>
<div class="field">
<label class="label-sm field-label">Member number {member.role === 'cab' ? '(allocated)' : '(only set for cab role)'}</label>
<input type="text" class="input body-md" value={member.member_number ?? '—'} disabled />
</div>
<div class="field">
<label for="title" class="label-sm field-label">Job title</label>
<input type="text" id="title" name="title" class="input body-md" value={member.title ?? ''} placeholder="e.g. Senior Adviser" />
</div>
<div class="field">
<label for="focus_tags" class="label-sm field-label">Focus tags (comma-separated, max 3 × 24 chars)</label>
<input type="text" id="focus_tags" name="focus_tags" class="input body-md" value={tagsStr} placeholder="GDPR, Telemetry, Policy" />
</div>
</div>
<div class="field">
<label for="pull_quote" class="label-sm field-label">Pull quote (one sentence in their voice — max 200 chars)</label>
<textarea id="pull_quote" name="pull_quote" class="input body-md" rows="3" maxlength="200" data-counter>{member.pull_quote ?? ''}</textarea>
<span class="char-counter label-sm" data-counter-for="pull_quote">{(member.pull_quote ?? '').length} / 200</span>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary label-sm">Save changes</button>
<a href="/admin?tab=participants" class="action-link label-sm">Cancel</a>
</div>
</form>
<p class="body-sm note">
Role transitions and deactivation live in the participants table.
A member-number is allocated the first time a user becomes CAB and is never reused.
</p>
</section>
</div>
<script>
// Tiny live counter for the 200-char pull-quote field — no framework.
document.querySelectorAll<HTMLTextAreaElement>('[data-counter]').forEach((el) => {
const counter = document.querySelector<HTMLElement>(`[data-counter-for="${el.id}"]`);
if (!counter) return;
const update = () => { counter.textContent = `${el.value.length} / 200`; };
el.addEventListener('input', update);
});
</script>
<style>
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
.char-counter { color: var(--on-surface-muted); margin-top: var(--space-1); display: inline-block; }
.note {
color: var(--on-surface-muted);
margin-top: var(--space-4);
max-width: var(--reading-max);
}
.input:disabled {
color: var(--on-surface-muted);
background: var(--surface-container-low);
cursor: not-allowed;
}
</style>

View file

@ -2,24 +2,29 @@
import AppLayout from '../../layouts/AppLayout.astro'; import AppLayout from '../../layouts/AppLayout.astro';
import { import {
getAllInvites, getAllUsersPublic, revokeInvite, getAllInvites, getAllUsersPublic, revokeInvite,
createInvite, updateUserRole, deactivateUser, createInvite, updateUserRole, deactivateUser, updateUserAdminFields,
getAllJoinRequests, getUserPublicById, getAllJoinRequests,
createPulse, updatePulse, publishPulse, closePulse, deletePulse, createPulse, updatePulse, publishPulse, closePulse, deletePulse,
getAllPulses, getPulseById, getPulseWithCounts, getAllPulses, getPulseById, getPulseWithCounts,
createRoadmapItem, updateRoadmapItem, deleteRoadmapItem, createRoadmapItem, updateRoadmapItem, deleteRoadmapItem,
setRoadmapAttributions, getAllRoadmapItems, getRoadmapItem, setRoadmapAttributions, getAllRoadmapItems, getRoadmapItem,
createEvent, updateEvent, deleteEvent, getAllEvents, getEventBySlug, createEvent, updateEvent, deleteEvent, getAllEvents, getEventBySlug,
getEventRsvpCount, getEventById, getEventRsvpCount, getEventById,
createDispatch, updateDispatch, publishDispatch, archiveDispatch,
deleteDispatch, getAllDispatchesForAdmin, getDispatchById,
recordActivity, getAllActivityForAdmin, recordActivity, getAllActivityForAdmin,
} from '../../lib/db'; } from '../../lib/db';
import { generateInviteToken, inviteExpiresAt } from '../../lib/auth'; import { generateInviteToken, inviteExpiresAt } from '../../lib/auth';
import { fmtDate } from '../../lib/markdown'; import { fmtDate } from '../../lib/markdown';
import { parseFocusTags } from '../../lib/format';
import { notifyPulseOpened } from '../../lib/notify'; import { notifyPulseOpened } from '../../lib/notify';
import PulsesTab from '../../components/admin/PulsesTab.astro'; import PulsesTab from '../../components/admin/PulsesTab.astro';
import RoadmapTab from '../../components/admin/RoadmapTab.astro'; import RoadmapTab from '../../components/admin/RoadmapTab.astro';
import EventsTab from '../../components/admin/EventsTab.astro'; import EventsTab from '../../components/admin/EventsTab.astro';
import ActivityTab from '../../components/admin/ActivityTab.astro'; import ActivityTab from '../../components/admin/ActivityTab.astro';
import type { Role, RoadmapStatus, EventKind } from '../../lib/db'; import DispatchesTab from '../../components/admin/DispatchesTab.astro';
import UserEditTab from '../../components/admin/UserEditTab.astro';
import type { Role, RoadmapStatus, EventKind, DispatchKind, DispatchStatus } from '../../lib/db';
const user = Astro.locals.user; const user = Astro.locals.user;
@ -75,6 +80,50 @@ if (Astro.request.method === 'POST') {
if (userId && userId !== user.id) deactivateUser(userId); if (userId && userId !== user.id) deactivateUser(userId);
return Astro.redirect('/admin?tab=participants&msg=deactivated'); return Astro.redirect('/admin?tab=participants&msg=deactivated');
// ── User profile edit (title / pull_quote / focus_tags) ─────
} else if (action === 'update_user_admin') {
const userId = Number(data.get('user_id'));
if (userId) {
const title = String(data.get('title') ?? '').trim() || null;
const pullQuote = String(data.get('pull_quote') ?? '').trim() || null;
const tagsInput = String(data.get('focus_tags') ?? '');
const focusTags = parseFocusTags(tagsInput);
updateUserAdminFields(userId, { title, pull_quote: pullQuote, focus_tags: focusTags });
}
return Astro.redirect(`/admin?tab=participants&edit=${userId}&msg=user_updated`);
// ── Dispatches ───────────────────────────────────────────────
} else if (action === 'create_dispatch' || action === 'update_dispatch') {
const title = String(data.get('title') ?? '').trim();
const body = String(data.get('body') ?? '');
const excerpt = String(data.get('excerpt') ?? '').trim() || null;
const kind = String(data.get('kind') ?? '') as DispatchKind;
const authorId = Number(data.get('author_id'));
const status = String(data.get('status') ?? 'draft') as DispatchStatus;
if (!title || !body || !['decision','update','behind_the_scenes','note'].includes(kind)) {
formError = 'Title, body, and a valid kind are required.';
} else if (action === 'create_dispatch') {
createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status });
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_created');
} else {
const id = Number(data.get('dispatch_id'));
if (id) updateDispatch(id, { title, body, excerpt, kind, author_id: authorId || user.id });
return Astro.redirect(`/admin?tab=dispatches&edit=${id}&msg=dispatch_updated`);
}
} else if (action === 'publish_dispatch') {
const id = Number(data.get('dispatch_id'));
if (id) publishDispatch(id);
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_published');
} else if (action === 'archive_dispatch') {
const id = Number(data.get('dispatch_id'));
if (id) archiveDispatch(id);
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_archived');
} else if (action === 'delete_dispatch') {
const id = Number(data.get('dispatch_id'));
if (id) deleteDispatch(id);
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_deleted');
// ── Pulses ─────────────────────────────────────────────────── // ── Pulses ───────────────────────────────────────────────────
} else if (action === 'create_pulse' || action === 'update_pulse') { } else if (action === 'create_pulse' || action === 'update_pulse') {
const question = String(data.get('question') ?? '').trim(); const question = String(data.get('question') ?? '').trim();
@ -229,6 +278,12 @@ const joinRequests = getAllJoinRequests();
const editId = Number(Astro.url.searchParams.get('edit') ?? 0) || null; const editId = Number(Astro.url.searchParams.get('edit') ?? 0) || null;
const viewId = Number(Astro.url.searchParams.get('view') ?? 0) || null; const viewId = Number(Astro.url.searchParams.get('view') ?? 0) || null;
const fenjaUsers = users.filter(u => u.role === 'fenja');
const editingUser = tab === 'participants' && editId ? getUserPublicById(editId) : null;
const dispatches = tab === 'dispatches' ? getAllDispatchesForAdmin() : [];
const dispatchEditing = tab === 'dispatches' && editId ? getDispatchById(editId) : null;
// Per-tab data // Per-tab data
const pulses = tab === 'pulses' ? getAllPulses() : []; const pulses = tab === 'pulses' ? getAllPulses() : [];
const pulseEditing = tab === 'pulses' && editId ? getPulseById(editId) : null; const pulseEditing = tab === 'pulses' && editId ? getPulseById(editId) : null;
@ -248,21 +303,27 @@ const eventViewingRsvps = tab === 'events' && viewId && eventViewing
const activityRows = tab === 'activity' ? getAllActivityForAdmin(200) : []; const activityRows = tab === 'activity' ? getAllActivityForAdmin(200) : [];
const MSGS: Record<string, string> = { const MSGS: Record<string, string> = {
revoked: 'Invite revoked.', revoked: 'Invite revoked.',
updated: 'Role updated.', updated: 'Role updated.',
deactivated: 'User deactivated.', deactivated: 'User deactivated.',
pulse_created: 'Pulse saved.', user_updated: 'Member profile updated.',
pulse_updated: 'Pulse updated.', pulse_created: 'Pulse saved.',
pulse_published: 'Pulse published — members notified.', pulse_updated: 'Pulse updated.',
pulse_closed: 'Pulse closed.', pulse_published: 'Pulse published — members notified.',
pulse_deleted: 'Pulse deleted.', pulse_closed: 'Pulse closed.',
roadmap_created: 'Roadmap item saved.', pulse_deleted: 'Pulse deleted.',
roadmap_updated: 'Roadmap item updated.', roadmap_created: 'Roadmap item saved.',
roadmap_deleted: 'Roadmap item deleted.', roadmap_updated: 'Roadmap item updated.',
roadmap_moved: 'Roadmap reordered.', roadmap_deleted: 'Roadmap item deleted.',
event_created: 'Event saved.', roadmap_moved: 'Roadmap reordered.',
event_updated: 'Event updated.', event_created: 'Event saved.',
event_deleted: 'Event deleted.', event_updated: 'Event updated.',
event_deleted: 'Event deleted.',
dispatch_created: 'Dispatch saved.',
dispatch_updated: 'Dispatch updated.',
dispatch_published: 'Dispatch published.',
dispatch_archived: 'Dispatch archived.',
dispatch_deleted: 'Dispatch deleted.',
}; };
actionMsg = Astro.url.searchParams.get('msg'); actionMsg = Astro.url.searchParams.get('msg');
--- ---
@ -279,6 +340,7 @@ actionMsg = Astro.url.searchParams.get('msg');
<a href="/admin?tab=pulses" class:list={['tab label-sm', { active: tab === 'pulses' }]}>Pulses</a> <a href="/admin?tab=pulses" class:list={['tab label-sm', { active: tab === 'pulses' }]}>Pulses</a>
<a href="/admin?tab=roadmap" class:list={['tab label-sm', { active: tab === 'roadmap' }]}>Roadmap</a> <a href="/admin?tab=roadmap" class:list={['tab label-sm', { active: tab === 'roadmap' }]}>Roadmap</a>
<a href="/admin?tab=events" class:list={['tab label-sm', { active: tab === 'events' }]}>Events</a> <a href="/admin?tab=events" class:list={['tab label-sm', { active: tab === 'events' }]}>Events</a>
<a href="/admin?tab=dispatches" class:list={['tab label-sm', { active: tab === 'dispatches' }]}>Dispatches</a>
<a href="/admin?tab=invitations" class:list={['tab label-sm', { active: tab === 'invitations' }]}>Invitations</a> <a href="/admin?tab=invitations" class:list={['tab label-sm', { active: tab === 'invitations' }]}>Invitations</a>
<a href="/admin?tab=participants" class:list={['tab label-sm', { active: tab === 'participants' }]}>Participants</a> <a href="/admin?tab=participants" class:list={['tab label-sm', { active: tab === 'participants' }]}>Participants</a>
<a href="/admin?tab=join" class:list={['tab label-sm', { active: tab === 'join' }]}> <a href="/admin?tab=join" class:list={['tab label-sm', { active: tab === 'join' }]}>
@ -397,7 +459,11 @@ actionMsg = Astro.url.searchParams.get('msg');
)} )}
<!-- Participants tab --> <!-- Participants tab -->
{tab === 'participants' && ( {tab === 'participants' && editingUser && (
<UserEditTab member={editingUser} />
)}
{tab === 'participants' && !editingUser && (
<div class="tab-content"> <div class="tab-content">
<section class="section"> <section class="section">
<h2 class="label-sm section-heading">All participants</h2> <h2 class="label-sm section-heading">All participants</h2>
@ -436,7 +502,8 @@ actionMsg = Astro.url.searchParams.get('msg');
<td class="body-sm muted"> <td class="body-sm muted">
{u.last_seen_at ? fmtDate(u.last_seen_at) : 'Never'} {u.last_seen_at ? fmtDate(u.last_seen_at) : 'Never'}
</td> </td>
<td> <td class="action-cell">
<a href={`/admin?tab=participants&edit=${u.id}`} class="action-link label-sm">Edit</a>
{u.id !== user.id && ( {u.id !== user.id && (
<form method="POST" class="inline-form"> <form method="POST" class="inline-form">
<input type="hidden" name="action" value="deactivate_user" /> <input type="hidden" name="action" value="deactivate_user" />
@ -509,6 +576,10 @@ actionMsg = Astro.url.searchParams.get('msg');
<ActivityTab rows={activityRows} /> <ActivityTab rows={activityRows} />
)} )}
{tab === 'dispatches' && (
<DispatchesTab dispatches={dispatches} editing={dispatchEditing} fenjaUsers={fenjaUsers} currentUserId={user.id} />
)}
</div> </div>
</AppLayout> </AppLayout>
@ -791,4 +862,21 @@ actionMsg = Astro.url.searchParams.get('msg');
.danger-btn:hover { .danger-btn:hover {
background: rgba(185, 107, 88, 0.08); background: rgba(185, 107, 88, 0.08);
} }
.action-cell { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.action-link {
background: none;
border: none;
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
font-family: var(--font-sans);
font-size: var(--text-label-md);
transition: color var(--duration-fast) var(--ease-standard);
padding: 0;
}
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
</style> </style>