220 lines
6.2 KiB
Vue
220 lines
6.2 KiB
Vue
<script setup lang="ts">
|
||
import { fullDate } from '~~/server/utils/date';
|
||
|
||
const { d, t } = useI18n();
|
||
const props = defineProps<{
|
||
answer: {
|
||
id: number;
|
||
text: string | null;
|
||
updatedAt: string;
|
||
team: { id: number; name: string };
|
||
final: boolean;
|
||
review: {
|
||
internalNote: string | null;
|
||
rankingValue: number | null;
|
||
showcase: boolean;
|
||
score: number | null;
|
||
review: string | null;
|
||
updatedAt: string;
|
||
reviewer: { name: string; id: number } | null;
|
||
} | null;
|
||
member: { name: string; id: number } | null;
|
||
picture: {
|
||
url: string;
|
||
createdAt: string;
|
||
} | null;
|
||
};
|
||
quest: {
|
||
title: string;
|
||
pictureRequired: boolean;
|
||
textRequired: boolean;
|
||
points: number;
|
||
extraPoints: number | null;
|
||
extraText: string | null;
|
||
textSolution: string | null;
|
||
};
|
||
}>();
|
||
|
||
const emits = defineEmits<{
|
||
update: [];
|
||
}>();
|
||
|
||
const formData = useCloned(
|
||
() =>
|
||
props.answer.review ?? {
|
||
internalNote: '',
|
||
rankingValue: 0,
|
||
showcase: false,
|
||
score: props.quest.points,
|
||
review: ''
|
||
}
|
||
);
|
||
|
||
const header = computed(() => {
|
||
const parts = [
|
||
props.answer.team.name,
|
||
props.answer.picture?.url ? '📸✅' : '📷❌',
|
||
props.answer.text ? '📝✅' : '📝❌',
|
||
props.answer.review ? '🔍✅' : '🔍❌',
|
||
props.answer.review?.score ?? 'No points',
|
||
d(props.answer.updatedAt, fullDate),
|
||
props.answer.review?.rankingValue,
|
||
props.answer.review?.showcase ? '★' : null
|
||
];
|
||
|
||
return parts.filter((s) => !!s).join(' – ');
|
||
});
|
||
|
||
const style = computed<[string, string]>(() => {
|
||
if (props.answer.review?.review && props.answer.review?.score !== null) {
|
||
return ['success', 'check'];
|
||
}
|
||
if (!props.answer.final) {
|
||
return ['', ''];
|
||
}
|
||
if (props.answer.review?.review || props.answer.review?.score !== null) {
|
||
return ['danger', 'priority_high'];
|
||
}
|
||
if (
|
||
(props.quest.pictureRequired ? !!props.answer.picture?.url : true) &&
|
||
(props.quest.textRequired ? !!props.answer.text : true)
|
||
) {
|
||
return ['primary', 'info'];
|
||
}
|
||
return ['', ''];
|
||
});
|
||
|
||
const pending = ref(false);
|
||
const submitError = ref('');
|
||
|
||
async function submit() {
|
||
pending.value = true;
|
||
submitError.value = '';
|
||
try {
|
||
await $fetch(`/api/admin/answer/${props.answer.id}/review`, {
|
||
method: 'POST',
|
||
body: formData.cloned.value
|
||
});
|
||
|
||
emits('update');
|
||
formData.sync();
|
||
} catch (e) {
|
||
if (e instanceof Error && 'statusCode' in e && e.statusCode === 409) {
|
||
submitError.value = 'Conflict: Please refresh';
|
||
} else {
|
||
submitError.value = parseError(e);
|
||
}
|
||
}
|
||
|
||
pending.value = false;
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<VaCollapse :header="header" :icon="style[1]" :color="style[0]">
|
||
<template #content>
|
||
<div class="flex flex-col gap-2">
|
||
<div
|
||
class="mb-2 grid grid-cols-3 justify-between gap-8 rounded-lg bg-gray-200 p-4 font-bold md:grid-cols-6"
|
||
>
|
||
<p v-if="!answer.final">NOT FINAL</p>
|
||
<p>
|
||
{{
|
||
answer.picture?.url ? '📸✅ Picture done' : '📷❌ Picture missing'
|
||
}}
|
||
</p>
|
||
<p>{{ answer.text ? '📝✅ Answer done' : '📝❌ Answer missing' }}</p>
|
||
<p>
|
||
{{ answer.review ? '🔍✅ Review done' : '🔍❌ Review missing' }}
|
||
</p>
|
||
<p v-if="answer.review?.rankingValue">
|
||
<VaIcon name="leaderboard" color="primary" />
|
||
{{ answer.review?.rankingValue }}
|
||
</p>
|
||
<p v-if="answer.review?.showcase">
|
||
<VaIcon name="auto_awesome" color="warning" /> Showcase
|
||
</p>
|
||
<p>{{ answer.review?.score ?? 'No' }} points</p>
|
||
</div>
|
||
<ClickableImage
|
||
v-if="answer.picture?.url"
|
||
:src="answer.picture.url"
|
||
:title="`${quest.title} – ${answer.team.name}`"
|
||
class="h-48"
|
||
/>
|
||
<h3 v-if="quest.textSolution">Solution:</h3>
|
||
<p v-if="quest.textSolution" class="bg-green-300 p-2 font-mono text-xl">
|
||
{{ quest.textSolution }}
|
||
</p>
|
||
<h3>Teams Answer:</h3>
|
||
<p class="bg-gray-300 p-2 font-mono text-xl">{{ answer.text }}</p>
|
||
<VaDivider />
|
||
<div
|
||
class="flex flex-col justify-between gap-4 md:flex-row md:items-center"
|
||
>
|
||
<VaInput
|
||
v-model="formData.cloned.value.score"
|
||
label="Score"
|
||
type="number"
|
||
:min="0"
|
||
messages="Including extra points"
|
||
/>
|
||
<VaInput
|
||
v-model="formData.cloned.value.rankingValue"
|
||
label="Ranking value"
|
||
type="number"
|
||
:min="0"
|
||
messages="Useful for sorting metric-based answers (e.g. distance, time), sorted descending"
|
||
/>
|
||
<VaCheckbox
|
||
v-model="formData.cloned.value.showcase"
|
||
label="Showcase"
|
||
messages="Special showcase at the end, before the final leaderboard"
|
||
/>
|
||
</div>
|
||
<VaTextarea
|
||
v-model="formData.cloned.value.review"
|
||
label="Review"
|
||
messages="Will be shown publicly!"
|
||
/>
|
||
<VaDivider />
|
||
<VaTextarea
|
||
v-model="formData.cloned.value.internalNote"
|
||
label="Internal note"
|
||
messages="Only internal, the team won't see this!"
|
||
/>
|
||
<p v-if="answer.review?.reviewer">
|
||
Reviewed by {{ answer.review?.reviewer?.name }} at
|
||
{{ d(answer.review.updatedAt, fullDate) }}
|
||
</p>
|
||
<VaAlert
|
||
v-if="submitError.length > 0"
|
||
color="danger"
|
||
class="w-full text-center"
|
||
>{{ t(submitError) }}</VaAlert
|
||
>
|
||
<VaButtonGroup :loading="pending" :disabled="pending">
|
||
<VaButton
|
||
icon="replay"
|
||
:disabled="!formData.isModified.value"
|
||
@click.prevent="formData.sync()"
|
||
>Reset all values</VaButton
|
||
>
|
||
<VaButton
|
||
:disabled="formData.isModified.value"
|
||
@click.prevent="emits('update')"
|
||
>Refresh</VaButton
|
||
>
|
||
<VaButton
|
||
:disabled="!formData.isModified.value"
|
||
@click.prevent="submit"
|
||
>Save</VaButton
|
||
>
|
||
</VaButtonGroup>
|
||
</div>
|
||
</template>
|
||
</VaCollapse>
|
||
</template>
|
||
|
||
<style scoped></style>
|