Huge bunch of UI/UX improvements and tweaks!
Some checks failed
Test / vitest (push) Successful in 10m36s
Test / playwright (push) Failing after 9m23s
Test / visual (push) Failing after 9m13s
Test / Notify on failure (push) Successful in 2s

This commit is contained in:
Jennie Robinson Faber 2026-04-06 16:17:12 +01:00
parent 501be10bfe
commit fb25e72215
37 changed files with 1651 additions and 949 deletions

View file

@ -65,103 +65,66 @@
<thead>
<tr>
<th class="col-check">
<UCheckbox
label="Select all"
:ui="{ label: 'sr-only' }"
:model-value="
allVisibleSelected
? true
: someVisibleSelected
? 'indeterminate'
: false
"
@update:model-value="toggleSelectAll"
/>
<label class="custom-check" aria-label="Select all">
<input
type="checkbox"
:checked="allVisibleSelected"
:indeterminate="!allVisibleSelected && someVisibleSelected"
@change="toggleSelectAll"
/>
<span class="check-mark" />
</label>
</th>
<th>Name</th>
<th>Email</th>
<th>City</th>
<th>Role</th>
<th>Status</th>
<th class="col-date">Registered</th>
<th class="sortable" @click="toggleSort('name')">Name <span v-if="sortKey === 'name'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable" @click="toggleSort('email')">Email <span v-if="sortKey === 'email'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable" @click="toggleSort('city')">City <span v-if="sortKey === 'city'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable" @click="toggleSort('role')">Role <span v-if="sortKey === 'role'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable" @click="toggleSort('status')">Status <span v-if="sortKey === 'status'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
<th class="sortable col-date" @click="toggleSort('createdAt')">Registered <span v-if="sortKey === 'createdAt'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span></th>
</tr>
</thead>
<tbody>
<template v-for="pr in filtered" :key="pr._id">
<tr
:class="{ 'row-expanded': expandedId === pr._id }"
@click="toggleExpand(pr._id)"
style="cursor: pointer"
>
<td class="col-check" @click.stop>
<UCheckbox
:label="`Select ${pr.name || pr.email}`"
:ui="{ label: 'sr-only' }"
:model-value="selectedIds.includes(pr._id)"
@update:model-value="toggleSelect(pr._id)"
<tr
v-for="pr in filtered"
:key="pr._id"
class="selectable-row"
:class="{ 'row-selected': selectedIds.includes(pr._id) }"
@click="toggleSelect(pr._id)"
>
<td class="col-check" @click.stop>
<label class="custom-check" :aria-label="`Select ${pr.name || pr.email}`">
<input
type="checkbox"
:checked="selectedIds.includes(pr._id)"
@change="toggleSelect(pr._id)"
/>
</td>
<td class="col-name">{{ pr.name || "—" }}</td>
<td class="col-email">{{ pr.email }}</td>
<td>{{ pr.city || "—" }}</td>
<td>{{ pr.role || "—" }}</td>
<td>
<span class="status-badge" :class="`status-${pr.status}`">
{{ pr.status }}
</span>
</td>
<td class="col-mono col-date">
{{ formatDate(pr.createdAt) }}
</td>
</tr>
<!-- Expanded detail row -->
<tr v-if="expandedId === pr._id" class="detail-row">
<td colspan="7">
<div class="detail-panel">
<div class="detail-fields">
<div class="field">
<label>Admin Notes</label>
<textarea
v-model="editNotes"
rows="3"
placeholder="Add notes about this pre-registrant..."
@click.stop
></textarea>
</div>
<div class="detail-actions">
<select
v-model="editStatus"
aria-label="Change status"
@click.stop
>
<option value="pending">Pending</option>
<option value="selected">Selected</option>
</select>
<button
class="btn"
@click.stop="saveDetail(pr._id)"
:disabled="savingDetail"
>
{{ savingDetail ? "Saving..." : "Save" }}
</button>
</div>
</div>
<div
v-if="pr.inviteEmailSentAt"
class="detail-meta"
>
Invite sent:
{{ new Date(pr.inviteEmailSentAt).toLocaleString() }}
</div>
<div v-if="pr.acceptedAt" class="detail-meta">
Accepted:
{{ new Date(pr.acceptedAt).toLocaleString() }}
</div>
</div>
</td>
</tr>
</template>
<span class="check-mark" />
</label>
</td>
<td class="col-name">{{ pr.name || "—" }}</td>
<td class="col-email">{{ pr.email }}</td>
<td>{{ pr.city || "—" }}</td>
<td>{{ pr.role || "—" }}</td>
<td @click.stop>
<select
class="inline-status"
:class="`status-${pr.status}`"
:value="pr.status"
:disabled="savingId === pr._id"
aria-label="Change status"
@change="updateStatus(pr._id, $event.target.value)"
>
<option value="pending">Pending</option>
<option value="selected">Selected</option>
<option value="invited">Invited</option>
<option value="accepted">Accepted</option>
<option value="expired">Expired</option>
</select>
</td>
<td class="col-mono col-date">
{{ formatDate(pr.createdAt) }}
</td>
</tr>
</tbody>
</table>
@ -267,10 +230,9 @@ const { data: stats, refresh: refreshStats } = await useFetch(
const searchQuery = ref("");
const statusFilter = ref("");
const selectedIds = ref([]);
const expandedId = ref(null);
const editNotes = ref("");
const editStatus = ref("pending");
const savingDetail = ref(false);
const savingId = ref(null);
const sortKey = ref("");
const sortDir = ref("asc");
// Invite
const showInviteModal = ref(false);
@ -291,10 +253,19 @@ See you inside.`;
const inviteTemplate = ref(DEFAULT_INVITE_TEMPLATE);
const toggleSort = (key) => {
if (sortKey.value === key) {
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
} else {
sortKey.value = key;
sortDir.value = "asc";
}
};
const filtered = computed(() => {
if (!preRegistrants.value) return [];
return preRegistrants.value.filter((pr) => {
const result = preRegistrants.value.filter((pr) => {
const q = searchQuery.value.toLowerCase();
const matchesSearch =
!q ||
@ -307,6 +278,18 @@ const filtered = computed(() => {
return matchesSearch && matchesStatus;
});
if (sortKey.value) {
const dir = sortDir.value === "asc" ? 1 : -1;
const key = sortKey.value;
result.sort((a, b) => {
const aVal = (a[key] || "").toString().toLowerCase();
const bVal = (b[key] || "").toString().toLowerCase();
return aVal < bVal ? -dir : aVal > bVal ? dir : 0;
});
}
return result;
});
// Selection helpers
@ -319,12 +302,12 @@ const someVisibleSelected = computed(() => {
return filtered.value.some((pr) => selectedIds.value.includes(pr._id));
});
// IDs of selected pre-registrants that can actually be invited (pending or selected status)
// IDs of selected pre-registrants that can actually be invited (pending, selected, or invited for resend)
const invitableIds = computed(() => {
if (!preRegistrants.value) return [];
return selectedIds.value.filter((id) => {
const pr = preRegistrants.value.find((p) => p._id === id);
return pr && (pr.status === "pending" || pr.status === "selected");
return pr && (pr.status === "pending" || pr.status === "selected" || pr.status === "invited");
});
});
@ -350,28 +333,16 @@ const toggleSelect = (id) => {
}
};
// Expand / collapse detail rows
const toggleExpand = (id) => {
if (expandedId.value === id) {
expandedId.value = null;
return;
}
expandedId.value = id;
const pr = preRegistrants.value?.find((p) => p._id === id);
editNotes.value = pr?.adminNotes || "";
editStatus.value = pr?.status === "selected" ? "selected" : "pending";
};
const saveDetail = async (id) => {
savingDetail.value = true;
const updateStatus = async (id, newStatus) => {
savingId.value = id;
try {
await $fetch(`/api/admin/pre-registrants/${id}`, {
method: "PUT",
body: { status: editStatus.value, adminNotes: editNotes.value },
body: { status: newStatus },
});
await refresh();
await refreshStats();
toast.add({ title: "Updated", color: "green" });
toast.add({ title: "Status updated", color: "green" });
} catch (err) {
toast.add({
title: "Failed to update",
@ -379,7 +350,7 @@ const saveDetail = async (id) => {
color: "red",
});
} finally {
savingDetail.value = false;
savingId.value = null;
}
};
@ -459,10 +430,7 @@ const formatDate = (dateString) => {
</script>
<style scoped>
.admin-prereg {
max-width: 1100px;
margin: 0 auto;
}
.admin-prereg {}
/* ---- PAGE HEADER ---- */
.page-header {
@ -525,6 +493,21 @@ thead th {
letter-spacing: 0.05em;
color: var(--text-faint);
border-bottom: 1px dashed var(--border);
font-weight: normal;
}
thead th.sortable {
cursor: pointer;
user-select: none;
}
thead th.sortable:hover {
color: var(--text-dim);
}
.sort-arrow {
font-size: 10px;
color: var(--candle);
}
tbody tr {
@ -543,11 +526,78 @@ tbody td {
}
.col-check {
width: 32px;
padding-left: 0;
width: 40px;
padding-left: 12px;
padding-right: 4px;
}
.selectable-row {
cursor: pointer;
}
.row-selected {
background: var(--surface);
}
/* ---- CUSTOM CHECKBOX ---- */
.custom-check {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
width: 16px;
height: 16px;
}
.custom-check input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.check-mark {
width: 14px;
height: 14px;
border: 1px solid var(--border);
background: var(--input-bg);
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s, background 0.15s;
}
.custom-check:hover .check-mark {
border-color: var(--candle);
}
.custom-check input:checked + .check-mark {
background: var(--candle);
border-color: var(--candle);
}
.custom-check input:checked + .check-mark::after {
content: "";
width: 4px;
height: 8px;
border: solid var(--bg);
border-width: 0 1.5px 1.5px 0;
transform: rotate(45deg) translateY(-1px);
}
.custom-check input:indeterminate + .check-mark {
background: var(--candle);
border-color: var(--candle);
}
.custom-check input:indeterminate + .check-mark::after {
content: "";
width: 8px;
height: 0;
border-bottom: 1.5px solid var(--bg);
}
.col-name {
font-weight: 500;
color: var(--text-bright);
@ -600,42 +650,21 @@ tbody td {
border-color: var(--ember);
}
/* ---- EXPANDED DETAIL ROW ---- */
.row-expanded {
background: var(--surface);
/* ---- INLINE STATUS SELECT ---- */
.inline-status {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 6px;
border: 1px dashed var(--border);
background: transparent;
cursor: pointer;
font-family: "Commit Mono", monospace;
}
.detail-row td {
padding: 0 10px 16px;
border-bottom: 1px dashed var(--border);
}
.detail-panel {
padding: 12px 0;
}
.detail-fields {
display: flex;
gap: 12px;
align-items: flex-end;
}
.detail-fields .field {
flex: 1;
margin-bottom: 0;
}
.detail-actions {
display: flex;
gap: 8px;
align-items: flex-end;
flex-shrink: 0;
}
.detail-meta {
font-size: 11px;
color: var(--text-faint);
margin-top: 8px;
.inline-status:disabled {
opacity: 0.5;
cursor: wait;
}
/* ---- STATUS INDICATORS ---- */
@ -822,9 +851,5 @@ tbody td {
table {
min-width: 600px;
}
.detail-fields {
flex-direction: column;
}
}
</style>