Updates
Some checks failed
Test / vitest (push) Failing after 6m9s
Test / visual (push) Has been skipped
Test / playwright (push) Has been skipped
Test / Notify on failure (push) Successful in 2s

This commit is contained in:
Jennie Robinson Faber 2026-04-15 17:45:09 +01:00
parent 28040f44f4
commit 2394248d53
13 changed files with 571 additions and 538 deletions

View file

@ -28,7 +28,7 @@
<!-- Search / Filter -->
<div class="filter-bar">
<div class="field" style="margin-bottom: 0; flex: 1">
<input v-model="searchQuery" placeholder="Search members..." />
<input v-model="searchQuery" placeholder="Search members..." >
</div>
<div class="field" style="margin-bottom: 0">
<select v-model="circleFilter" aria-label="Filter by circle">
@ -38,6 +38,15 @@
<option value="practitioner">Practitioner</option>
</select>
</div>
<div class="field" style="margin-bottom: 0">
<select v-model="statusFilter" aria-label="Filter by status">
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="pending_payment">Pending Payment</option>
<option value="suspended">Suspended</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
<!-- Members Table -->
@ -61,17 +70,18 @@
:checked="allVisibleSelected"
:indeterminate="!allVisibleSelected && someVisibleSelected"
@change="toggleSelectAll"
/>
>
<span class="check-mark" />
</label>
</th>
<th>Name</th>
<th>Email</th>
<th>Circle</th>
<th>Tier</th>
<th class="sortable" @click="toggleSort('name')">Name <span class="sort-ind">{{ sortIndicator('name') }}</span></th>
<th class="sortable" @click="toggleSort('email')">Email <span class="sort-ind">{{ sortIndicator('email') }}</span></th>
<th class="sortable" @click="toggleSort('circle')">Circle <span class="sort-ind">{{ sortIndicator('circle') }}</span></th>
<th class="sortable" @click="toggleSort('contributionTier')">Tier <span class="sort-ind">{{ sortIndicator('contributionTier') }}</span></th>
<th class="sortable" @click="toggleSort('status')">Status <span class="sort-ind">{{ sortIndicator('status') }}</span></th>
<th>Invite</th>
<th>Slack</th>
<th>Joined</th>
<th class="sortable" @click="toggleSort('createdAt')">Joined <span class="sort-ind">{{ sortIndicator('createdAt') }}</span></th>
<th>Actions</th>
</tr>
</thead>
@ -89,7 +99,7 @@
type="checkbox"
:checked="selectedMemberIds.includes(member._id)"
@change="toggleSelect(member._id)"
/>
>
<span class="check-mark" />
</label>
</td>
@ -103,6 +113,9 @@
}}</span>
</td>
<td class="col-mono">${{ member.contributionTier }}/mo</td>
<td>
<span class="badge status" :class="`status-${member.status || 'pending_payment'}`">{{ statusLabel(member.status) }}</span>
</td>
<td>
<span
:class="member.inviteEmailSent ? 'status-ok' : 'status-dim'"
@ -122,10 +135,10 @@
<NuxtLink :to="`/admin/members/${member._id}`" class="link-btn" @click.stop
>View</NuxtLink
>
<button @click.stop="sendSlackInvite(member)" class="link-btn">
<button class="link-btn" @click.stop="sendSlackInvite(member)">
Slack
</button>
<button @click.stop="editMember(member)" class="link-btn">Edit</button>
<button class="link-btn" @click.stop="editMember(member)">Edit</button>
</td>
</tr>
</tbody>
@ -150,10 +163,10 @@
</button>
</div>
<form @submit.prevent="createMember" class="modal-body">
<form class="modal-body" @submit.prevent="createMember">
<div class="field">
<label>Name</label>
<input v-model="newMember.name" placeholder="Full name" required />
<input v-model="newMember.name" placeholder="Full name" required >
</div>
<div class="field">
<label>Email</label>
@ -162,7 +175,7 @@
type="email"
placeholder="email@example.com"
required
/>
>
</div>
<div class="field">
<label>Circle</label>
@ -220,9 +233,9 @@
ref="csvFileInput"
type="file"
accept=".csv"
@change="handleCsvFile"
class="file-input"
/>
@change="handleCsvFile"
>
</div>
<div v-if="csvParseError" class="error-box">
@ -243,7 +256,7 @@
>
{{ csvRows.length - csvValidRows.length }} with errors.
</span>
<button @click="resetCsvImport" class="link-btn">
<button class="link-btn" @click="resetCsvImport">
Choose different file
</button>
</div>
@ -303,14 +316,14 @@
</div>
<div class="modal-actions">
<button @click="closeImportModal" class="btn">
<button class="btn" @click="closeImportModal">
{{ importResults ? "Done" : "Cancel" }}
</button>
<button
v-if="csvValidRows.length && !importResults"
:disabled="importing"
@click="submitImport"
class="btn btn-primary"
@click="submitImport"
>
{{
importing
@ -336,14 +349,14 @@
</button>
</div>
<form @submit.prevent="submitEditMember" class="modal-body">
<form class="modal-body" @submit.prevent="submitEditMember">
<div class="field">
<label>Name</label>
<input v-model="editingMember.name" required />
<input v-model="editingMember.name" required >
</div>
<div class="field">
<label>Email</label>
<input v-model="editingMember.email" type="email" required />
<input v-model="editingMember.email" type="email" required >
</div>
<div class="field">
<label>Circle</label>
@ -407,7 +420,7 @@
<div class="field">
<label>Email Template</label>
<textarea v-model="inviteTemplate" rows="12"></textarea>
<textarea v-model="inviteTemplate" rows="12"/>
<p class="help-text" style="margin-top: 4px">
Tokens: <code>{name}</code>, <code>{loginLink}</code>,
<code>{circle}</code>
@ -439,14 +452,14 @@
</div>
<div class="modal-actions">
<button @click="showInviteModal = false" class="btn">
<button class="btn" @click="showInviteModal = false">
{{ inviteResults ? "Done" : "Cancel" }}
</button>
<button
v-if="!inviteResults"
:disabled="sendingInvites"
@click="submitInvites"
class="btn btn-primary"
@click="submitInvites"
>
{{
sendingInvites
@ -477,6 +490,30 @@ const {
const searchQuery = ref("");
const circleFilter = ref("");
const statusFilter = ref("");
const sortKey = ref("createdAt");
const sortDir = ref("desc");
const STATUS_LABELS = {
active: "Active",
pending_payment: "Pending",
suspended: "Suspended",
cancelled: "Cancelled",
};
const statusLabel = (s) => STATUS_LABELS[s] || "Pending";
const toggleSort = (key) => {
if (sortKey.value === key) {
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
} else {
sortKey.value = key;
sortDir.value = key === "createdAt" ? "desc" : "asc";
}
};
const sortIndicator = (key) => {
if (sortKey.value !== key) return "";
return sortDir.value === "asc" ? "▲" : "▼";
};
const showCreateModal = ref(false);
const creating = ref(false);
@ -519,7 +556,7 @@ const newMember = reactive({
const filteredMembers = computed(() => {
if (!members.value) return [];
return members.value.filter((member) => {
const filtered = members.value.filter((member) => {
const matchesSearch =
!searchQuery.value ||
member.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
@ -528,7 +565,33 @@ const filteredMembers = computed(() => {
const matchesCircle =
!circleFilter.value || member.circle === circleFilter.value;
return matchesSearch && matchesCircle;
const matchesStatus =
!statusFilter.value || (member.status || "pending_payment") === statusFilter.value;
return matchesSearch && matchesCircle && matchesStatus;
});
const key = sortKey.value;
const dir = sortDir.value === "asc" ? 1 : -1;
return [...filtered].sort((a, b) => {
let av = a[key];
let bv = b[key];
if (key === "contributionTier") {
av = Number(av) || 0;
bv = Number(bv) || 0;
} else if (key === "createdAt") {
av = av ? new Date(av).getTime() : 0;
bv = bv ? new Date(bv).getTime() : 0;
} else if (key === "status") {
av = a.status || "pending_payment";
bv = b.status || "pending_payment";
} else {
av = (av || "").toString().toLowerCase();
bv = (bv || "").toString().toLowerCase();
}
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return 0;
});
});
@ -1055,6 +1118,44 @@ tbody td {
font-size: 11px;
}
/* ---- SORTABLE HEADERS ---- */
th.sortable {
cursor: pointer;
user-select: none;
}
th.sortable:hover {
color: var(--candle);
}
.sort-ind {
display: inline-block;
width: 10px;
font-size: 9px;
color: var(--candle);
margin-left: 2px;
}
/* ---- MEMBER STATUS BADGES ---- */
.badge.status {
text-transform: uppercase;
}
.badge.status-active {
color: var(--green, #3a6b3a);
border-color: rgba(58, 107, 58, 0.45);
}
.badge.status-pending_payment {
color: var(--text-dim);
border-color: var(--border);
}
.badge.status-suspended {
color: var(--ember);
border-color: rgba(138, 68, 32, 0.45);
}
.badge.status-cancelled {
color: var(--text-faint);
border-color: var(--border);
opacity: 0.7;
}
/* ---- MODALS ---- */
.modal-overlay {
position: fixed;