source: petify-frontend/src/views/AdminListingsView.vue@ 92e7c7a

Last change on this file since 92e7c7a was 92e7c7a, checked in by veronika-ils <ilioskaveronika@…>, 6 hours ago

Petify fullstack project

  • Property mode set to 100644
File size: 14.1 KB
RevLine 
[92e7c7a]1<template>
2 <main class="admin-listings">
3 <section class="header-section">
4 <div class="container">
5 <h1 class="page-title">Listings</h1>
6 <p class="page-subtitle">
7 Browse all listings in the system. Results are paginated (500 per page).
8 </p>
9 </div>
10 </section>
11
12 <section class="container listings-body">
13 <div v-if="errorMessage" class="alert alert-danger">{{ errorMessage }}</div>
14
15 <section class="panel">
16 <div class="panel-header">
17 <div>
18 <h2>All Listings</h2>
19 <p v-if="pageData" class="panel-subtitle">
20 Total: {{ pageData.totalItems }} | Active: {{ pageData.activeListings }} | Sold: {{ pageData.soldListings }}
21 </p>
22 </div>
23
24 <div class="panel-actions">
25 <button class="btn btn-sm btn-outline-secondary" type="button" :disabled="isLoading" @click="refresh">
26 Refresh
27 </button>
28 </div>
29 </div>
30
31 <div class="pagination-bar" v-if="pageData">
32 <button
33 class="btn btn-sm btn-outline-secondary"
34 type="button"
35 :disabled="isLoading || !pageData.hasPrevious"
36 @click="goPrevious"
37 >
38 Previous
39 </button>
40
41 <div class="page-meta">
42 Page {{ pageData.page + 1 }} / {{ Math.max(1, pageData.totalPages) }} (size: {{ pageData.size }})
43 </div>
44
45 <button
46 class="btn btn-sm btn-outline-secondary"
47 type="button"
48 :disabled="isLoading || !pageData.hasNext"
49 @click="goNext"
50 >
51 Next
52 </button>
53 </div>
54
55 <div v-if="pageData" class="filter-bar">
56 <div class="filter-field search-field">
57 <label for="listingSearch">Search</label>
58 <input
59 id="listingSearch"
60 v-model="filters.search"
61 class="form-control form-control-sm"
62 placeholder="Pet, owner, or description"
63 />
64 </div>
65
66 <div class="filter-field">
67 <label for="listingStatus">Status</label>
68 <select id="listingStatus" v-model="filters.status" class="form-select form-select-sm">
69 <option value="">All</option>
70 <option v-for="status in availableStatuses" :key="status" :value="status">{{ status }}</option>
71 </select>
72 </div>
73
74 <div class="filter-field">
75 <label for="minPrice">Min price</label>
76 <input id="minPrice" v-model="filters.minPrice" class="form-control form-control-sm" type="number" min="0" step="1" @change="applyFilters" />
77 </div>
78
79 <div class="filter-field">
80 <label for="maxPrice">Max price</label>
81 <input id="maxPrice" v-model="filters.maxPrice" class="form-control form-control-sm" type="number" min="0" step="1" @change="applyFilters" />
82 </div>
83
84 <button class="btn btn-sm btn-outline-secondary clear-filters" type="button" @click="clearFilters">
85 Clear
86 </button>
87 </div>
88
89 <p v-if="pageData && hasActiveFilters" class="filter-summary">
90 Showing {{ filteredListings.length }} of {{ pageData.totalItems }} listings.
91 </p>
92
93 <div v-if="isLoading" class="alert alert-info">Loading listings...</div>
94
95 <div v-if="!isLoading && (!pageData || pageData.items.length === 0)" class="empty-state">
96 No listings found.
97 </div>
98
99 <div v-else-if="!isLoading && filteredListings.length === 0" class="empty-state">
100 No listings match the selected filters.
101 </div>
102
103 <div v-if="!isLoading && filteredListings.length" class="listings-grid">
104 <article v-for="listing in filteredListings" :key="listing.listingId" class="listing-card">
105 <div class="listing-card-header">
106 <div>
107 <RouterLink v-if="listing.listingId" class="listing-title" :to="`/listing/${listing.listingId}`">
108 {{ getAnimalName(listing.animalId) }}
109 </RouterLink>
110 <h3 v-else class="listing-title">{{ getAnimalName(listing.animalId) }}</h3>
111 <p class="listing-owner">{{ getOwnerName(listing.ownerId) }}</p>
112 </div>
113 <span class="badge" :class="statusClass(listing.status)">{{ listing.status }}</span>
114 </div>
115
116 <p class="listing-description">{{ listing.description || 'No description' }}</p>
117
118 <div class="listing-footer">
119 <span class="listing-price">{{ formatPrice(listing.price) }}</span>
120 <span class="listing-date">{{ formatDate(listing.createdAt) }}</span>
121 </div>
122 </article>
123 </div>
124
125 <div class="pagination-bar" v-if="pageData">
126 <button
127 class="btn btn-sm btn-outline-secondary"
128 type="button"
129 :disabled="isLoading || !pageData.hasPrevious"
130 @click="goPrevious"
131 >
132 Previous
133 </button>
134
135 <div class="page-meta">
136 Page {{ pageData.page + 1 }} / {{ Math.max(1, pageData.totalPages) }}
137 </div>
138
139 <button
140 class="btn btn-sm btn-outline-secondary"
141 type="button"
142 :disabled="isLoading || !pageData.hasNext"
143 @click="goNext"
144 >
145 Next
146 </button>
147 </div>
148 </section>
149 </section>
150 </main>
151</template>
152
153<script setup lang="ts">
154import { computed, onMounted, ref, watch } from 'vue'
155import { useRouter } from 'vue-router'
156import { getAllListings, getAllUsers, type AdminListingsPage } from '../api/admin'
157import { getPet } from '../api/profile'
158import { useAuthStore } from '../stores/auth'
159
160const router = useRouter()
161const auth = useAuthStore()
162
163const pageData = ref<AdminListingsPage | null>(null)
164const page = ref(0)
165const isLoading = ref(false)
166const errorMessage = ref('')
167const ownerNameMap = ref<Record<number, string>>({})
168const animalNameMap = ref<Record<number, string>>({})
169const filters = ref({
170 search: '',
171 status: '',
172 minPrice: '',
173 maxPrice: '',
174})
175
176const PAGE_SIZE = 500
177const availableStatuses = ['ACTIVE', 'SOLD', 'DRAFT', 'ARCHIVED']
178
179let latestListingsRequest = 0
180
181const hasActiveFilters = computed(() => {
182 return Boolean(filters.value.search.trim() || filters.value.status || filters.value.minPrice || filters.value.maxPrice)
183})
184
185const filteredListings = computed(() => {
186 const listings = pageData.value?.items || []
187 const search = filters.value.search.trim().toLowerCase()
188
189 return listings.filter((listing: any) => {
190 const matchesSearch = !search || [
191 getAnimalName(listing.animalId),
192 getOwnerName(listing.ownerId),
193 listing.description || '',
194 listing.status || '',
195 ].join(' ').toLowerCase().includes(search)
196
197 return matchesSearch
198 })
199})
200
201async function loadPage(targetPage: number) {
202 if (!auth.user?.userId) return
203
204 const requestId = ++latestListingsRequest
205 isLoading.value = true
206 errorMessage.value = ''
207 try {
208 const data = await getAllListings(auth.user.userId, targetPage, PAGE_SIZE, {
209 status: filters.value.status,
210 minPrice: filters.value.minPrice,
211 maxPrice: filters.value.maxPrice,
212 })
213 if (requestId !== latestListingsRequest) return
214 pageData.value = data
215 page.value = data.page
216 await loadNamesForPage(data.items)
217 } catch (error) {
218 if (requestId !== latestListingsRequest) return
219 pageData.value = null
220 errorMessage.value = error instanceof Error ? error.message : 'Failed to load listings'
221 } finally {
222 if (requestId === latestListingsRequest) {
223 isLoading.value = false
224 }
225 }
226}
227
228async function loadNamesForPage(listings: any[]) {
229 const ownerIds = Array.from(new Set(
230 listings
231 .map((listing) => Number(listing.ownerId))
232 .filter((ownerId) => Number.isFinite(ownerId) && !ownerNameMap.value[ownerId])
233 ))
234 const animalIds = Array.from(new Set(
235 listings
236 .map((listing) => Number(listing.animalId))
237 .filter((animalId) => Number.isFinite(animalId) && !animalNameMap.value[animalId])
238 ))
239
240 if (ownerIds.length > 0 && auth.user?.userId) {
241 try {
242 const users = await getAllUsers(auth.user.userId)
243 const nextOwnerMap = { ...ownerNameMap.value }
244 users.forEach((user: any) => {
245 if (user.userId) {
246 nextOwnerMap[Number(user.userId)] = `${user.firstName || ''} ${user.lastName || ''}`.trim() || user.username || `Owner #${user.userId}`
247 }
248 })
249 ownerNameMap.value = nextOwnerMap
250 } catch (error) {
251 console.error('Failed to load owner names:', error)
252 }
253 }
254
255 if (animalIds.length > 0) {
256 const entries = await Promise.all(animalIds.map(async (animalId) => {
257 try {
258 const pet = await getPet(animalId)
259 return [animalId, pet.name || `Pet #${animalId}`] as const
260 } catch {
261 return [animalId, `Pet #${animalId}`] as const
262 }
263 }))
264 animalNameMap.value = {
265 ...animalNameMap.value,
266 ...Object.fromEntries(entries),
267 }
268 }
269}
270
271function getOwnerName(ownerId: number | undefined) {
272 if (!ownerId) return 'Unknown owner'
273 return ownerNameMap.value[ownerId] || 'Loading owner...'
274}
275
276function getAnimalName(animalId: number | undefined) {
277 if (!animalId) return 'Unknown pet'
278 return animalNameMap.value[animalId] || 'Loading pet...'
279}
280
281function clearFilters() {
282 filters.value = {
283 search: '',
284 status: '',
285 minPrice: '',
286 maxPrice: '',
287 }
288 void loadPage(0)
289}
290
291async function applyFilters() {
292 await loadPage(0)
293}
294
295async function refresh() {
296 await loadPage(page.value)
297}
298
299async function goNext() {
300 if (!pageData.value?.hasNext) return
301 await loadPage(page.value + 1)
302}
303
304async function goPrevious() {
305 if (!pageData.value?.hasPrevious) return
306 await loadPage(Math.max(0, page.value - 1))
307}
308
309function formatDate(value: string | undefined) {
310 if (!value) return ''
311 const date = new Date(value)
312 if (Number.isNaN(date.getTime())) return value
313 return date.toLocaleString('en-US', {
314 year: 'numeric',
315 month: 'short',
316 day: 'numeric',
317 })
318}
319
320function formatPrice(value: unknown) {
321 if (value === null || value === undefined) return ''
322 const numberValue = Number(value)
323 if (!Number.isFinite(numberValue)) return String(value)
324 return new Intl.NumberFormat('en-US', {
325 style: 'currency',
326 currency: 'USD',
327 }).format(numberValue)
328}
329
330function statusClass(status: string) {
331 const normalized = String(status || '').toUpperCase()
332 if (normalized === 'ACTIVE') return 'bg-success'
333 if (normalized === 'SOLD') return 'bg-secondary'
334 if (normalized === 'PENDING') return 'bg-warning'
335 return 'bg-dark'
336}
337
338watch(
339 () => filters.value.status,
340 async () => {
341 if (!auth.isAuthenticated || auth.user?.userType !== 'ADMIN') return
342 await loadPage(0)
343 }
344)
345
346onMounted(async () => {
347 if (!auth.isAuthenticated) {
348 router.push('/login')
349 return
350 }
351 if (auth.user?.userType !== 'ADMIN') {
352 router.push('/')
353 return
354 }
355
356 await loadPage(0)
357})
358</script>
359
360<style scoped>
361.admin-listings {
362 min-height: 100vh;
363 background: #f7fafc;
364 padding-bottom: 48px;
365}
366
367.header-section {
368 background: white;
369 border-bottom: 1px solid #e2e8f0;
370 padding: 32px 0;
371}
372
373.page-title {
374 color: #1a202c;
375 font-size: 2rem;
376 margin: 0;
377}
378
379.page-subtitle {
380 color: #718096;
381 margin: 8px 0 0;
382}
383
384.listings-body {
385 padding-top: 28px;
386}
387
388.panel {
389 background: white;
390 border: 1px solid #e2e8f0;
391 border-radius: 8px;
392 padding: 24px;
393}
394
395.panel-header {
396 display: flex;
397 align-items: flex-start;
398 justify-content: space-between;
399 gap: 16px;
400 margin-bottom: 18px;
401}
402
403.panel-header h2 {
404 color: #1a202c;
405 margin: 0;
406}
407
408.panel-subtitle {
409 color: #718096;
410 margin: 6px 0 0;
411}
412
413.panel-actions {
414 display: flex;
415 gap: 10px;
416}
417
418.pagination-bar {
419 display: flex;
420 align-items: center;
421 justify-content: space-between;
422 gap: 12px;
423 margin: 12px 0;
424}
425
426.page-meta {
427 color: #718096;
428 font-weight: 600;
429}
430
431.filter-bar {
432 align-items: end;
433 background: #f8fafc;
434 border: 1px solid #e2e8f0;
435 border-radius: 8px;
436 display: grid;
437 gap: 12px;
438 grid-template-columns: minmax(220px, 1fr) 140px 120px 120px auto;
439 margin: 16px 0;
440 padding: 14px;
441}
442
443.filter-field {
444 display: grid;
445 gap: 6px;
446}
447
448.filter-field label {
449 color: #4a5568;
450 font-size: 0.82rem;
451 font-weight: 700;
452}
453
454.clear-filters {
455 min-width: 72px;
456}
457
458.filter-summary {
459 color: #718096;
460 font-size: 0.9rem;
461 margin: -4px 0 14px;
462}
463
464.listings-grid {
465 display: grid;
466 gap: 12px;
467 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
468}
469
470.listing-card {
471 background: #ffffff;
472 border: 1px solid #e2e8f0;
473 border-radius: 8px;
474 display: flex;
475 flex-direction: column;
476 gap: 14px;
477 min-height: 190px;
478 padding: 16px;
479}
480
481.listing-card-header {
482 align-items: flex-start;
483 display: flex;
484 gap: 12px;
485 justify-content: space-between;
486}
487
488.listing-title {
489 color: #1a202c;
490 display: block;
491 font-size: 1.05rem;
492 font-weight: 800;
493 line-height: 1.25;
494 text-decoration: none;
495}
496
497.listing-title:hover {
498 color: #f97316;
499}
500
501.listing-owner,
502.listing-date {
503 color: #718096;
504 margin: 4px 0 0;
505}
506
507.listing-description {
508 color: #4a5568;
509 display: -webkit-box;
510 line-height: 1.45;
511 margin: 0;
512 overflow: hidden;
513 -webkit-box-orient: vertical;
514 -webkit-line-clamp: 3;
515}
516
517.listing-footer {
518 align-items: center;
519 border-top: 1px solid #edf2f7;
520 display: flex;
521 justify-content: space-between;
522 margin-top: auto;
523 padding-top: 12px;
524}
525
526.listing-price {
527 color: #f97316;
528 font-size: 1.05rem;
529 font-weight: 800;
530}
531
532.badge {
533 border-radius: 4px;
534 color: white;
535 font-size: 0.78rem;
536 font-weight: 700;
537 padding: 4px 8px;
538}
539
540.bg-success {
541 background: #16a34a;
542}
543
544.bg-secondary {
545 background: #64748b;
546}
547
548.bg-warning {
549 background: #f59e0b;
550}
551
552.bg-dark {
553 background: #111827;
554}
555
556.empty-state {
557 color: #718096;
558 padding: 20px;
559 text-align: center;
560}
561
562@media (max-width: 900px) {
563 .filter-bar {
564 grid-template-columns: 1fr 1fr;
565 }
566
567 .search-field {
568 grid-column: 1 / -1;
569 }
570}
571
572@media (max-width: 560px) {
573 .filter-bar,
574 .pagination-bar,
575 .panel-header {
576 align-items: stretch;
577 grid-template-columns: 1fr;
578 }
579
580 .pagination-bar,
581 .panel-header {
582 flex-direction: column;
583 }
584}
585</style>
Note: See TracBrowser for help on using the repository browser.