Huge bunch of UI/UX improvements and tweaks!
This commit is contained in:
parent
501be10bfe
commit
fb25e72215
37 changed files with 1651 additions and 949 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue