feat(polls): polls attach to dispatches — standalone Pulses entity retired
Schema (migration 0005): dispatches gains a nullable pulse_id FK to pulses(id) ON DELETE SET NULL. Partial index on the populated rows. The pulses + votes tables themselves are unchanged — vote uniqueness, status derivation, and the existing tests still hold; only the entity relationship changes. db.ts: - Dispatch type gains pulse_id. New DispatchWithPoll = DispatchWithAuthor + a hydrated poll (pulse + counts + viewer's vote). - createDispatch accepts an optional poll input — if provided, creates the pulse first in the same transaction and stamps dispatches.pulse_id. - updateDispatch grows two new arguments: poll (input or null) and a pollExplicit flag. The flag distinguishes "leave the existing poll alone" (undefined) from "the admin actively chose to detach / replace it" (true). The detach path nulls pulse_id; the replace path mutates the existing pulse in place via updatePulse so vote history survives. - publishDispatch / archiveDispatch are now wrappers that also publishPulse / closePulse on the attached pulse. Dispatch state drives poll state. - getDispatchWithPoll(dispatchId, viewerId) — single call for the page renderers. Admin: - The Pulses tab is removed from the admin tab nav. The route + POST handlers stay in place so existing draft pulses aren't orphaned, but the entity is no longer a place admins go to think. - DispatchesTab form gains a poll fieldset: question + 4 option inputs (first two required if any are filled) + opens_at + closes_at. A hidden poll_explicit flag tells the server the form intentionally asserted the poll state (so leaving the fields blank during edit detaches rather than no-ops). On edit, fields prefill from the attached pulse if present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cafbcf8b74
commit
867661ee3d
4 changed files with 237 additions and 28 deletions
10
migrations/0005_polls_on_dispatches.sql
Normal file
10
migrations/0005_polls_on_dispatches.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Polls are no longer a standalone entity in the UX: every poll is attached
|
||||||
|
-- to a dispatch. We keep the pulses + votes tables (vote uniqueness, status
|
||||||
|
-- derivation, admin history) and add a nullable FK from dispatches.
|
||||||
|
--
|
||||||
|
-- ON DELETE SET NULL — if an attached pulse is hard-deleted, the dispatch
|
||||||
|
-- survives without a poll rather than vanishing with it.
|
||||||
|
|
||||||
|
ALTER TABLE dispatches ADD COLUMN pulse_id INTEGER REFERENCES pulses(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_dispatches_pulse ON dispatches(pulse_id) WHERE pulse_id IS NOT NULL;
|
||||||
|
|
@ -1,16 +1,25 @@
|
||||||
---
|
---
|
||||||
import type { DispatchWithAuthor, UserPublic } from '../../lib/db';
|
import type { DispatchWithAuthor, UserPublic, PulseRow } from '../../lib/db';
|
||||||
import { fmtDateTime } from '../../lib/markdown';
|
import { fmtDateTime } from '../../lib/markdown';
|
||||||
import { dispatchKindLabel } from '../../lib/format';
|
import { dispatchKindLabel } from '../../lib/format';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dispatches: DispatchWithAuthor[];
|
dispatches: DispatchWithAuthor[];
|
||||||
editing: DispatchWithAuthor | null;
|
editing: DispatchWithAuthor | null;
|
||||||
|
editingPoll: PulseRow | null;
|
||||||
fenjaUsers: UserPublic[];
|
fenjaUsers: UserPublic[];
|
||||||
currentUserId: number;
|
currentUserId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { dispatches, editing, fenjaUsers, currentUserId } = Astro.props;
|
const { dispatches, editing, editingPoll, fenjaUsers, currentUserId } = Astro.props;
|
||||||
|
|
||||||
|
function toInputValue(sql: string | null | undefined): string {
|
||||||
|
if (!sql) return '';
|
||||||
|
return sql.replace(' ', 'T').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollOptionsForForm: string[] = editingPoll ? [...editingPoll.options] : [];
|
||||||
|
while (pollOptionsForForm.length < 4) pollOptionsForForm.push('');
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
draft: 'Draft',
|
draft: 'Draft',
|
||||||
|
|
@ -78,6 +87,65 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<!-- ── Attached poll (optional) ────────────────────────────── -->
|
||||||
|
<fieldset class="poll-fieldset">
|
||||||
|
<legend class="label-sm field-label">Attach a poll (optional)</legend>
|
||||||
|
<input type="hidden" name="poll_explicit" value="1" />
|
||||||
|
|
||||||
|
<p class="body-sm muted poll-help">
|
||||||
|
Fill in a question and at least two options to attach a poll. Leave them all blank
|
||||||
|
to {editingPoll ? 'detach the existing poll' : 'skip'}.
|
||||||
|
{editingPoll && <span class="poll-existing-flag"> · Currently attached: pulse #{editingPoll.id}, status {editingPoll.status}.</span>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="d-poll-question" class="label-sm field-label">Poll question</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="d-poll-question"
|
||||||
|
name="poll_question"
|
||||||
|
class="input body-md"
|
||||||
|
value={editingPoll?.question ?? ''}
|
||||||
|
placeholder={editing ? editing.title : 'A question for the council'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="poll-options-grid">
|
||||||
|
{pollOptionsForForm.map((val, i) => (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={`poll_option_${i}`}
|
||||||
|
placeholder={`Option ${String.fromCharCode(65 + i)}${i < 2 ? '' : ' (optional)'}`}
|
||||||
|
class="input body-md"
|
||||||
|
value={val}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="d-poll-opens" class="label-sm field-label">Poll opens at (UTC)</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
id="d-poll-opens"
|
||||||
|
name="poll_opens_at"
|
||||||
|
class="input body-md"
|
||||||
|
value={toInputValue(editingPoll?.opens_at)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="d-poll-closes" class="label-sm field-label">Poll closes at (UTC)</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
id="d-poll-closes"
|
||||||
|
name="poll_closes_at"
|
||||||
|
class="input body-md"
|
||||||
|
value={toInputValue(editingPoll?.closes_at)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Save dispatch'}</button>
|
<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>}
|
{editing && <a href="/admin?tab=dispatches" class="action-link label-sm">Cancel</a>}
|
||||||
|
|
@ -144,6 +212,30 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
|
||||||
.mono { font-family: var(--font-mono); font-size: var(--text-body-sm); }
|
.mono { font-family: var(--font-mono); font-size: var(--text-body-sm); }
|
||||||
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
|
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
|
||||||
|
|
||||||
|
.poll-fieldset {
|
||||||
|
border: 0.5px solid var(--surface-card-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.poll-fieldset legend {
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
.poll-help { color: var(--on-surface-muted); margin: 0; }
|
||||||
|
.poll-existing-flag { color: var(--pigment-terracotta); }
|
||||||
|
.poll-options-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.muted { color: var(--on-surface-muted); }
|
||||||
|
|
||||||
.status-pill {
|
.status-pill {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.15em var(--space-3);
|
padding: 0.15em var(--space-3);
|
||||||
|
|
|
||||||
126
src/lib/db.ts
126
src/lib/db.ts
|
|
@ -1004,6 +1004,7 @@ export interface Dispatch {
|
||||||
published_at: string | null;
|
published_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
pulse_id: number | null; // attached poll, if any
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DispatchWithAuthor extends Dispatch {
|
export interface DispatchWithAuthor extends Dispatch {
|
||||||
|
|
@ -1012,6 +1013,18 @@ export interface DispatchWithAuthor extends Dispatch {
|
||||||
author_role: Role;
|
author_role: Role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DispatchWithPoll extends DispatchWithAuthor {
|
||||||
|
poll: PulseWithCounts | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional poll attachment used when creating/updating a dispatch. */
|
||||||
|
export interface DispatchPollInput {
|
||||||
|
question: string;
|
||||||
|
options: string[];
|
||||||
|
opens_at: string;
|
||||||
|
closes_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function createDispatch(data: {
|
export function createDispatch(data: {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
|
@ -1019,46 +1032,119 @@ export function createDispatch(data: {
|
||||||
kind: DispatchKind;
|
kind: DispatchKind;
|
||||||
author_id: number;
|
author_id: number;
|
||||||
status: DispatchStatus;
|
status: DispatchStatus;
|
||||||
|
poll?: DispatchPollInput | null;
|
||||||
}): number {
|
}): number {
|
||||||
const published_at = data.status === 'published'
|
const published_at = data.status === 'published'
|
||||||
? new Date().toISOString().slice(0, 19).replace('T', ' ')
|
? new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||||
: null;
|
: null;
|
||||||
const r = db.prepare(`
|
return db.transaction(() => {
|
||||||
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at)
|
let pulseId: number | null = null;
|
||||||
VALUES (?,?,?,?,?,?,?)
|
if (data.poll && data.poll.options.length >= 2) {
|
||||||
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at);
|
pulseId = createPulse({
|
||||||
return Number(r.lastInsertRowid);
|
question: data.poll.question,
|
||||||
|
context: null,
|
||||||
|
options: data.poll.options,
|
||||||
|
opens_at: data.poll.opens_at,
|
||||||
|
closes_at: data.poll.closes_at,
|
||||||
|
status: data.status === 'published' ? 'open' : 'draft',
|
||||||
|
created_by: data.author_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, pulse_id)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?)
|
||||||
|
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at, pulseId);
|
||||||
|
return Number(r.lastInsertRowid);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update a dispatch and, optionally, manage its attached poll. */
|
||||||
export function updateDispatch(id: number, data: {
|
export function updateDispatch(id: number, data: {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
excerpt: string | null;
|
excerpt: string | null;
|
||||||
kind: DispatchKind;
|
kind: DispatchKind;
|
||||||
author_id: number;
|
author_id: number;
|
||||||
|
poll?: DispatchPollInput | null; // present + has options ⇒ attach/update; explicit null ⇒ detach
|
||||||
|
pollExplicit?: boolean; // distinguishes "leave poll alone" (undefined) from "detach" (null + flag)
|
||||||
}): void {
|
}): void {
|
||||||
db.prepare(`
|
db.transaction(() => {
|
||||||
UPDATE dispatches
|
const cur = db.prepare('SELECT pulse_id, status FROM dispatches WHERE id = ?')
|
||||||
SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?, updated_at = datetime('now')
|
.get(id) as { pulse_id: number | null; status: DispatchStatus } | undefined;
|
||||||
WHERE id = ?
|
if (!cur) return;
|
||||||
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, id);
|
|
||||||
|
let pulseId: number | null = cur.pulse_id;
|
||||||
|
|
||||||
|
if (data.pollExplicit) {
|
||||||
|
if (data.poll && data.poll.options.length >= 2) {
|
||||||
|
if (cur.pulse_id) {
|
||||||
|
// update the existing pulse in place
|
||||||
|
updatePulse(cur.pulse_id, {
|
||||||
|
question: data.poll.question,
|
||||||
|
context: null,
|
||||||
|
options: data.poll.options,
|
||||||
|
opens_at: data.poll.opens_at,
|
||||||
|
closes_at: data.poll.closes_at,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pulseId = createPulse({
|
||||||
|
question: data.poll.question,
|
||||||
|
context: null,
|
||||||
|
options: data.poll.options,
|
||||||
|
opens_at: data.poll.opens_at,
|
||||||
|
closes_at: data.poll.closes_at,
|
||||||
|
status: cur.status === 'published' ? 'open' : 'draft',
|
||||||
|
created_by: data.author_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// explicit detach
|
||||||
|
pulseId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE dispatches
|
||||||
|
SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?,
|
||||||
|
pulse_id = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, pulseId, id);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch + its attached poll (with counts + this viewer's vote). */
|
||||||
|
export function getDispatchWithPoll(dispatchId: number, viewerId: number): DispatchWithPoll | null {
|
||||||
|
const d = getDispatchById(dispatchId);
|
||||||
|
if (!d) return null;
|
||||||
|
const poll = d.pulse_id ? getPulseWithCounts(d.pulse_id, viewerId) : null;
|
||||||
|
return { ...d, poll };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Promote draft → published, stamping published_at = now() on first publish.
|
/** Promote draft → published, stamping published_at = now() on first publish.
|
||||||
* Idempotent: if already published, published_at is preserved. */
|
* Idempotent: if already published, published_at is preserved. Also opens
|
||||||
|
* any attached draft poll so members can start voting. */
|
||||||
export function publishDispatch(id: number): void {
|
export function publishDispatch(id: number): void {
|
||||||
db.prepare(`
|
db.transaction(() => {
|
||||||
UPDATE dispatches
|
db.prepare(`
|
||||||
SET status = 'published',
|
UPDATE dispatches
|
||||||
published_at = COALESCE(published_at, datetime('now')),
|
SET status = 'published',
|
||||||
updated_at = datetime('now')
|
published_at = COALESCE(published_at, datetime('now')),
|
||||||
WHERE id = ?
|
updated_at = datetime('now')
|
||||||
`).run(id);
|
WHERE id = ?
|
||||||
|
`).run(id);
|
||||||
|
const row = db.prepare('SELECT pulse_id FROM dispatches WHERE id = ?').get(id) as { pulse_id: number | null } | undefined;
|
||||||
|
if (row?.pulse_id) publishPulse(row.pulse_id);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Archive a dispatch. Leaves published_at intact for history. */
|
/** Archive a dispatch. Leaves published_at intact for history. Closes any
|
||||||
|
* attached open poll so the bar charts read final. */
|
||||||
export function archiveDispatch(id: number): void {
|
export function archiveDispatch(id: number): void {
|
||||||
db.prepare("UPDATE dispatches SET status = 'archived', updated_at = datetime('now') WHERE id = ?").run(id);
|
db.transaction(() => {
|
||||||
|
db.prepare("UPDATE dispatches SET status = 'archived', updated_at = datetime('now') WHERE id = ?").run(id);
|
||||||
|
const row = db.prepare('SELECT pulse_id FROM dispatches WHERE id = ?').get(id) as { pulse_id: number | null } | undefined;
|
||||||
|
if (row?.pulse_id) closePulse(row.pulse_id);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteDispatch(id: number): void {
|
export function deleteDispatch(id: number): void {
|
||||||
|
|
|
||||||
|
|
@ -101,14 +101,35 @@ if (Astro.request.method === 'POST') {
|
||||||
const authorId = Number(data.get('author_id'));
|
const authorId = Number(data.get('author_id'));
|
||||||
const status = String(data.get('status') ?? 'draft') as DispatchStatus;
|
const status = String(data.get('status') ?? 'draft') as DispatchStatus;
|
||||||
|
|
||||||
|
// Parse optional poll attachment fields.
|
||||||
|
const pollExplicit = String(data.get('poll_explicit') ?? '') === '1';
|
||||||
|
const pollQuestion = String(data.get('poll_question') ?? '').trim();
|
||||||
|
const pollOpts = [0, 1, 2, 3]
|
||||||
|
.map(i => String(data.get(`poll_option_${i}`) ?? '').trim())
|
||||||
|
.filter(s => s.length > 0);
|
||||||
|
const pollOpens = String(data.get('poll_opens_at') ?? '');
|
||||||
|
const pollCloses = String(data.get('poll_closes_at') ?? '');
|
||||||
|
let pollInput: { question: string; options: string[]; opens_at: string; closes_at: string } | null = null;
|
||||||
|
if (pollQuestion && pollOpts.length >= 2 && pollOpens && pollCloses) {
|
||||||
|
pollInput = {
|
||||||
|
question: pollQuestion,
|
||||||
|
options: pollOpts,
|
||||||
|
opens_at: toSqlDate(pollOpens),
|
||||||
|
closes_at: toSqlDate(pollCloses),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!title || !body || !['decision','update','behind_the_scenes','note'].includes(kind)) {
|
if (!title || !body || !['decision','update','behind_the_scenes','note'].includes(kind)) {
|
||||||
formError = 'Title, body, and a valid kind are required.';
|
formError = 'Title, body, and a valid kind are required.';
|
||||||
} else if (action === 'create_dispatch') {
|
} else if (action === 'create_dispatch') {
|
||||||
createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status });
|
createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status, poll: pollInput });
|
||||||
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_created');
|
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_created');
|
||||||
} else {
|
} else {
|
||||||
const id = Number(data.get('dispatch_id'));
|
const id = Number(data.get('dispatch_id'));
|
||||||
if (id) updateDispatch(id, { title, body, excerpt, kind, author_id: authorId || user.id });
|
if (id) updateDispatch(id, {
|
||||||
|
title, body, excerpt, kind, author_id: authorId || user.id,
|
||||||
|
poll: pollInput, pollExplicit,
|
||||||
|
});
|
||||||
return Astro.redirect(`/admin?tab=dispatches&edit=${id}&msg=dispatch_updated`);
|
return Astro.redirect(`/admin?tab=dispatches&edit=${id}&msg=dispatch_updated`);
|
||||||
}
|
}
|
||||||
} else if (action === 'publish_dispatch') {
|
} else if (action === 'publish_dispatch') {
|
||||||
|
|
@ -283,6 +304,7 @@ const editingUser = tab === 'participants' && editId ? getUserPublicById(editId)
|
||||||
|
|
||||||
const dispatches = tab === 'dispatches' ? getAllDispatchesForAdmin() : [];
|
const dispatches = tab === 'dispatches' ? getAllDispatchesForAdmin() : [];
|
||||||
const dispatchEditing = tab === 'dispatches' && editId ? getDispatchById(editId) : null;
|
const dispatchEditing = tab === 'dispatches' && editId ? getDispatchById(editId) : null;
|
||||||
|
const dispatchEditingPoll = dispatchEditing?.pulse_id ? getPulseById(dispatchEditing.pulse_id) : null;
|
||||||
|
|
||||||
// Per-tab data
|
// Per-tab data
|
||||||
const pulses = tab === 'pulses' ? getAllPulses() : [];
|
const pulses = tab === 'pulses' ? getAllPulses() : [];
|
||||||
|
|
@ -335,12 +357,11 @@ actionMsg = Astro.url.searchParams.get('msg');
|
||||||
<h1 class="display-md page-title">Control panel.</h1>
|
<h1 class="display-md page-title">Control panel.</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs (Pulses entity merged into Dispatches — polls now attach to articles) -->
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<a href="/admin?tab=pulses" class:list={['tab label-sm', { active: tab === 'pulses' }]}>Pulses</a>
|
<a href="/admin?tab=dispatches" class:list={['tab label-sm', { active: tab === 'dispatches' }]}>Dispatches</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' }]}>
|
||||||
|
|
@ -577,7 +598,7 @@ actionMsg = Astro.url.searchParams.get('msg');
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'dispatches' && (
|
{tab === 'dispatches' && (
|
||||||
<DispatchesTab dispatches={dispatches} editing={dispatchEditing} fenjaUsers={fenjaUsers} currentUserId={user.id} />
|
<DispatchesTab dispatches={dispatches} editing={dispatchEditing} editingPoll={dispatchEditingPoll} fenjaUsers={fenjaUsers} currentUserId={user.id} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue