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
|
|
@ -447,7 +447,7 @@
|
|||
<button
|
||||
type="button"
|
||||
@click="removeAgendaItem(index)"
|
||||
class="agenda-remove"
|
||||
class="link-btn link-btn-danger"
|
||||
>
|
||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||
</button>
|
||||
|
|
@ -949,7 +949,6 @@ const saveAndCreateAnother = async () => {
|
|||
<style scoped>
|
||||
.create-form {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
|
|
@ -972,15 +971,17 @@ const saveAndCreateAnother = async () => {
|
|||
}
|
||||
|
||||
.back-link {
|
||||
font-size: 12px;
|
||||
color: var(--candle);
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
text-decoration: none;
|
||||
margin-bottom: 8px;
|
||||
display: inline-block;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--candle);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.form-body {
|
||||
|
|
@ -1059,6 +1060,8 @@ const saveAndCreateAnother = async () => {
|
|||
}
|
||||
|
||||
.check-label input[type="checkbox"] {
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
accent-color: var(--candle);
|
||||
}
|
||||
|
|
@ -1137,16 +1140,22 @@ const saveAndCreateAnother = async () => {
|
|||
flex: 1;
|
||||
}
|
||||
|
||||
.agenda-remove {
|
||||
padding: 6px;
|
||||
color: var(--ember);
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: 1px dashed transparent;
|
||||
border: none;
|
||||
color: var(--candle);
|
||||
cursor: pointer;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.agenda-remove:hover {
|
||||
border-color: var(--ember);
|
||||
.link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.link-btn-danger {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.add-agenda-btn {
|
||||
|
|
|
|||
|
|
@ -27,14 +27,6 @@
|
|||
<option value="showcase">Showcase</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 0;">
|
||||
<select v-model="statusFilter">
|
||||
<option value="all">All Status</option>
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="ongoing">Ongoing</option>
|
||||
<option value="past">Past</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 0;">
|
||||
<select v-model="seriesFilter">
|
||||
<option value="all">All Events</option>
|
||||
|
|
@ -44,124 +36,214 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events Table -->
|
||||
<div class="table-wrap">
|
||||
<div v-if="pending" class="loading-state">
|
||||
<div class="spinner" />
|
||||
<span>Loading events...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
Error loading events: {{ error }}
|
||||
</div>
|
||||
|
||||
<table v-else-if="filteredEvents.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-title">Title</th>
|
||||
<th>Type</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th>Registration</th>
|
||||
<th>Tickets</th>
|
||||
<th class="col-actions-head">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="event in filteredEvents" :key="event._id">
|
||||
<!-- Title -->
|
||||
<td class="col-title">
|
||||
<div class="event-title-cell">
|
||||
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
|
||||
<img
|
||||
:src="event.featureImage.url"
|
||||
:alt="event.title"
|
||||
@error="handleImageError($event)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span class="event-name">{{ event.title }}</span>
|
||||
<span class="event-desc">{{ event.description.substring(0, 80) }}...</span>
|
||||
<div v-if="event.series?.isSeriesEvent" class="series-tag">
|
||||
<span class="series-pos">{{ event.series.position }}</span>
|
||||
{{ event.series.title }}
|
||||
</div>
|
||||
<div class="event-flags">
|
||||
<span v-if="event.membersOnly" class="flag">Members Only</span>
|
||||
<span v-if="event.targetCircles?.length" class="flag">{{ event.targetCircles.join(', ') }}</span>
|
||||
<span v-if="!event.isVisible" class="flag flag-dim">Hidden</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Type -->
|
||||
<td>
|
||||
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Date -->
|
||||
<td class="col-date">
|
||||
<span class="date-main">{{ formatDate(event.startDate) }}</span>
|
||||
<span class="date-time">{{ formatTime(event.startDate) }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td>
|
||||
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
||||
{{ getEventStatus(event) }}
|
||||
</span>
|
||||
<span v-if="event.isCancelled" class="status-pill status-cancelled">Cancelled</span>
|
||||
</td>
|
||||
|
||||
<!-- Registration -->
|
||||
<td>
|
||||
<span v-if="event.registrationRequired" class="status-ok" style="font-size: 11px;">Required</span>
|
||||
<span v-else class="status-dim" style="font-size: 11px;">Optional</span>
|
||||
<span v-if="event.maxAttendees" class="reg-count">
|
||||
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Tickets -->
|
||||
<td class="col-tickets">
|
||||
<template v-if="event.tickets?.enabled">
|
||||
<span class="ticket-on">Ticketing On</span>
|
||||
<span v-if="event.tickets?.requiresSeriesTicket" class="ticket-detail">Series Pass Required</span>
|
||||
<template v-else>
|
||||
<span v-if="event.tickets.member?.available" class="ticket-detail">
|
||||
Member: {{ event.tickets.member.isFree ? 'Free' : `$${event.tickets.member.price}` }}
|
||||
</span>
|
||||
<span v-if="event.tickets.public?.available" class="ticket-detail">
|
||||
Public: ${{ event.tickets.public.price || 0 }}
|
||||
<template v-if="event.tickets.public.quantity">
|
||||
({{ event.tickets.public.sold || 0 }}/{{ event.tickets.public.quantity }})
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else class="status-dim" style="font-size: 11px;">No tickets</span>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="col-actions">
|
||||
<NuxtLink
|
||||
:to="`/events/${event.slug || String(event._id)}`"
|
||||
class="link-btn"
|
||||
title="View"
|
||||
>View</NuxtLink>
|
||||
<button @click="editEvent(event)" class="link-btn" title="Edit">Edit</button>
|
||||
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button>
|
||||
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
No events found matching your criteria
|
||||
</div>
|
||||
<!-- Loading / Error -->
|
||||
<div v-if="pending" class="loading-state">
|
||||
<div class="spinner" />
|
||||
<span>Loading events...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
Error loading events: {{ error }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- ── Upcoming Events ── -->
|
||||
<div class="section-divider">
|
||||
<span class="section-label">Upcoming Events</span>
|
||||
<span class="event-count">{{ upcomingFiltered.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table v-if="upcomingPaged.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-title">Title</th>
|
||||
<th>Type</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th>Registration</th>
|
||||
<th>Tickets</th>
|
||||
<th class="col-actions-head">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="event in upcomingPaged" :key="event._id">
|
||||
<td class="col-title">
|
||||
<div class="event-title-cell">
|
||||
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
|
||||
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="event-name">{{ event.title }}</span>
|
||||
<span class="event-desc">{{ event.description.substring(0, 80) }}...</span>
|
||||
<div v-if="event.series?.isSeriesEvent" class="series-tag">
|
||||
<span class="series-pos">{{ event.series.position }}</span>
|
||||
{{ event.series.title }}
|
||||
</div>
|
||||
<div class="event-flags">
|
||||
<span v-if="event.membersOnly" class="flag">Members Only</span>
|
||||
<span v-if="event.targetCircles?.length" class="flag">{{ event.targetCircles.join(', ') }}</span>
|
||||
<span v-if="!event.isVisible" class="flag flag-dim">Hidden</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
||||
</td>
|
||||
<td class="col-date">
|
||||
<span class="date-main">{{ formatDate(event.startDate) }}</span>
|
||||
<span class="date-time">{{ formatTime(event.startDate) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
||||
{{ getEventStatus(event) }}
|
||||
</span>
|
||||
<span v-if="event.isCancelled" class="status-pill status-cancelled">Cancelled</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="event.registrationRequired" class="status-ok" style="font-size: 11px;">Required</span>
|
||||
<span v-else class="status-dim" style="font-size: 11px;">Optional</span>
|
||||
<span v-if="event.maxAttendees" class="reg-count">
|
||||
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-tickets">
|
||||
<template v-if="event.tickets?.enabled">
|
||||
<span class="ticket-on">Ticketing On</span>
|
||||
<span v-if="event.tickets?.requiresSeriesTicket" class="ticket-detail">Series Pass Required</span>
|
||||
<template v-else>
|
||||
<span v-if="event.tickets.member?.available" class="ticket-detail">
|
||||
Member: {{ event.tickets.member.isFree ? 'Free' : `$${event.tickets.member.price}` }}
|
||||
</span>
|
||||
<span v-if="event.tickets.public?.available" class="ticket-detail">
|
||||
Public: ${{ event.tickets.public.price || 0 }}
|
||||
<template v-if="event.tickets.public.quantity">
|
||||
({{ event.tickets.public.sold || 0 }}/{{ event.tickets.public.quantity }})
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else class="status-dim" style="font-size: 11px;">No tickets</span>
|
||||
</td>
|
||||
<td class="col-actions">
|
||||
<NuxtLink :to="`/events/${event.slug || String(event._id)}`" class="link-btn" title="View">View</NuxtLink>
|
||||
<button @click="editEvent(event)" class="link-btn" title="Edit">Edit</button>
|
||||
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button>
|
||||
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-else class="empty-state">No upcoming events matching your filters</div>
|
||||
|
||||
<div v-if="upcomingPageCount > 1" class="pagination">
|
||||
<button class="page-btn" :disabled="upcomingPage === 1" @click="upcomingPage--">←</button>
|
||||
<span class="page-info">{{ upcomingPage }} / {{ upcomingPageCount }}</span>
|
||||
<button class="page-btn" :disabled="upcomingPage === upcomingPageCount" @click="upcomingPage++">→</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Past Events ── -->
|
||||
<div class="section-divider">
|
||||
<span class="section-label">Past Events</span>
|
||||
<span class="event-count">{{ pastFiltered.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table v-if="pastPaged.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-title">Title</th>
|
||||
<th>Type</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th>Registration</th>
|
||||
<th>Tickets</th>
|
||||
<th class="col-actions-head">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="event in pastPaged" :key="event._id">
|
||||
<td class="col-title">
|
||||
<div class="event-title-cell">
|
||||
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="event-thumb">
|
||||
<img :src="event.featureImage.url" :alt="event.title" @error="handleImageError($event)" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="event-name">{{ event.title }}</span>
|
||||
<span class="event-desc">{{ event.description.substring(0, 80) }}...</span>
|
||||
<div v-if="event.series?.isSeriesEvent" class="series-tag">
|
||||
<span class="series-pos">{{ event.series.position }}</span>
|
||||
{{ event.series.title }}
|
||||
</div>
|
||||
<div class="event-flags">
|
||||
<span v-if="event.membersOnly" class="flag">Members Only</span>
|
||||
<span v-if="event.targetCircles?.length" class="flag">{{ event.targetCircles.join(', ') }}</span>
|
||||
<span v-if="!event.isVisible" class="flag flag-dim">Hidden</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
||||
</td>
|
||||
<td class="col-date">
|
||||
<span class="date-main">{{ formatDate(event.startDate) }}</span>
|
||||
<span class="date-time">{{ formatTime(event.startDate) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
||||
{{ getEventStatus(event) }}
|
||||
</span>
|
||||
<span v-if="event.isCancelled" class="status-pill status-cancelled">Cancelled</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="event.registrationRequired" class="status-ok" style="font-size: 11px;">Required</span>
|
||||
<span v-else class="status-dim" style="font-size: 11px;">Optional</span>
|
||||
<span v-if="event.maxAttendees" class="reg-count">
|
||||
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-tickets">
|
||||
<template v-if="event.tickets?.enabled">
|
||||
<span class="ticket-on">Ticketing On</span>
|
||||
<span v-if="event.tickets?.requiresSeriesTicket" class="ticket-detail">Series Pass Required</span>
|
||||
<template v-else>
|
||||
<span v-if="event.tickets.member?.available" class="ticket-detail">
|
||||
Member: {{ event.tickets.member.isFree ? 'Free' : `$${event.tickets.member.price}` }}
|
||||
</span>
|
||||
<span v-if="event.tickets.public?.available" class="ticket-detail">
|
||||
Public: ${{ event.tickets.public.price || 0 }}
|
||||
<template v-if="event.tickets.public.quantity">
|
||||
({{ event.tickets.public.sold || 0 }}/{{ event.tickets.public.quantity }})
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else class="status-dim" style="font-size: 11px;">No tickets</span>
|
||||
</td>
|
||||
<td class="col-actions">
|
||||
<NuxtLink :to="`/events/${event.slug || String(event._id)}`" class="link-btn" title="View">View</NuxtLink>
|
||||
<button @click="editEvent(event)" class="link-btn" title="Edit">Edit</button>
|
||||
<button @click="duplicateEvent(event)" class="link-btn" title="Duplicate">Dup</button>
|
||||
<button @click="deleteEvent(event)" class="link-btn link-btn-danger" title="Delete">Del</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-else class="empty-state">No past events matching your filters</div>
|
||||
|
||||
<div v-if="pastPageCount > 1" class="pagination">
|
||||
<button class="page-btn" :disabled="pastPage === 1" @click="pastPage--">←</button>
|
||||
<span class="page-info">{{ pastPage }} / {{ pastPageCount }}</span>
|
||||
<button class="page-btn" :disabled="pastPage === pastPageCount" @click="pastPage++">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Confirm Delete Modal -->
|
||||
<div v-if="confirmDelete.show" class="modal-overlay" @click.self="confirmDelete.show = false">
|
||||
<div class="modal">
|
||||
|
|
@ -199,33 +281,12 @@ const {
|
|||
|
||||
const searchQuery = ref('')
|
||||
const typeFilter = ref('all')
|
||||
const statusFilter = ref('all')
|
||||
const seriesFilter = ref('all')
|
||||
|
||||
const filteredEvents = computed(() => {
|
||||
if (!events.value) return []
|
||||
|
||||
return events.value.filter((event) => {
|
||||
const matchesSearch =
|
||||
!searchQuery.value ||
|
||||
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
|
||||
const matchesType =
|
||||
typeFilter.value === 'all' || event.eventType === typeFilter.value
|
||||
|
||||
const eventStatus = getEventStatus(event)
|
||||
const matchesStatus =
|
||||
statusFilter.value === 'all' || eventStatus.toLowerCase() === statusFilter.value
|
||||
|
||||
const matchesSeries =
|
||||
seriesFilter.value === 'all' ||
|
||||
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
|
||||
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
|
||||
|
||||
return matchesSearch && matchesType && matchesStatus && matchesSeries
|
||||
})
|
||||
})
|
||||
const upcomingPage = ref(1)
|
||||
const pastPage = ref(1)
|
||||
const UPCOMING_PAGE_SIZE = 10
|
||||
const PAST_PAGE_SIZE = 5
|
||||
|
||||
const getEventStatus = (event) => {
|
||||
const now = new Date()
|
||||
|
|
@ -237,6 +298,57 @@ const getEventStatus = (event) => {
|
|||
return 'Past'
|
||||
}
|
||||
|
||||
const applyBaseFilters = (list) => {
|
||||
if (!list) return []
|
||||
return list.filter((event) => {
|
||||
const matchesSearch =
|
||||
!searchQuery.value ||
|
||||
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
|
||||
const matchesType =
|
||||
typeFilter.value === 'all' || event.eventType === typeFilter.value
|
||||
|
||||
const matchesSeries =
|
||||
seriesFilter.value === 'all' ||
|
||||
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
|
||||
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
|
||||
|
||||
return matchesSearch && matchesType && matchesSeries
|
||||
})
|
||||
}
|
||||
|
||||
const upcomingFiltered = computed(() => {
|
||||
return applyBaseFilters(events.value)
|
||||
.filter((e) => getEventStatus(e) !== 'Past')
|
||||
.sort((a, b) => new Date(a.startDate) - new Date(b.startDate))
|
||||
})
|
||||
|
||||
const pastFiltered = computed(() => {
|
||||
return applyBaseFilters(events.value)
|
||||
.filter((e) => getEventStatus(e) === 'Past')
|
||||
.sort((a, b) => new Date(b.startDate) - new Date(a.startDate))
|
||||
})
|
||||
|
||||
const upcomingPageCount = computed(() => Math.max(1, Math.ceil(upcomingFiltered.value.length / UPCOMING_PAGE_SIZE)))
|
||||
const pastPageCount = computed(() => Math.max(1, Math.ceil(pastFiltered.value.length / PAST_PAGE_SIZE)))
|
||||
|
||||
const upcomingPaged = computed(() => {
|
||||
const start = (upcomingPage.value - 1) * UPCOMING_PAGE_SIZE
|
||||
return upcomingFiltered.value.slice(start, start + UPCOMING_PAGE_SIZE)
|
||||
})
|
||||
|
||||
const pastPaged = computed(() => {
|
||||
const start = (pastPage.value - 1) * PAST_PAGE_SIZE
|
||||
return pastFiltered.value.slice(start, start + PAST_PAGE_SIZE)
|
||||
})
|
||||
|
||||
// Reset pagination when filters change
|
||||
watch([searchQuery, typeFilter, seriesFilter], () => {
|
||||
upcomingPage.value = 1
|
||||
pastPage.value = 1
|
||||
})
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
|
|
@ -309,10 +421,7 @@ const editEvent = (event) => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-events {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.admin-events {}
|
||||
|
||||
/* ---- PAGE HEADER ---- */
|
||||
.page-header {
|
||||
|
|
@ -350,9 +459,34 @@ const editEvent = (event) => {
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ---- SECTION DIVIDER ---- */
|
||||
.section-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 28px 0;
|
||||
}
|
||||
|
||||
.section-divider .section-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.event-count {
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--border);
|
||||
padding: 1px 7px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ---- TABLE ---- */
|
||||
.table-wrap {
|
||||
padding: 0 28px 28px;
|
||||
padding: 12px 28px 24px;
|
||||
}
|
||||
|
||||
table {
|
||||
|
|
@ -580,6 +714,41 @@ tbody td {
|
|||
color: var(--ember);
|
||||
}
|
||||
|
||||
/* ---- PAGINATION ---- */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 0 0;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
background: none;
|
||||
border: 1px dashed var(--border);
|
||||
color: var(--candle);
|
||||
cursor: pointer;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
transition: border-color 0.1s;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
color: var(--text-faint);
|
||||
border-color: var(--border);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.page-btn:not(:disabled):hover {
|
||||
border-color: var(--candle);
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ---- STATES ---- */
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
|
|
@ -597,7 +766,7 @@ tbody td {
|
|||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
padding: 32px 24px;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
|
@ -699,11 +868,15 @@ tbody td {
|
|||
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
padding: 12px 20px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
padding: 16px 20px 0;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
padding: 0 12px 20px;
|
||||
padding: 12px 20px 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@
|
|||
<div v-else-if="recentMembers.length" class="item-list">
|
||||
<div v-for="member in recentMembers" :key="member._id" class="item-row">
|
||||
<div>
|
||||
<span class="item-name">{{ member.name }}</span>
|
||||
<NuxtLink :to="`/admin/members/${member._id}`" class="item-name">{{ member.name }}</NuxtLink>
|
||||
<span class="item-sub">{{ member.email }}</span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
|
|
@ -125,10 +125,7 @@ const formatDateTime = (dateString) => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-dash {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.admin-dash {}
|
||||
|
||||
/* ---- PAGE HEADER ---- */
|
||||
.page-header {
|
||||
|
|
@ -241,6 +238,12 @@ const formatDateTime = (dateString) => {
|
|||
display: block;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.item-name:hover {
|
||||
color: var(--candle);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.item-sub {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
<template>
|
||||
<div class="admin-member-detail">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-nav">
|
||||
<NuxtLink to="/admin/members" class="back-link">← Members</NuxtLink>
|
||||
<NuxtLink v-if="member && member.status === 'active' && member.showInDirectory" :to="`/members/${member._id}`" class="profile-link" target="_blank">
|
||||
View public profile ↗
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="header-row">
|
||||
<div>
|
||||
<NuxtLink to="/admin/members" class="back-link">← Members</NuxtLink>
|
||||
<h1 v-if="member">{{ member.name }}</h1>
|
||||
<h1 v-else-if="pending">Loading…</h1>
|
||||
<h1 v-else>Member not found</h1>
|
||||
<p v-if="member" class="member-email">{{ member.email }}</p>
|
||||
</div>
|
||||
<div v-if="member" class="header-actions">
|
||||
<div v-if="member" class="header-badges">
|
||||
<span class="badge" :class="member.circle">{{ member.circle }}</span>
|
||||
<span :class="statusClass(member.status)" class="status-badge">{{
|
||||
member.status
|
||||
}}</span>
|
||||
<span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -26,165 +30,173 @@
|
|||
<div v-else-if="fetchError" class="error-state">Failed to load member.</div>
|
||||
|
||||
<template v-else-if="member">
|
||||
<!-- Edit form -->
|
||||
<section class="detail-section">
|
||||
<div class="section-label">Member details</div>
|
||||
<form class="edit-form" @submit.prevent="submitEdit">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input v-model="form.name" type="text" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Email</label>
|
||||
<input v-model="form.email" type="email" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Circle</label>
|
||||
<select v-model="form.circle">
|
||||
<option value="community">Community</option>
|
||||
<option value="founder">Founder</option>
|
||||
<option value="practitioner">Practitioner</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Contribution tier ($/mo)</label>
|
||||
<select v-model="form.contributionTier">
|
||||
<option value="0">$0</option>
|
||||
<option value="5">$5</option>
|
||||
<option value="15">$15</option>
|
||||
<option value="30">$30</option>
|
||||
<option value="50">$50</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Status</label>
|
||||
<select v-model="form.status">
|
||||
<option value="pending_payment">pending_payment</option>
|
||||
<option value="active">active</option>
|
||||
<option value="suspended">suspended</option>
|
||||
<option value="cancelled">cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Role</label>
|
||||
<select v-model="form.role">
|
||||
<option value="member">member</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? "Saving…" : "Save changes" }}
|
||||
</button>
|
||||
<button type="button" class="btn" @click="resetForm">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<div class="detail-body">
|
||||
<!-- LEFT COLUMN: form + metadata -->
|
||||
<div class="detail-left">
|
||||
<!-- Edit form -->
|
||||
<section class="detail-section">
|
||||
<div class="section-label">Member details</div>
|
||||
<form class="edit-form" @submit.prevent="submitEdit">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input v-model="form.name" type="text" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Email</label>
|
||||
<input v-model="form.email" type="email" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Circle</label>
|
||||
<select v-model="form.circle">
|
||||
<option value="community">Community</option>
|
||||
<option value="founder">Founder</option>
|
||||
<option value="practitioner">Practitioner</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Contribution tier ($/mo)</label>
|
||||
<select v-model="form.contributionTier">
|
||||
<option value="0">$0</option>
|
||||
<option value="5">$5</option>
|
||||
<option value="15">$15</option>
|
||||
<option value="30">$30</option>
|
||||
<option value="50">$50</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Status</label>
|
||||
<select v-model="form.status">
|
||||
<option value="pending_payment">pending_payment</option>
|
||||
<option value="active">active</option>
|
||||
<option value="suspended">suspended</option>
|
||||
<option value="cancelled">cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Role</label>
|
||||
<select v-model="form.role">
|
||||
<option value="member">member</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? "Saving…" : "Save changes" }}
|
||||
</button>
|
||||
<button type="button" class="btn" @click="resetForm">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Metadata -->
|
||||
<section class="detail-section">
|
||||
<div class="section-label">Account info</div>
|
||||
<dl class="meta-list">
|
||||
<div class="meta-row">
|
||||
<dt>Member ID</dt>
|
||||
<dd class="mono">{{ member._id }}</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Joined</dt>
|
||||
<dd>{{ formatDate(member.createdAt) }}</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Invite email</dt>
|
||||
<dd :class="member.inviteEmailSent ? 'status-ok' : 'status-dim'">
|
||||
{{ member.inviteEmailSent ? "Sent" : "Not sent" }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Slack invite</dt>
|
||||
<dd :class="member.slackInvited ? 'status-ok' : 'status-dim'">
|
||||
{{ member.slackInvited ? "Invited" : "Pending" }}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="member.helcimCustomerId" class="meta-row">
|
||||
<dt>Helcim customer</dt>
|
||||
<dd class="mono">{{ member.helcimCustomerId }}</dd>
|
||||
</div>
|
||||
<div v-if="member.helcimSubscriptionId" class="meta-row">
|
||||
<dt>Helcim subscription</dt>
|
||||
<dd class="mono">{{ member.helcimSubscriptionId }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<!-- Metadata -->
|
||||
<section class="detail-section">
|
||||
<div class="section-label">Account info</div>
|
||||
<dl class="meta-list">
|
||||
<div v-if="member.memberNumber" class="meta-row">
|
||||
<dt>Member number</dt>
|
||||
<dd class="mono">#{{ member.memberNumber }}</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Member ID</dt>
|
||||
<dd class="mono">{{ member._id }}</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Joined</dt>
|
||||
<dd>{{ formatDate(member.createdAt) }}</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Invite email</dt>
|
||||
<dd :class="member.inviteEmailSent ? 'status-ok' : 'status-dim'">
|
||||
{{ member.inviteEmailSent ? "Sent" : "Not sent" }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Slack invite</dt>
|
||||
<dd :class="member.slackInvited ? 'status-ok' : 'status-dim'">
|
||||
{{ member.slackInvited ? "Invited" : "Pending" }}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="member.helcimCustomerId" class="meta-row">
|
||||
<dt>Helcim customer</dt>
|
||||
<dd class="mono">{{ member.helcimCustomerId }}</dd>
|
||||
</div>
|
||||
<div v-if="member.helcimSubscriptionId" class="meta-row">
|
||||
<dt>Helcim subscription</dt>
|
||||
<dd class="mono">{{ member.helcimSubscriptionId }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- Notification preferences -->
|
||||
<section class="detail-section">
|
||||
<div class="section-label">Notification preferences</div>
|
||||
<dl class="meta-list">
|
||||
<div class="meta-row">
|
||||
<dt>Event reminders</dt>
|
||||
<dd
|
||||
:class="
|
||||
member.notifications?.events !== false
|
||||
? 'status-ok'
|
||||
: 'status-dim'
|
||||
"
|
||||
>
|
||||
{{ member.notifications?.events !== false ? "On" : "Off" }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Community updates</dt>
|
||||
<dd
|
||||
:class="
|
||||
member.notifications?.updates !== false
|
||||
? 'status-ok'
|
||||
: 'status-dim'
|
||||
"
|
||||
>
|
||||
{{ member.notifications?.updates !== false ? "On" : "Off" }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Peer support requests</dt>
|
||||
<dd
|
||||
:class="
|
||||
member.notifications?.peerRequests !== false
|
||||
? 'status-ok'
|
||||
: 'status-dim'
|
||||
"
|
||||
>
|
||||
{{ member.notifications?.peerRequests !== false ? "On" : "Off" }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<!-- Notification preferences -->
|
||||
<section class="detail-section">
|
||||
<div class="section-label">Notification preferences</div>
|
||||
<dl class="meta-list">
|
||||
<div class="meta-row">
|
||||
<dt>Event reminders</dt>
|
||||
<dd :class="member.notifications?.events !== false ? 'status-ok' : 'status-dim'">
|
||||
{{ member.notifications?.events !== false ? "On" : "Off" }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Community updates</dt>
|
||||
<dd :class="member.notifications?.updates !== false ? 'status-ok' : 'status-dim'">
|
||||
{{ member.notifications?.updates !== false ? "On" : "Off" }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>Peer support requests</dt>
|
||||
<dd :class="member.notifications?.peerRequests !== false ? 'status-ok' : 'status-dim'">
|
||||
{{ member.notifications?.peerRequests !== false ? "On" : "Off" }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Full Activity Log -->
|
||||
<section class="detail-section">
|
||||
<div class="section-label">Full Activity Log</div>
|
||||
<ClientOnly>
|
||||
<div v-if="activityLoading && !activityEntries.length" class="loading-state">
|
||||
<div class="spinner" />
|
||||
Loading activity...
|
||||
</div>
|
||||
<div v-else-if="activityEntries.length" class="activity-log">
|
||||
<div v-for="entry in activityEntries" :key="entry._id" class="al-item" :class="{ 'al-admin': entry.visibility === 'admin' }">
|
||||
<UIcon :name="getActivity(entry).icon" class="al-icon" />
|
||||
<span class="al-text">{{ getActivity(entry).text }}</span>
|
||||
<span class="al-time">{{ formatDate(entry.timestamp) }}</span>
|
||||
<span v-if="entry.visibility === 'admin'" class="al-vis-badge">admin-only</span>
|
||||
</div>
|
||||
<div v-if="activityHasMore" class="al-load-more">
|
||||
<button class="btn" :disabled="activityLoadingMore" @click="loadMoreActivity">
|
||||
{{ activityLoadingMore ? 'Loading...' : 'Load More' }}
|
||||
</button>
|
||||
<!-- RIGHT COLUMN: activity log -->
|
||||
<div class="detail-right">
|
||||
<div class="activity-panel">
|
||||
<div class="activity-panel-header">
|
||||
<div class="section-label">Activity log</div>
|
||||
<span class="activity-legend">
|
||||
<span class="al-vis-badge">admin-only</span> = not visible to member
|
||||
</span>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
<div v-if="activityLoading && !activityEntries.length" class="activity-loading">
|
||||
<div class="spinner" />
|
||||
Loading activity...
|
||||
</div>
|
||||
<div v-else-if="activityEntries.length" class="activity-timeline">
|
||||
<div
|
||||
v-for="entry in activityEntries"
|
||||
:key="entry._id"
|
||||
class="al-item"
|
||||
:class="{ 'al-admin': entry.visibility === 'admin' }"
|
||||
>
|
||||
<div class="al-dot" />
|
||||
<div class="al-body">
|
||||
<div class="al-row">
|
||||
<UIcon :name="getActivity(entry).icon" class="al-icon" />
|
||||
<span class="al-text">{{ getActivity(entry).text }}</span>
|
||||
<span v-if="entry.visibility === 'admin'" class="al-vis-badge">admin-only</span>
|
||||
</div>
|
||||
<span class="al-time">{{ formatDate(entry.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="activityHasMore" class="al-load-more">
|
||||
<button class="btn" :disabled="activityLoadingMore" @click="loadMoreActivity">
|
||||
{{ activityLoadingMore ? 'Loading...' : 'Load more' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="activity-empty">
|
||||
No activity recorded.
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<div v-else class="loading-state">
|
||||
No activity recorded.
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -271,12 +283,12 @@ async function submitEdit() {
|
|||
member.value = { ...member.value, ...updated, role: form.role };
|
||||
pageBreadcrumbTitle.value = form.name;
|
||||
}
|
||||
toast.add({ title: "Member updated", color: "green" });
|
||||
toast.add({ title: "Member updated", color: "success" });
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
title: "Failed to update member",
|
||||
description: err.data?.statusMessage || err.message,
|
||||
color: "red",
|
||||
color: "error",
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
|
|
@ -344,13 +356,42 @@ onMounted(loadActivity)
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-member-detail {
|
||||
max-width: 640px;
|
||||
padding: 32px 40px 60px;
|
||||
.admin-member-detail {}
|
||||
|
||||
/* ---- PAGE HEADER ---- */
|
||||
.page-header {
|
||||
padding: 28px 28px 20px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 32px;
|
||||
.header-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--candle);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.profile-link {
|
||||
font-size: 11px;
|
||||
color: var(--candle);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.profile-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
|
|
@ -360,26 +401,13 @@ onMounted(loadActivity)
|
|||
gap: 16px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
text-decoration: none;
|
||||
margin-bottom: 8px;
|
||||
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--candle);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
margin: 0 0 4px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.member-email {
|
||||
|
|
@ -388,29 +416,44 @@ onMounted(loadActivity)
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
.header-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 6px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
padding: 2px 8px;
|
||||
border: 1px dashed var(--border);
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ---- TWO-COLUMN BODY ---- */
|
||||
.detail-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.detail-left {
|
||||
border-right: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 40px;
|
||||
padding: 24px 28px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
|
|
@ -418,6 +461,8 @@ onMounted(loadActivity)
|
|||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.meta-list {
|
||||
|
|
@ -430,8 +475,8 @@ onMounted(loadActivity)
|
|||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 10px 14px;
|
||||
gap: 16px;
|
||||
padding: 9px 14px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
|
|
@ -452,6 +497,7 @@ onMounted(loadActivity)
|
|||
font-size: 12px;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mono {
|
||||
|
|
@ -459,8 +505,9 @@ onMounted(loadActivity)
|
|||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---- STATUS ---- */
|
||||
.status-ok {
|
||||
color: var(--c-founder);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.status-dim {
|
||||
|
|
@ -471,6 +518,7 @@ onMounted(loadActivity)
|
|||
color: var(--ember);
|
||||
}
|
||||
|
||||
/* ---- STATES ---- */
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
|
|
@ -478,46 +526,122 @@ onMounted(loadActivity)
|
|||
gap: 12px;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
padding: 40px 0;
|
||||
padding: 40px 28px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--candle);
|
||||
border: 2px dashed var(--candle);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ---- ACTIVITY LOG ---- */
|
||||
.activity-log {
|
||||
margin-top: 12px;
|
||||
border: 1px dashed var(--border);
|
||||
/* ---- ACTIVITY PANEL ---- */
|
||||
.detail-right {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.al-item {
|
||||
.activity-panel {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.activity-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
justify-content: space-between;
|
||||
padding: 24px 28px 16px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-legend {
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.activity-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 32px 28px;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.al-item:last-child {
|
||||
border-bottom: none;
|
||||
.activity-empty {
|
||||
padding: 32px 28px;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.al-admin {
|
||||
opacity: 0.7;
|
||||
/* Timeline */
|
||||
.activity-timeline {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 16px 0 24px;
|
||||
}
|
||||
|
||||
.al-item {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
gap: 0 10px;
|
||||
padding: 0 28px 0 20px;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.al-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 27px;
|
||||
top: 18px;
|
||||
bottom: -16px;
|
||||
width: 1px;
|
||||
border-left: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.al-item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.al-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border: 1px dashed var(--border);
|
||||
background: var(--bg);
|
||||
flex-shrink: 0;
|
||||
margin-top: 4px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.al-admin .al-dot {
|
||||
border-color: var(--candle-faint);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.al-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.al-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.al-icon {
|
||||
|
|
@ -525,41 +649,75 @@ onMounted(loadActivity)
|
|||
height: 14px;
|
||||
color: var(--text-faint);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.al-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--text-dim);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.al-time {
|
||||
display: block;
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
margin-top: 3px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.al-vis-badge {
|
||||
font-size: 9px;
|
||||
color: var(--text-faint);
|
||||
border: 1px dashed var(--border);
|
||||
padding: 1px 4px;
|
||||
color: var(--candle);
|
||||
border: 1px dashed var(--candle-faint);
|
||||
padding: 1px 5px;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.al-load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
padding: 8px 28px 0;
|
||||
}
|
||||
|
||||
/* ---- RESPONSIVE ---- */
|
||||
@media (max-width: 1024px) {
|
||||
.detail-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-left {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.activity-panel {
|
||||
position: static;
|
||||
max-height: none;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-member-detail {
|
||||
padding: 20px 16px 40px;
|
||||
.page-header {
|
||||
padding: 24px 20px 16px;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
.detail-section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.activity-panel-header {
|
||||
padding: 16px 20px 12px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.al-item {
|
||||
padding: 0 20px 0 14px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
|
|
|
|||
|
|
@ -55,18 +55,15 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th class="col-check">
|
||||
<UCheckbox
|
||||
label="Select all members"
|
||||
:ui="{ label: 'sr-only' }"
|
||||
:model-value="
|
||||
allVisibleSelected
|
||||
? true
|
||||
: someVisibleSelected
|
||||
? 'indeterminate'
|
||||
: false
|
||||
"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
<label class="custom-check" aria-label="Select all members">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allVisibleSelected"
|
||||
:indeterminate="!allVisibleSelected && someVisibleSelected"
|
||||
@change="toggleSelectAll"
|
||||
/>
|
||||
<span class="check-mark" />
|
||||
</label>
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
|
|
@ -79,16 +76,26 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="member in filteredMembers" :key="member._id">
|
||||
<td class="col-check">
|
||||
<UCheckbox
|
||||
:label="`Select ${member.name}`"
|
||||
:ui="{ label: 'sr-only' }"
|
||||
:model-value="selectedMemberIds.includes(member._id)"
|
||||
@update:model-value="toggleSelect(member._id)"
|
||||
/>
|
||||
<tr
|
||||
v-for="member in filteredMembers"
|
||||
:key="member._id"
|
||||
class="selectable-row"
|
||||
:class="{ 'row-selected': selectedMemberIds.includes(member._id) }"
|
||||
@click="toggleSelect(member._id)"
|
||||
>
|
||||
<td class="col-check" @click.stop>
|
||||
<label class="custom-check" :aria-label="`Select ${member.name}`">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedMemberIds.includes(member._id)"
|
||||
@change="toggleSelect(member._id)"
|
||||
/>
|
||||
<span class="check-mark" />
|
||||
</label>
|
||||
</td>
|
||||
<td class="col-name">
|
||||
<NuxtLink :to="`/admin/members/${member._id}`" class="member-name-link" @click.stop>{{ member.name }}</NuxtLink>
|
||||
</td>
|
||||
<td class="col-name">{{ member.name }}</td>
|
||||
<td class="col-email">{{ member.email }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="member.circle">{{
|
||||
|
|
@ -112,13 +119,13 @@
|
|||
{{ formatDate(member.createdAt) }}
|
||||
</td>
|
||||
<td class="col-actions">
|
||||
<NuxtLink :to="`/admin/members/${member._id}`" class="link-btn"
|
||||
<NuxtLink :to="`/admin/members/${member._id}`" class="link-btn" @click.stop
|
||||
>View</NuxtLink
|
||||
>
|
||||
<button @click="sendSlackInvite(member)" class="link-btn">
|
||||
<button @click.stop="sendSlackInvite(member)" class="link-btn">
|
||||
Slack
|
||||
</button>
|
||||
<button @click="editMember(member)" class="link-btn">Edit</button>
|
||||
<button @click.stop="editMember(member)" class="link-btn">Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
@ -602,13 +609,13 @@ const createMember = async () => {
|
|||
});
|
||||
|
||||
await refresh();
|
||||
toast.add({ title: "Member created", color: "green" });
|
||||
toast.add({ title: "Member created", color: "success" });
|
||||
} catch (err) {
|
||||
console.error("Failed to create member:", err);
|
||||
toast.add({
|
||||
title: "Failed to create member",
|
||||
description: err.data?.statusMessage || err.message,
|
||||
color: "red",
|
||||
color: "error",
|
||||
});
|
||||
} finally {
|
||||
creating.value = false;
|
||||
|
|
@ -718,14 +725,14 @@ const submitImport = async () => {
|
|||
toast.add({
|
||||
title: `Imported ${result.created} member${result.created !== 1 ? "s" : ""}`,
|
||||
description: result.failed ? `${result.failed} failed` : undefined,
|
||||
color: result.failed ? "orange" : "green",
|
||||
color: result.failed ? "warning" : "success",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Import failed:", err);
|
||||
toast.add({
|
||||
title: "Import failed",
|
||||
description: err.data?.statusMessage || err.message,
|
||||
color: "red",
|
||||
color: "error",
|
||||
});
|
||||
} finally {
|
||||
importing.value = false;
|
||||
|
|
@ -757,14 +764,14 @@ const submitInvites = async () => {
|
|||
toast.add({
|
||||
title: `Sent ${result.sent} invite${result.sent !== 1 ? "s" : ""}`,
|
||||
description: result.failed ? `${result.failed} failed` : undefined,
|
||||
color: result.failed ? "orange" : "green",
|
||||
color: result.failed ? "warning" : "success",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send invites:", err);
|
||||
toast.add({
|
||||
title: "Failed to send invites",
|
||||
description: err.data?.statusMessage || err.message,
|
||||
color: "red",
|
||||
color: "error",
|
||||
});
|
||||
} finally {
|
||||
sendingInvites.value = false;
|
||||
|
|
@ -815,12 +822,12 @@ const submitEditMember = async () => {
|
|||
});
|
||||
showEditModal.value = false;
|
||||
await refresh();
|
||||
toast.add({ title: "Member updated", color: "green" });
|
||||
toast.add({ title: "Member updated", color: "success" });
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
title: "Failed to update member",
|
||||
description: err.data?.statusMessage || err.message,
|
||||
color: "red",
|
||||
color: "error",
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
|
|
@ -829,10 +836,7 @@ const submitEditMember = async () => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-members {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.admin-members {}
|
||||
|
||||
/* ---- PAGE HEADER ---- */
|
||||
.page-header {
|
||||
|
|
@ -914,16 +918,94 @@ 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);
|
||||
}
|
||||
|
||||
.member-name-link {
|
||||
color: var(--text-bright);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.member-name-link:hover {
|
||||
color: var(--candle);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.col-email {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
|
|
@ -1184,11 +1266,11 @@ tbody td {
|
|||
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
padding: 12px 20px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
padding: 0 12px 20px;
|
||||
padding: 0 20px 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@
|
|||
<div class="series-title-row">
|
||||
<div>
|
||||
<span class="badge" :class="getSeriesTypeClass(series.type)">{{ formatSeriesType(series.type) }}</span>
|
||||
<h3>{{ series.title }}</h3>
|
||||
<h2>{{ series.title }}</h2>
|
||||
<p class="series-desc">{{ series.description }}</p>
|
||||
</div>
|
||||
<div class="series-meta">
|
||||
|
|
@ -171,15 +171,15 @@
|
|||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="section-label">Series Management Tools</div>
|
||||
<button @click="reorderAllSeries" class="bulk-action">
|
||||
<button @click="reorderAllSeries" class="btn bulk-action">
|
||||
<strong>Auto-Reorder Series</strong>
|
||||
<span>Fix position numbers based on event dates</span>
|
||||
</button>
|
||||
<button @click="validateAllSeries" class="bulk-action">
|
||||
<button @click="validateAllSeries" class="btn bulk-action">
|
||||
<strong>Validate Series Data</strong>
|
||||
<span>Check for consistency issues</span>
|
||||
</button>
|
||||
<button @click="exportSeriesData" class="bulk-action">
|
||||
<button @click="exportSeriesData" class="btn bulk-action">
|
||||
<strong>Export Series Data</strong>
|
||||
<span>Download series information as JSON</span>
|
||||
</button>
|
||||
|
|
@ -714,10 +714,7 @@ const exportSeriesData = () => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-series {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.admin-series {}
|
||||
|
||||
/* ---- PAGE HEADER ---- */
|
||||
.page-header {
|
||||
|
|
@ -813,7 +810,7 @@ const exportSeriesData = () => {
|
|||
gap: 16px;
|
||||
}
|
||||
|
||||
.series-header h3 {
|
||||
.series-header h2 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
|
|
@ -1217,7 +1214,7 @@ const exportSeriesData = () => {
|
|||
}
|
||||
|
||||
.series-list {
|
||||
padding: 16px 12px;
|
||||
padding: 20px 20px;
|
||||
}
|
||||
|
||||
.series-header,
|
||||
|
|
|
|||
|
|
@ -251,7 +251,6 @@ const createAndAddEvent = async () => {
|
|||
<style scoped>
|
||||
.create-form {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
|
|
@ -271,13 +270,14 @@ const createAndAddEvent = async () => {
|
|||
.page-header p { font-size: 12px; color: var(--text-dim); }
|
||||
|
||||
.back-link {
|
||||
font-size: 12px;
|
||||
color: var(--candle);
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
text-decoration: none;
|
||||
margin-bottom: 8px;
|
||||
display: inline-block;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
.back-link:hover { color: var(--candle); text-decoration: none; }
|
||||
|
||||
.form-body { padding: 24px 28px; }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue