source: petify-frontend/src/views/AdminModerationView.vue

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

Petify fullstack project

  • Property mode set to 100644
File size: 10.7 KB
Line 
1<template>
2 <main class="admin-moderation">
3 <section class="header-section">
4 <div class="container">
5 <h1 class="page-title">Clinics</h1>
6 <p class="page-subtitle">Review clinic applications and inspect clinic reviews.</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 <div class="moderation-grid">
14 <section class="panel">
15 <div class="panel-header">
16 <h2>Clinic Applications</h2>
17 <button class="btn btn-sm btn-outline-secondary" type="button" @click="loadClinicApplications">Refresh</button>
18 </div>
19
20 <div v-if="clinicApplications.length === 0" class="empty-state">No clinic applications found.</div>
21 <article v-for="application in clinicApplications" :key="application.applicationId" class="application-card">
22 <div class="item-header">
23 <div>
24 <h3>{{ application.name }}</h3>
25 <p>{{ application.city }} - {{ application.address }}</p>
26 <p>{{ application.email || 'No email' }} {{ application.phone ? `| ${application.phone}` : '' }}</p>
27 </div>
28 <span class="badge" :class="statusClass(application.status)">{{ application.status }}</span>
29 </div>
30 <p class="meta">Submitted {{ formatDate(application.submittedAt) }}</p>
31 <p v-if="application.denialReason" class="denial">Denied: {{ application.denialReason }}</p>
32 <div class="actions">
33 <button
34 class="btn btn-sm btn-success"
35 type="button"
36 :disabled="application.status === 'APPROVED'"
37 @click="approveApplication(application)"
38 >
39 Approve
40 </button>
41 <button
42 class="btn btn-sm btn-outline-danger"
43 type="button"
44 :disabled="application.status === 'DENIED'"
45 @click="denyApplication(application)"
46 >
47 Deny
48 </button>
49 </div>
50 </article>
51 </section>
52
53 <section class="panel">
54 <div class="panel-header">
55 <h2>Clinic Reviews</h2>
56 <button class="btn btn-sm btn-outline-secondary" type="button" @click="loadClinics">Refresh</button>
57 </div>
58
59 <div class="client-layout">
60 <div class="client-list">
61 <button
62 v-for="clinic in clinics"
63 :key="clinic.clinicId"
64 type="button"
65 class="client-row"
66 :class="{ active: selectedClinic?.clinicId === clinic.clinicId }"
67 @click="selectClinic(clinic)"
68 >
69 <span>{{ clinic.name }}</span>
70 <small>{{ clinic.city }} | {{ clinic.address }}</small>
71 <small class="clinic-review-stats">
72 {{ getClinicReviewCount(clinic.clinicId) }} reviews | {{ getClinicAverageRating(clinic.clinicId).toFixed(1) }} stars
73 </small>
74 </button>
75 </div>
76
77 <div class="review-panel">
78 <div v-if="!selectedClinic" class="empty-state">Select a clinic to inspect its reviews.</div>
79 <template v-else>
80 <div class="item-header">
81 <div>
82 <h3>{{ selectedClinic.name }}</h3>
83 <p>{{ selectedClinic.city }} | {{ selectedClinic.address }}</p>
84 </div>
85 </div>
86
87 <div v-if="clinicReviews.length === 0" class="empty-state compact">No reviews for this clinic.</div>
88 <article v-for="review in clinicReviews" :key="review.reviewId" class="review-card">
89 <div class="item-header">
90 <div>
91 <strong>{{ review.reviewerName }}</strong>
92 <small>@{{ review.reviewerUsername }}</small>
93 </div>
94 <div class="rating">{{ '★'.repeat(Number(review.rating || 0)) }}</div>
95 </div>
96 <p>{{ review.comment || 'No comment' }}</p>
97 <small>{{ formatDate(review.createdAt) }}</small>
98 </article>
99 </template>
100 </div>
101 </div>
102 </section>
103
104 </div>
105 </section>
106 </main>
107</template>
108
109<script setup lang="ts">
110import { onMounted, ref } from 'vue'
111import { useRouter } from 'vue-router'
112import {
113 approveClinicApplication,
114 denyClinicApplication,
115 getClinicApplications,
116 getClinicsAdmin,
117} from '../api/admin'
118import { getReviewsByClinic, type Review } from '../api/reviews'
119import { useAuthStore } from '../stores/auth'
120
121const router = useRouter()
122const auth = useAuthStore()
123
124const clinicApplications = ref<any[]>([])
125const clinics = ref<any[]>([])
126const selectedClinic = ref<any | null>(null)
127const clinicReviews = ref<Review[]>([])
128const clinicReviewStats = ref<Record<number, { count: number; average: number }>>({})
129const errorMessage = ref('')
130
131
132async function loadClinicApplications() {
133 if (!auth.user?.userId) return
134 clinicApplications.value = await getClinicApplications(auth.user.userId)
135}
136
137
138async function loadClinics() {
139 if (!auth.user?.userId) return
140 clinics.value = await getClinicsAdmin(auth.user.userId)
141 await loadClinicReviewStats()
142}
143
144async function selectClinic(clinic: any) {
145 selectedClinic.value = clinic
146 errorMessage.value = ''
147 try {
148 clinicReviews.value = await getReviewsByClinic(clinic.clinicId)
149 } catch (error) {
150 clinicReviews.value = []
151 errorMessage.value = error instanceof Error ? error.message : 'Failed to load clinic reviews'
152 }
153}
154
155async function loadClinicReviewStats() {
156 const entries = await Promise.all(clinics.value.map(async (clinic) => {
157 try {
158 const reviews = await getReviewsByClinic(clinic.clinicId)
159 const average = reviews.length > 0
160 ? reviews.reduce((sum, review) => sum + Number(review.rating || 0), 0) / reviews.length
161 : 0
162 return [clinic.clinicId, { count: reviews.length, average }] as const
163 } catch {
164 return [clinic.clinicId, { count: 0, average: 0 }] as const
165 }
166 }))
167
168 clinicReviewStats.value = Object.fromEntries(entries)
169}
170
171function getClinicReviewCount(clinicId: number) {
172 return clinicReviewStats.value[clinicId]?.count || 0
173}
174
175function getClinicAverageRating(clinicId: number) {
176 return clinicReviewStats.value[clinicId]?.average || 0
177}
178
179
180async function approveApplication(application: any) {
181 if (!auth.user?.userId) return
182 try {
183 const updated = await approveClinicApplication(auth.user.userId, application.applicationId)
184 replaceApplication(updated)
185 await loadClinics()
186 } catch (error) {
187 errorMessage.value = error instanceof Error ? error.message : 'Failed to approve application'
188 }
189}
190
191async function denyApplication(application: any) {
192 if (!auth.user?.userId) return
193 const reason = window.prompt('Reason for denial?', application.denialReason || '')
194 if (reason === null) return
195
196 try {
197 const updated = await denyClinicApplication(auth.user.userId, application.applicationId, reason)
198 replaceApplication(updated)
199 } catch (error) {
200 errorMessage.value = error instanceof Error ? error.message : 'Failed to deny application'
201 }
202}
203
204
205function replaceApplication(updated: any) {
206 clinicApplications.value = clinicApplications.value.map((application) =>
207 application.applicationId === updated.applicationId ? updated : application
208 )
209}
210
211function statusClass(status: string) {
212 if (status === 'APPROVED') return 'bg-success'
213 if (status === 'DENIED') return 'bg-danger'
214 return 'bg-warning'
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 Promise.all([loadClinicApplications(), loadClinics()])
237 } catch (error) {
238 errorMessage.value = error instanceof Error ? error.message : 'Failed to load admin moderation data'
239 }
240})
241</script>
242
243<style scoped>
244.admin-moderation {
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.moderation-grid {
272 display: grid;
273 gap: 24px;
274}
275
276.panel,
277.application-card,
278.review-panel {
279 background: white;
280 border: 1px solid #e2e8f0;
281 border-radius: 8px;
282}
283
284.panel {
285 padding: 24px;
286}
287
288.panel-header,
289.item-header,
290.actions {
291 display: flex;
292 align-items: flex-start;
293 justify-content: space-between;
294 gap: 16px;
295}
296
297.panel-header {
298 align-items: center;
299 margin-bottom: 18px;
300}
301
302.panel h2,
303.application-card h3,
304.review-panel h3 {
305 color: #1a202c;
306 margin: 0;
307}
308
309.application-card {
310 padding: 16px;
311 margin-bottom: 12px;
312}
313
314.application-card p,
315.review-panel p,
316.meta {
317 color: #718096;
318 margin: 4px 0;
319}
320
321.denial {
322 color: #b91c1c;
323 font-weight: 600;
324}
325
326.badge {
327 border-radius: 4px;
328 color: white;
329 font-size: 0.78rem;
330 font-weight: 700;
331 padding: 4px 8px;
332}
333
334.bg-success { background: #16a34a; }
335.bg-danger { background: #dc2626; }
336.bg-warning { background: #f59e0b; }
337
338.client-layout {
339 display: grid;
340 grid-template-columns: 300px minmax(0, 1fr);
341 gap: 18px;
342}
343
344.client-list {
345 display: grid;
346 gap: 8px;
347 max-height: 620px;
348 overflow: auto;
349}
350
351.client-row {
352 background: white;
353 border: 1px solid #e2e8f0;
354 border-radius: 8px;
355 color: #1a202c;
356 padding: 12px;
357 text-align: left;
358}
359
360.client-row.active {
361 border-color: #f97316;
362 box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.12);
363}
364
365.client-row span,
366.client-row small {
367 display: block;
368}
369
370.client-row span {
371 font-weight: 800;
372}
373
374.client-row small {
375 color: #718096;
376}
377
378.client-row .blocked {
379 color: #b91c1c;
380 font-weight: 700;
381}
382
383.clinic-review-stats {
384 color: #f97316;
385 font-weight: 700;
386 margin-top: 4px;
387}
388
389.review-panel {
390 padding: 18px;
391}
392
393.review-columns {
394 display: grid;
395 grid-template-columns: repeat(2, minmax(0, 1fr));
396 gap: 16px;
397 margin-top: 18px;
398}
399
400.review-card {
401 border: 1px solid #e2e8f0;
402 border-radius: 8px;
403 margin-bottom: 10px;
404 padding: 12px;
405}
406
407.rating {
408 color: #f97316;
409 letter-spacing: 0;
410}
411
412.empty-state {
413 color: #718096;
414 padding: 20px;
415 text-align: center;
416}
417
418.empty-state.compact {
419 border: 1px dashed #cbd5e0;
420 border-radius: 8px;
421 padding: 12px;
422}
423
424.search-input {
425 max-width: 240px;
426}
427
428@media (max-width: 900px) {
429 .client-layout,
430 .review-columns {
431 grid-template-columns: 1fr;
432 }
433}
434</style>
Note: See TracBrowser for help on using the repository browser.