source: petify-frontend/src/views/AdminClientsView.vue@ fa32d0f

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

Petify fullstack project

  • Property mode set to 100644
File size: 10.7 KB
Line 
1<template>
2 <main class="admin-clients">
3 <section class="header-section">
4 <div class="container">
5 <h1 class="page-title">Clients</h1>
6 <p class="page-subtitle">Review client history and block accounts when review behavior requires moderation.</p>
7 </div>
8 </section>
9
10 <section class="container moderation-body">
11 <div v-if="errorMessage" class="alert alert-danger">{{ errorMessage }}</div>
12
13 <section class="panel">
14 <div class="panel-header">
15 <h2>Client Review Moderation</h2>
16 <input v-model="searchQuery" class="form-control search-input" placeholder="Search clients..." />
17 </div>
18
19 <div v-if="isClientsLoading" class="alert alert-info">Loading clients and owners...</div>
20
21 <div class="client-layout">
22 <div class="client-list">
23 <div v-if="!isClientsLoading && filteredClients.length === 0" class="empty-state compact">
24 No clients or owners found.
25 </div>
26 <button
27 v-for="client in filteredClients"
28 :key="client.userId"
29 type="button"
30 class="client-row"
31 :class="{ active: selectedClient?.userId === client.userId }"
32 @click="selectClient(client)"
33 >
34 <span>{{ client.firstName }} {{ client.lastName }}</span>
35 <small>@{{ client.username }} | {{ client.userType }}</small>
36 <small class="review-stats">
37 {{ getClientReviewCount(client.userId) }} reviews for them | {{ getClientAverageRating(client.userId).toFixed(1) }} stars
38 </small>
39 <small v-if="client.isBlocked" class="blocked">Blocked: {{ client.blockedReason || 'No reason' }}</small>
40 </button>
41 </div>
42
43 <div class="review-panel">
44 <div v-if="!selectedClient" class="empty-state">Select a client or owner to inspect reviews.</div>
45 <template v-else>
46 <div class="item-header">
47 <div>
48 <h3>{{ selectedClient.firstName }} {{ selectedClient.lastName }}</h3>
49 <p>@{{ selectedClient.username }} | {{ selectedClient.email }}</p>
50 </div>
51 <button
52 v-if="!selectedClient.isBlocked"
53 class="btn btn-sm btn-outline-danger"
54 type="button"
55 @click="blockSelectedClient"
56 >
57 Block
58 </button>
59 <button
60 v-else
61 class="btn btn-sm btn-success"
62 type="button"
63 @click="unblockSelectedClient"
64 >
65 Unblock
66 </button>
67 </div>
68
69 <div class="review-columns">
70 <section>
71 <h4>Reviews for them</h4>
72 <div v-if="reviewsForSelected.length === 0" class="empty-state compact">No reviews received.</div>
73 <article v-for="review in reviewsForSelected" :key="review.reviewId" class="review-card">
74 <div class="rating">{{ '★'.repeat(Number(review.rating || 0)) }}</div>
75 <p>{{ review.comment || 'No comment' }}</p>
76 <small>By @{{ review.reviewerUsername }} on {{ formatDate(review.createdAt) }}</small>
77 </article>
78 </section>
79
80 <section>
81 <h4>Reviews left by them</h4>
82 <div v-if="reviewsBySelected.length === 0" class="empty-state compact">No reviews left.</div>
83 <article v-for="review in reviewsBySelected" :key="review.reviewId" class="review-card">
84 <div class="rating">{{ '★'.repeat(Number(review.rating || 0)) }}</div>
85 <p>{{ review.comment || 'No comment' }}</p>
86 <small>{{ formatDate(review.createdAt) }}</small>
87 </article>
88 </section>
89 </div>
90 </template>
91 </div>
92 </div>
93 </section>
94 </section>
95 </main>
96</template>
97
98<script setup lang="ts">
99import { computed, onMounted, ref } from 'vue'
100import { useRouter } from 'vue-router'
101import { blockUser, getAllUsers } from '../api/admin'
102import { getReviewsByOwner, getReviewsLeftByUser, type Review } from '../api/reviews'
103import { useAuthStore } from '../stores/auth'
104
105const router = useRouter()
106const auth = useAuthStore()
107
108const users = ref<any[]>([])
109const selectedClient = ref<any | null>(null)
110const reviewsForSelected = ref<Review[]>([])
111const reviewsBySelected = ref<Review[]>([])
112const clientReviewStats = ref<Record<number, { count: number; average: number }>>({})
113const searchQuery = ref('')
114const errorMessage = ref('')
115const isClientsLoading = ref(false)
116
117const filteredClients = computed(() => {
118 const query = searchQuery.value.trim().toLowerCase()
119 return users.value
120 .filter((user) => ['CLIENT', 'OWNER'].includes(normalizeUserType(user)))
121 .filter((user) => {
122 if (!query) return true
123 return `${user.firstName || ''} ${user.lastName || ''} ${user.username || ''} ${user.email || ''}`.toLowerCase().includes(query)
124 })
125})
126
127async function loadUsers() {
128 if (!auth.user?.userId) return
129 isClientsLoading.value = true
130 errorMessage.value = ''
131 try {
132 const allUsers = await getAllUsers(auth.user.userId)
133 users.value = allUsers
134 .map((user) => ({
135 ...user,
136 userType: normalizeUserType(user),
137 isBlocked: normalizeBlockedStatus(user),
138 blockedReason: user.blockedReason || '',
139 }))
140 .filter((user) => ['CLIENT', 'OWNER'].includes(user.userType))
141 await loadClientReviewStats()
142 } finally {
143 isClientsLoading.value = false
144 }
145}
146
147function normalizeUserType(user: any) {
148 return String(user.userType || 'CLIENT').toUpperCase()
149}
150
151function normalizeBlockedStatus(user: any) {
152 return Boolean(user.isBlocked ?? user.blocked)
153}
154
155async function selectClient(client: any) {
156 selectedClient.value = {
157 ...client,
158 isBlocked: normalizeBlockedStatus(client),
159 blockedReason: client.blockedReason || '',
160 }
161 errorMessage.value = ''
162 try {
163 const [received, left] = await Promise.all([
164 getReviewsByOwner(client.userId),
165 getReviewsLeftByUser(client.userId),
166 ])
167 reviewsForSelected.value = received
168 reviewsBySelected.value = left
169 } catch (error) {
170 reviewsForSelected.value = []
171 reviewsBySelected.value = []
172 errorMessage.value = error instanceof Error ? error.message : 'Failed to load review history'
173 }
174}
175
176async function loadClientReviewStats() {
177 const clients = users.value.filter((user) => ['CLIENT', 'OWNER'].includes(normalizeUserType(user)))
178 const entries = await Promise.all(clients.map(async (client) => {
179 try {
180 const reviews = await getReviewsByOwner(client.userId)
181 const average = reviews.length > 0
182 ? reviews.reduce((sum, review) => sum + Number(review.rating || 0), 0) / reviews.length
183 : 0
184 return [client.userId, { count: reviews.length, average }] as const
185 } catch {
186 return [client.userId, { count: 0, average: 0 }] as const
187 }
188 }))
189
190 clientReviewStats.value = Object.fromEntries(entries)
191}
192
193function getClientReviewCount(userId: number) {
194 return clientReviewStats.value[userId]?.count || 0
195}
196
197function getClientAverageRating(userId: number) {
198 return clientReviewStats.value[userId]?.average || 0
199}
200
201async function blockSelectedClient() {
202 if (!auth.user?.userId || !selectedClient.value) return
203 const reason = window.prompt('Reason for blocking this client?', 'Review policy violation')
204 if (reason === null) return
205 await blockUser(auth.user.userId, selectedClient.value.userId, true, reason)
206 selectedClient.value = { ...selectedClient.value, isBlocked: true, blocked: true, blockedReason: reason }
207 users.value = users.value.map((user) => user.userId === selectedClient.value.userId ? selectedClient.value : user)
208}
209
210async function unblockSelectedClient() {
211 if (!auth.user?.userId || !selectedClient.value) return
212 await blockUser(auth.user.userId, selectedClient.value.userId, false)
213 selectedClient.value = { ...selectedClient.value, isBlocked: false, blocked: false, blockedReason: '' }
214 users.value = users.value.map((user) => user.userId === selectedClient.value.userId ? selectedClient.value : user)
215}
216
217function formatDate(value: string) {
218 return new Date(value).toLocaleDateString('en-US', {
219 year: 'numeric',
220 month: 'short',
221 day: 'numeric',
222 })
223}
224
225onMounted(async () => {
226 if (!auth.isAuthenticated) {
227 router.push('/login')
228 return
229 }
230 if (auth.user?.userType !== 'ADMIN') {
231 router.push('/')
232 return
233 }
234
235 try {
236 await loadUsers()
237 } catch (error) {
238 errorMessage.value = error instanceof Error ? error.message : 'Failed to load clients'
239 }
240})
241</script>
242
243<style scoped>
244.admin-clients {
245 min-height: 100vh;
246 background: #f7fafc;
247 padding-bottom: 48px;
248}
249
250.header-section {
251 background: white;
252 border-bottom: 1px solid #e2e8f0;
253 padding: 32px 0;
254}
255
256.page-title {
257 color: #1a202c;
258 font-size: 2rem;
259 margin: 0;
260}
261
262.page-subtitle {
263 color: #718096;
264 margin: 8px 0 0;
265}
266
267.moderation-body {
268 padding-top: 28px;
269}
270
271.panel,
272.review-panel {
273 background: white;
274 border: 1px solid #e2e8f0;
275 border-radius: 8px;
276}
277
278.panel {
279 padding: 24px;
280}
281
282.panel-header,
283.item-header {
284 display: flex;
285 align-items: flex-start;
286 justify-content: space-between;
287 gap: 16px;
288}
289
290.panel-header {
291 align-items: center;
292 margin-bottom: 18px;
293}
294
295.client-layout {
296 display: grid;
297 grid-template-columns: 300px minmax(0, 1fr);
298 gap: 18px;
299}
300
301.client-list {
302 display: grid;
303 gap: 8px;
304 max-height: 620px;
305 overflow: auto;
306}
307
308.client-row {
309 background: white;
310 border: 1px solid #e2e8f0;
311 border-radius: 8px;
312 color: #1a202c;
313 padding: 12px;
314 text-align: left;
315}
316
317.client-row.active {
318 border-color: #f97316;
319 box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.12);
320}
321
322.client-row span,
323.client-row small {
324 display: block;
325}
326
327.client-row span {
328 font-weight: 800;
329}
330
331.client-row small,
332.review-panel p,
333.empty-state {
334 color: #718096;
335}
336
337.client-row .blocked {
338 color: #b91c1c;
339 font-weight: 700;
340}
341
342.review-stats {
343 color: #f97316;
344 font-weight: 700;
345 margin-top: 4px;
346}
347
348.review-panel {
349 padding: 18px;
350}
351
352.review-columns {
353 display: grid;
354 grid-template-columns: repeat(2, minmax(0, 1fr));
355 gap: 16px;
356 margin-top: 18px;
357}
358
359.review-card {
360 border: 1px solid #e2e8f0;
361 border-radius: 8px;
362 margin-bottom: 10px;
363 padding: 12px;
364}
365
366.rating {
367 color: #f97316;
368 letter-spacing: 0;
369}
370
371.empty-state {
372 padding: 20px;
373 text-align: center;
374}
375
376.empty-state.compact {
377 border: 1px dashed #cbd5e0;
378 border-radius: 8px;
379 padding: 12px;
380}
381
382.search-input {
383 max-width: 240px;
384}
385
386@media (max-width: 900px) {
387 .client-layout,
388 .review-columns {
389 grid-template-columns: 1fr;
390 }
391}
392</style>
Note: See TracBrowser for help on using the repository browser.