This repository has been archived on 2024-03-28. You can view files and clone it, but cannot push or open issues or pull requests.
atsebay.t/ui/src/routes/surveys/[sid]/admin/+page.svelte

698 lines
28 KiB
Svelte

<script>
import { user } from '$lib/stores/user';
import CorrectionPieChart from '$lib/components/CorrectionPieChart.svelte';
import ListInputResponses from '$lib/components/ListInputResponses.svelte';
import QuestionForm from '$lib/components/QuestionForm.svelte';
import StartStopLiveSurvey from '$lib/components/StartStopLiveSurvey.svelte';
import SurveyAdmin from '$lib/components/SurveyAdmin.svelte';
import SurveyBadge from '$lib/components/SurveyBadge.svelte';
import { getSurvey } from '$lib/surveys';
import { getQuestion, getQuestions, Question } from '$lib/questions';
import { getUsers } from '$lib/users';
export let data;
let survey;
let req_questions;
$: {
survey = data.survey;
updateQuestions();
if (survey.direct !== null) {
wsconnect();
}
}
async function updateSurvey() {
survey = await getSurvey(survey.id);
updateQuestions();
if (survey.direct !== null) {
wsconnect();
}
}
function updateQuestions() {
req_questions = getQuestions(survey.id);
}
function deleteQuestion(question) {
edit_question = null;
question.delete();
}
let ws = null;
let ws_up = false;
let wsstats = null;
let current_question = null;
let edit_question = null;
let responses = {};
let corrected = false;
let next_corrected = false;
let with_stats = false;
let timer = 20;
let timer_end = null;
let timer_remain = 0;
let timer_cancel = null;
function updTimer() {
const now = new Date().getTime();
if (now > timer_end) {
timer_remain = 0;
clearInterval(timer_cancel);
timer_cancel = null;
} else {
timer_remain = Math.floor((timer_end - now) / 100)/10;
}
}
let users = {};
function updateUsers() {
getUsers().then((usr) => {
const tmp = { };
for (const u of usr) {
tmp[u.id.toString()] = u;
}
users = tmp;
});
}
updateUsers();
let scroll_states = { };
let scroll_mean = 0;
$: {
let mean = 0;
for (const k in scroll_states) {
mean += scroll_states[k];
}
scroll_mean = mean / Object.keys(scroll_states).length;
}
let responsesbyid = { };
$: {
const tmp = { };
for (const response in responses) {
if (!tmp[response]) tmp[response] = [];
for (const r in responses[response]) {
tmp[response].push(responses[response][r]);
}
}
responsesbyid = tmp;
}
let graph_data = {labels:[], datasets:[]};
async function reset_graph_data(questionid) {
if (questionid) {
const labels = [];
const flabels = [];
let question = null;
for (const q of await req_questions) {
if (q.id == current_question) {
question = q;
}
}
if (question) {
for (const p of await question.getProposals()) {
flabels.push(p.id.toString());
labels.push(p.label);
}
}
graph_data = {
labels,
flabels,
datasets: [
{
values: labels.map(() => 0)
}
]
}
}
if (current_question && responses[current_question] && graph_data.labels.length != 0) {
const values = graph_data.datasets[0].values.map(() => 0);
for (const u in responses[current_question]) {
const res = responses[current_question][u];
for (const r of res.split(',')) {
let idx = graph_data.flabels.indexOf(r);
values[idx] += 1;
}
}
graph_data.datasets[0].values = values;
}
}
let asks = [];
function wsconnect() {
if (ws !== null) return;
ws = new WebSocket((window.location.protocol == 'https:'?'wss://':'ws://') + window.location.host + `/api/surveys/${data.sid}/ws-admin`);
ws.addEventListener("open", () => {
ws_up = true;
ws.send('{"action":"get_responses"}');
ws.send('{"action":"get_stats"}');
ws.send('{"action":"get_asks"}');
});
ws.addEventListener("close", (e) => {
ws_up = false;
console.log('Socket is closed. Reconnect will be attempted in 1 second.');
setTimeout(function() {
ws = null;
updateSurvey();
}, 1500);
});
ws.addEventListener("error", (err) => {
ws_up = false;
console.log('Socket closed due to error.', err);
});
ws.addEventListener("message", (message) => {
const data = JSON.parse(message.data);
if (data.action && data.action == "new_question") {
current_question = data.question;
corrected = data.corrected == true;
if (timer_cancel) {
clearInterval(timer_cancel);
timer_cancel = null;
}
if (data.timer) {
timer_end = new Date().getTime() + data.timer;
timer_cancel = setInterval(updTimer, 250);
} else {
timer_end = null;
}
reset_graph_data(data.question);
} else if (data.action && data.action == "stats") {
wsstats = data.stats;
} else if (data.action && data.action == "new_response") {
if (!responses[data.question]) responses[data.question] = { };
responses[data.question][data.user] = data.value;
reset_graph_data();
} else if (data.action && data.action == "new_ask") {
asks.push({"id": data.question, "content": data.value, "userid": data.user});
asks = asks;
} else if (data.action && data.action == "myscroll" && wsstats && wsstats.users) {
scroll_states[data.user] = parseFloat(data.value);
for (const k in wsstats.users) {
if (wsstats.users[k].id == data.user) {
wsstats.users[k].myscroll = scroll_states[data.user];
}
}
} else if (data.action && data.action == "end") {
ws.close();
updateSurvey();
} else {
current_question = null;
timer_end = null;
if (timer_cancel) {
clearInterval(timer_cancel);
timer_cancel = null;
}
}
});
}
</script>
{#if $user && $user.is_admin}
<StartStopLiveSurvey
{survey}
class="ms-1 float-end"
on:update={() => updateSurvey()}
on:end={() => { if (confirm("Sûr ?")) ws.send('{"action":"end"}') }}
/>
<a href="surveys/{survey.id}/responses" class="btn btn-success ms-1 float-end" title="Voir les réponses"><i class="bi bi-files"></i></a>
{/if}
<div class="d-flex align-items-center">
<h2>
<a href="surveys/" class="text-muted" style="text-decoration: none">&lt;</a>
{survey.title}
<small class="text-muted">
Administration
</small>
{#if asks.length}
<a href="surveys/{data.sid}/admin#questions_part">
<i class="bi bi-patch-question-fill text-danger"></i>
</a>
{/if}
</h2>
{#if survey.direct !== null}
<div
class="badge rounded-pill ms-2"
class:bg-success={ws_up}
class:bg-danger={!ws_up}
>
{#if ws_up}Connecté{:else}Déconnecté{/if}
</div>
{:else}
<SurveyBadge
class="mx-2"
{survey}
/>
{/if}
</div>
{#if survey.direct === null}
<SurveyAdmin
{survey}
on:saved={updateSurvey}
/>
{:else}
{#await req_questions}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des questions &hellip;</span>
</div>
{:then questions}
<div class="card my-3">
<table class="table table-hover table-striped mb-0">
<thead>
<tr>
<th>
Question
{#if timer_end}
<div class="input-group input-group-sm float-end" style="max-width: 150px;">
<input
type="number"
class="form-control"
disabled
value={timer_remain}
>
<span class="input-group-text">s</span>
</div>
{:else}
<div class="input-group input-group-sm float-end" style="max-width: 150px;">
<input
type="number"
class="form-control"
bind:value={timer}
placeholder="Valeur du timer"
>
<span class="input-group-text">s</span>
</div>
{/if}
<button
type="button"
class="btn btn-sm btn-info ms-1"
on:click={updateQuestions}
title="Rafraîchir les questions"
>
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</th>
<th>
Réponses
</th>
<th>
<button
type="button"
class="btn btn-sm btn-primary"
disabled={!current_question || !ws_up}
on:click={() => { ws.send('{"action":"pause"}')} }
title="Passer sur une scène sans question"
>
<i class="bi bi-pause-fill"></i>
</button>
<button
type="button"
class="btn btn-sm"
class:btn-outline-success={!next_corrected}
class:btn-success={next_corrected}
on:click={() => { next_corrected = !next_corrected } }
title="La prochaine question est affichée corrigée"
>
<i class="bi bi-eye"></i>
</button>
<button
type="button"
class="btn btn-sm"
class:btn-outline-success={!with_stats}
class:btn-success={with_stats}
on:click={() => { with_stats = !with_stats } }
title="La prochaine correction sera affichée avec les statistiques"
>
<i class="bi bi-bar-chart-fill"></i>
</button>
<button
type="button"
class="btn btn-sm btn-outline-danger"
on:click={() => { fetch('api/cache', {method: 'DELETE'}) } }
title="Vider les caches"
>
<i class="bi bi-bandaid-fill"></i>
</button>
<button
type="button"
class="btn btn-sm btn-info mt-1"
on:click={() => { edit_question = new Question({ id_survey: survey.id }) } }
title="Ajouter une question"
>
<i class="bi bi-plus"></i>
</button>
</th>
</tr>
</thead>
<tbody>
{#each questions as question (question.id)}
<tr>
<td>
{#if responses[question.id]}
<a href="surveys/{data.sid}/admin#q{question.id}_res">
{question.title}
</a>
{:else}
{question.title}
{/if}
</td>
<td>
{#if responses[question.id]}
{Object.keys(responses[question.id]).length}
{:else}
0
{/if}
{#if wsstats}/ {wsstats.nb_clients}{/if}
</td>
<td>
<button
type="button"
class="btn btn-sm"
class:btn-primary={!next_corrected}
class:btn-success={next_corrected}
disabled={(question.id === current_question && next_corrected == corrected) || !ws_up}
on:click={() => { ws.send('{"action":"new_question", "corrected": ' + next_corrected + (with_stats?', "stats": {}':'') + ', "timer": 0, "question":' + question.id + '}')} }
>
<i class="bi bi-play-fill"></i>
</button>
<button
type="button"
class="btn btn-sm btn-danger"
disabled={question.id === current_question || !ws_up}
on:click={() => { ws.send('{"action":"new_question", "corrected": ' + next_corrected + (with_stats?', "stats": {}':'') + ', "timer": ' + timer * 1000 + ',"question":' + question.id + '}')} }
>
<i class="bi bi-stopwatch-fill"></i>
</button>
<a
href="/surveys/{survey.id}/responses/{question.id}"
target="_blank"
rel="noreferrer"
type="button"
class="btn btn-sm btn-success"
>
<i class="bi bi-files"></i>
</a>
<button
type="button"
class="btn btn-sm btn-info"
disabled={question.id === current_question}
on:click={() => { getQuestion(question.id).then((q) => {edit_question = q})} }
>
<i class="bi bi-pencil"></i>
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/await}
{#if edit_question !== null}
<QuestionForm
{survey}
edit
question={edit_question}
on:delete={() => deleteQuestion(edit_question)}
/>
{/if}
<hr>
<button
type="button"
class="btn btn-sm btn-info ms-1 float-end"
on:click={() => { ws.send('{"action":"get_asks", "value": ""}'); asks = []; }}
title="Rafraîchir les réponses"
>
<i class="bi bi-arrow-counterclockwise"></i>
<i class="bi bi-question-diamond"></i>
</button>
<button
type="button"
class="btn btn-sm btn-light ms-1 float-end"
on:click={() => { ws.send('{"action":"get_asks", "value": "unanswered"}'); asks = []; }}
title="Rafraîchir les réponses, en rapportant les réponses déjà répondues"
>
<i class="bi bi-arrow-counterclockwise"></i>
<i class="bi bi-question-diamond"></i>
</button>
<button
type="button"
class="btn btn-sm btn-success float-end"
title="Tout marqué comme répondu"
on:click={() => { ws.send('{"action":"mark_answered", "value": "all"}'); asks = [] }}
>
<i class="bi bi-check-all"></i>
</button>
<h3 id="questions_part">
Questions
{#if asks.length}
<small class="text-muted">
{asks.length}&nbsp;question{#if asks.length > 1}s{/if}
</small>
{/if}
</h3>
{#if asks.length}
{#each asks as ask (ask.id)}
<div class="card mb-3">
<div class="card-body">
<p class="card-text">
{ask.content}
</p>
</div>
<div class="card-footer">
<button
type="button"
class="btn btn-sm btn-success float-end"
title="Marqué comme répondu"
on:click={() => { ws.send('{"action":"mark_answered", "question": ' + ask.id + '}'); asks = asks.filter((e) => e.id != ask.id) }}
>
<i class="bi bi-check"></i>
</button>
Par
<a href="users/{ask.userid}" target="_blank" rel="noreferrer">
{#if users && users[ask.userid]}
{users[ask.userid].login}
{:else}
{ask.userid}
{/if}
</a>
</div>
</div>
{/each}
{:else}
<div class="text-center text-muted">
Pas de question pour l'instant.
</div>
{/if}
<hr>
<button
type="button"
class="btn btn-sm btn-info ms-1 float-end"
on:click={() => { ws.send('{"action":"get_responses"}') }}
title="Rafraîchir les réponses"
>
<i class="bi bi-arrow-counterclockwise"></i>
<i class="bi bi-card-checklist"></i>
</button>
<h3>
Réponses
</h3>
{#if Object.keys(responses).length}
{#each Object.keys(responses) as q, qid (qid)}
{#await req_questions then questions}
{#each questions as question}
{#if question.id == q}
<h4 id="q{question.id}_res">
{question.title}
</h4>
{#if question.kind == 'ucq'}
{#await question.getProposals()}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des propositions &hellip;</span>
</div>
{:then proposals}
{#if current_question == question.id}
<CorrectionPieChart
{question}
{proposals}
data={graph_data}
/>
{:else}
<CorrectionPieChart
{question}
/>
{/if}
<div class="card mb-4">
<table class="table table-sm table-striped table-hover mb-0">
<tbody>
{#each proposals as proposal (proposal.id)}
<tr>
<td>
{proposal.label}
</td>
<td>
{responsesbyid[q].filter((e) => e == proposal.id.toString()).length}/{responsesbyid[q].length}
</td>
<td>
{Math.trunc(responsesbyid[q].filter((e) => e == proposal.id.toString()).length / responsesbyid[q].length * 1000)/10}&nbsp;%
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/await}
{:else if question.kind == 'mcq'}
{#await question.getProposals()}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des propositions &hellip;</span>
</div>
{:then proposals}
{#if current_question == question.id}
<CorrectionPieChart
{question}
{proposals}
data={graph_data}
/>
{:else}
<CorrectionPieChart
{question}
/>
{/if}
<div class="card mb-4">
<table class="table table-sm table-striped table-hover mb-0">
<tbody>
{#each proposals as proposal (proposal.id)}
<tr>
<td>
{proposal.label}
</td>
<td>
{responsesbyid[q].filter((e) => e.indexOf(proposal.id.toString()) >= 0).length}/{responsesbyid[q].length}
</td>
<td>
{Math.trunc(responsesbyid[q].filter((e) => e.indexOf(proposal.id.toString()) >= 0).length / responsesbyid[q].length * 1000)/10}&nbsp;%
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/await}
{:else if question.kind && question.kind.startsWith('list')}
<ListInputResponses
responses={responses[q]}
{users}
/>
{:else}
<div class="card mb-4">
<ul class="list-group list-group-flush">
{#each Object.keys(responses[q]) as user, rid (rid)}
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<span>
{responses[q][user]}
</span>
<a href="users/{user}" target="_blank" rel="noreferrer" class="badge bg-dark rounded-pill">
{#if users && users[user]}
{users[user].login}
{:else}
{user}
{/if}
</a>
</li>
{/each}
</ul>
</div>
{/if}
{/if}
{/each}
{/await}
{/each}
{/if}
<hr>
<button
type="button"
class="btn btn-sm btn-info ms-1 float-end"
on:click={() => { ws.send('{"action":"get_stats"}') }}
title="Rafraîchir les stats"
>
<i class="bi bi-arrow-counterclockwise"></i>
<i class="bi bi-123"></i>
</button>
<button
type="button"
class="btn btn-sm btn-primary ms-1 float-end"
title="Rafraîchir la liste des utilisateurs"
on:click={updateUsers}
>
<i class="bi bi-arrow-clockwise"></i>
<i class="bi bi-people"></i>
</button>
<button
type="button"
class="btn btn-sm btn-warning ms-1 float-end"
on:click={() => { scroll_states = {}; ws.send('{"action":"where_are_you"}')} }
title="Rapporter l'avancement"
>
<i class="bi bi-geo-fill"></i>
</button>
<h3 id="users">
Connectés
{#if wsstats}
<small class="text-muted">{wsstats.nb_clients} utilisateurs</small>
{/if}
{#if scroll_mean}
<small
class:text-danger={scroll_mean >= 0 && scroll_mean < 0.2}
class:text-warning={scroll_mean >= 0.2 && scroll_mean < 0.6}
class:text-info={scroll_mean >= 0.6 && scroll_mean < 0.9}
class:text-success={scroll_mean >= 0.9}
>Avancement global&nbsp;: {Math.trunc(scroll_mean*10000)/100} %</small>
{/if}
</h3>
{#if wsstats && wsstats.users}
<div class="row row-cols-5 py-3">
{#each wsstats.users as user, lid (lid)}
<div class="col">
<div class="card">
<img alt="{user.login}" src="//photos.cri.epita.fr/thumb/{user.login}" class="card-img-top">
<div class="card-footer text-center text-truncate p-0">
<a href="users/{user.login}" target="_blank" rel="noreferrer">
{user.login}
</a>
</div>
{#if user.myscroll != null}
<div
class="card-footer py-0 px-1"
class:bg-danger={user.myscroll >= 0 && user.myscroll < 0.2}
class:bg-warning={user.myscroll >= 0.2 && user.myscroll < 0.6}
class:bg-info={user.myscroll >= 0.6 && user.myscroll < 0.9}
class:bg-success={user.myscroll >= 0.9}
>
Avancement&nbsp;: {Math.trunc(user.myscroll*10000)/100}&nbsp;%
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{/if}