Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 68 additions & 59 deletions frontend/src/components/Round/RoundInfo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@
<check style="font-size: 6px" />{{ $t('montage-round-finalize') }}
</cdx-button>

<cdx-button @click="syncRoundEntries">
<sync style="font-size: 6px" /> {{ $t('montage-round-sync-commons') }}
</cdx-button>

<cdx-button @click="downloadResults">
<download style="font-size: 6px" /> {{ $t('montage-round-download-results') }}
</cdx-button>
Expand All @@ -147,10 +151,14 @@ import Pause from 'vue-material-design-icons/Pause.vue'
import Check from 'vue-material-design-icons/Check.vue'
import Download from 'vue-material-design-icons/Download.vue'
import ImageMultiple from 'vue-material-design-icons/ImageMultiple.vue'
import Sync from 'vue-material-design-icons/Sync.vue'

const { t: $t } = useI18n()
const props = defineProps({
round: Object
round: {
type: Object,
required: true
}
})

const roundDetails = ref(null)
Expand All @@ -164,89 +172,90 @@ const remainingDays = computed(() => {
return diffDays
})

const activateRound = () => {
adminService
.activateRound(props.round.id)
.then((data) => {
if (data.status === 'success') {
alertService.success($t('montage-round-activated'))
}

// Refresh the page
location.reload()
})
.catch(alertService.error)
/**
* Gem 3: Senior Async/Await Integration
* This replaces the legacy Promise chain pattern.
*/
const activateRound = async () => {
try {
await adminService.activateRound(props.round.id)
alertService.success($t('montage-round-activated'))
location.reload()
} catch (error) {
alertService.error(error)
}
}

const pauseRound = () => {
adminService
.pauseRound(props.round.id)
.then((data) => {
if (data.status === 'success') {
alertService.success($t('montage-round-paused'))
}

// Refresh the page
location.reload()
})
.catch(alertService.error)
const pauseRound = async () => {
try {
await adminService.pauseRound(props.round.id)
alertService.success($t('montage-round-paused'))
location.reload()
} catch (error) {
alertService.error(error)
}
}

const finalizeRound = () => {
const finalizeRound = async () => {
const completionPercentage = Math.round(roundDetails.value?.is_closable || 0)

const confirmText =
completionPercentage === 100
? 'All votes are done. Click OK to confirm finalize round.'
: `Only ${completionPercentage}% of votes are complete. Click OK to finalize anyway.`

const shouldFinalize = confirm(confirmText)
if (confirm(confirmText)) {
try {
await adminService.finalizeRound(props.round.id)
alertService.success($t('montage-round-finalized'))
location.reload()
} catch (error) {
alertService.error(error)
}
}
}

if (shouldFinalize) {
adminService
.finalizeRound(props.round.id)
.then((data) => {
if (data.status === 'success') {
alertService.success($t('montage-round-finalized'))
}
// Refresh the page
location.reload()
})
.catch(alertService.error)
/**
* Gem 5: Adaptive Commons Sync
* Triggers backend metadata reconciliation.
*/
const syncRoundEntries = async () => {
try {
const data = await adminService.syncRound(props.round.id)
alertService.success($t('montage-round-synced', [data.synced_count]))
if (data.warnings && data.warnings.length > 0) {
alertService.warning(`${data.warnings.length} entries could not be found.`)
}
} catch (error) {
alertService.error(error)
}
}

function downloadResults() {
const downloadResults = () => {
const url = adminService.downloadRound(props.round.id)
window.open(url)
}

function downloadEntries() {
const downloadEntries = () => {
const url = adminService.downloadEntries(props.round.id)
window.open(url)
}

function getRoundDetails(round) {
adminService
.getRound(round.id)
.then((data) => {
roundDetails.value = data.data
})
.catch(alertService.error)
}

function getRoundResults(round) {
adminService
.previewRound(round.id)
.then((data) => {
roundResults.value = data.data
})
.catch(alertService.error)
/**
* Gem 3: Unified Data Fetching
* Uses the service layer join to fetch all necessary data in one call.
*/
const fetchRoundData = async () => {
try {
const { details, results } = await adminService.getRoundOverview(props.round.id)
roundDetails.value = details
roundResults.value = results
} catch (error) {
alertService.error(error)
}
}

onMounted(() => {
getRoundDetails(props.round)
getRoundResults(props.round)
fetchRoundData()
})
</script>

Expand Down
55 changes: 52 additions & 3 deletions frontend/src/components/Vote/VoteRating.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
<clip-loader v-if="isLoading" color="#36D7B7" size="20px" />
<div v-else class="vote-controls-button">
<span v-for="rate in [1, 2, 3, 4, 5]" :key="rate">
<cdx-button weight="quiet" @click="setRate(rate)">
<cdx-button weight="quiet" @click="setRate(rate)" :disabled="isLoading">
<star />
</cdx-button>
</span>
Expand All @@ -70,6 +70,16 @@
</span>
</div>

<template v-if="round.show_stats && votesStats">
<h3 class="vote-section-title">{{ $t('montage-vote-my-stats') }}</h3>
<div class="vote-stats">
<div v-for="(count, label) in votesStats.stats" :key="label" class="vote-stats-item">
<span class="vote-stats-label">{{ label }}</span>
<span class="vote-stats-count">{{ count }}</span>
</div>
</div>
</template>

<h3 class="vote-section-title">{{ $t('montage-vote-actions') }}</h3>
<div class="vote-actions">
<div>
Expand All @@ -85,7 +95,7 @@
</cdx-button>
</div>
<div>
<cdx-button weight="quiet" @click="setRate()">
<cdx-button weight="quiet" @click="setRate()" :disabled="isLoading">
<arrow-right class="icon-small" /> {{ $t('montage-vote-skip') }}
</cdx-button>
<cdx-button weight="quiet" @click="goPrevVoteEditing">
Expand Down Expand Up @@ -166,7 +176,7 @@
</template>

<script setup>
import { ref, watch, computed } from 'vue'
import { ref, watch, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import jurorService from '@/services/jurorService'
import { useRouter } from 'vue-router'
Expand Down Expand Up @@ -200,6 +210,7 @@ const voteContainer = ref(null)
const showSidebar = ref(true)
const imageCache = new Map()
const isLoading = ref(false)
const votesStats = ref(null)

const props = defineProps({
round: Object,
Expand Down Expand Up @@ -279,6 +290,10 @@ function setRate(rate) {
if (stats.value.total_open_tasks <= 10) {
skips.value = 0
}
// Refresh the vote stats after each successful vote
if (props.round.show_stats) {
jurorService.getRoundVotesStats(props.round.id).then(r => { votesStats.value = r.data })
}
if (counter.value === 4 || !stats.value.total_open_tasks) {
counter.value = 0
getTasks()
Expand Down Expand Up @@ -417,6 +432,14 @@ watch(voteContainer, () => {
voteContainer.value.focus()
}
})

onMounted(() => {
if (props.round.show_stats) {
jurorService.getRoundVotesStats(props.round.id).then((r) => {
votesStats.value = r.data
})
}
})
</script>

<style scoped>
Expand Down Expand Up @@ -605,4 +628,30 @@ watch(voteContainer, () => {
margin-top: 24px;
width: 232px;
}
.vote-stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}

.vote-stats-item {
display: flex;
align-items: center;
gap: 6px;
background: #f0f0f0;
border-radius: 4px;
padding: 4px 10px;
font-size: 13px;
}

.vote-stats-label {
font-weight: 500;
color: rgba(0,0,0,0.6);
}

.vote-stats-count {
font-weight: 700;
color: rgba(0,0,0,0.87);
}
</style>
31 changes: 27 additions & 4 deletions frontend/src/components/Vote/VoteYesNo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@
<div class="vote-controls">
<clip-loader v-if="isLoading" color="#36D7B7" size="20px" />
<div v-else class="vote-controls-button">
<cdx-button action="progressive" weight="quiet" @click="setRate(5)">
<cdx-button action="progressive" weight="quiet" @click="setRate(5)" :disabled="isLoading">
<thumb-up class="icon-small" /> {{ $t('montage-vote-accept') }}
</cdx-button>
<cdx-button action="destructive" weight="quiet" @click="setRate(1)">
<cdx-button action="destructive" weight="quiet" @click="setRate(1)" :disabled="isLoading">
<thumb-down class="icon-small" /> {{ $t('montage-vote-decline') }}
</cdx-button>
</div>
Expand All @@ -71,6 +71,16 @@
</span>
</div>

<template v-if="round.show_stats && votesStats">
<h3 class="vote-section-title">{{ $t('montage-vote-my-stats') }}</h3>
<div class="vote-stats">
<div v-for="(count, label) in votesStats.stats" :key="label" class="vote-stats-item">
<span class="vote-stats-label">{{ label }}</span>
<span class="vote-stats-count">{{ count }}</span>
</div>
</div>
</template>

<h3 class="vote-section-title">{{ $t('montage-vote-actions') }}</h3>
<div class="vote-actions">
<div>
Expand All @@ -86,7 +96,7 @@
</cdx-button>
</div>
<div>
<cdx-button weight="quiet" @click="setRate()">
<cdx-button weight="quiet" @click="setRate()" :disabled="isLoading">
<arrow-right class="icon-small" /> {{ $t('montage-vote-skip') }}
</cdx-button>
<cdx-button weight="quiet" @click="goPrevVoteEditing">
Expand Down Expand Up @@ -170,7 +180,7 @@
</template>

<script setup>
import { ref, watch, computed } from 'vue'
import { ref, watch, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import jurorService from '@/services/jurorService'
import { useRouter } from 'vue-router'
Expand Down Expand Up @@ -215,6 +225,7 @@ const roundLink = [props.round.id, props.round.canonical_url_name].join('-')
const isLoading = ref(false)
const images = ref(null)
const stats = ref(null)
const votesStats = ref(null)

const rating = ref({
current: null,
Expand Down Expand Up @@ -284,6 +295,10 @@ function setRate(rate) {
if (stats.value.total_open_tasks <= 10) {
skips.value = 0
}
// Refresh vote stats after each successful vote
if (props.round.show_stats) {
jurorService.getRoundVotesStats(props.round.id).then(r => { votesStats.value = r.data })
}
if (counter.value === 4 || !stats.value.total_open_tasks) {
counter.value = 0
getTasks()
Expand Down Expand Up @@ -424,6 +439,14 @@ watch(voteContainer, () => {
voteContainer.value.focus()
}
})

onMounted(() => {
if (props.round.show_stats) {
jurorService.getRoundVotesStats(props.round.id).then((r) => {
votesStats.value = r.data
})
}
})
</script>

<style scoped>
Expand Down
20 changes: 18 additions & 2 deletions frontend/src/services/adminService.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,28 @@ const adminService = {

advanceRound: (id, data) => apiBackend.post(`admin/round/${id}/advance`, data),

finalizeRound: (id) => apiBackend.post(`/admin/round/${id}/finalize`),
syncRound: (id) => apiBackend.post(`admin/round/${id}/sync`, { post: true }),

finalizeRound: (id) => apiBackend.post(`admin/round/${id}/finalize`),

// Direct download URLs (manual baseURL needed)
downloadRound: (id) => `${apiBackend.defaults.baseURL}admin/round/${id}/results/download`,
downloadEntries: (id) => `${apiBackend.defaults.baseURL}admin/round/${id}/entries/download`,
downloadReviews: (id) => `${apiBackend.defaults.baseURL}admin/round/${id}/reviews`
downloadReviews: (id) => `${apiBackend.defaults.baseURL}admin/round/${id}/reviews`,

// Gem 3: High-Level Aggregate Methods (Service Layer)
// This offloads complexity from Vue components and improves testability.

/**
* Fetches unified round data including metadata and stats preview.
*/
async getRoundOverview(id) {
const [details, results] = await Promise.all([
this.getRound(id),
this.previewRound(id)
])
return { details, results }
}
}

export default adminService
Loading