This repository has been archived on 2025-01-01. You can view files and clone it, but cannot push or open issues or pull requests.
moodle-local_treestudyplan/amd/src/studyplan-report-components.js
2024-07-19 17:48:21 +02:00

941 lines
No EOL
39 KiB
JavaScript

/* eslint no-var: "error"*/
/* eslint no-unused-vars: warn */
/* eslint max-depth: ["error", 6] */
/* eslint promise/no-nesting: "off" */
/* eslint camelcase: "off" */
/* eslint-env es6*/
// Put this file in path/to/plugin/amd/src
import {loadStrings} from './util/string-helper';
import {call} from 'core/ajax';
import notification from 'core/notification';
import TSComponents from './treestudyplan-components';
import FitTextVue from './util/fittext-vue';
import {formatDatetime} from "./util/date-helper";
/**
* Strip tags from html
* @param {*} html
* @returns {string}
*/
function striptags(html) {
const tmp = document.createElement("DIV");
tmp.innerHTML = html;
const text = tmp.textContent || tmp.innerText;
tmp.remove();
return text;
}
/**
* Retrieve condition headers
* @param {Object} item
* @returns {Array}
*/
function conditionHeaders(item) {
const course = item.course;
const list = [];
if (course.competency) {
for (const cmp of course.competency.competencies) {
list.push({
name: (cmp.details ? (`${cmp.title} - ${cmp.details}`) : cmp.title),
tooltip: cmp.description,
});
}
} else if (course.completion) {
for (const cnd of course.completion.conditions) {
for (const itm of cnd.items) {
list.push({
name: itm.title,
tooltip: `${itm.details.type}: ${itm.details.requirement}`,
});
}
}
} else if (course.grades) {
for (const g of course.grades) {
if (g.selected) {
list.push({
name: g.name,
tooltip: `${g.typename}: ${striptags(g.name)}`,
});
}
}
}
return list;
}
/**
* Retrieve conditions
* @param {Object} item
* @returns {Array}
*/
function conditions(item) {
const course = item.course;
const list = [];
if (course.competency) {
for (const cmp of course.competency.competencies) {
list.push(cmp);
}
} else if (course.completion) {
for (const cnd of course.completion.conditions) {
for (const itm of cnd.items) {
list.push(itm);
}
}
} else if (course.grades) {
for (const g of course.grades) {
if (g.selected) {
list.push(g);
}
}
}
return list;
}
export default {
install(Vue /* ,options */) {
Vue.use(TSComponents);
Vue.use(FitTextVue);
let strings = loadStrings({
report: {
loading: "loadinghelp@core",
studyplan_past: "studyplan_past",
studyplan_present: "studyplan_present",
studyplan_future: "studyplan_future",
back: "back",
},
invalid: {
error: 'error',
},
header: {
overall: 'overall',
students: 'students@core',
firstname: 'firstname@core',
lastname: 'lastname@core',
email: 'email@core',
lastaccess: 'lastaccess@core',
},
studentresults: {
completion_incomplete: "completion_incomplete",
completion_failed: "completion_failed",
completion_pending: "completion_pending",
completion_progress: "completion_progress",
completion_completed: "completion_completed",
completion_good: "completion_good",
completion_excellent: "completion_excellent",
student_not_tracked: "student_not_tracked",
never: "never@core",
}
});
/* **********************************
* *
* Treestudyplan Viewer components *
* *
* **********************************/
Vue.component('q-studyplanreport', {
props: {
structure: {
type: Object,
},
},
data() {
return {
students: [],
studentresults: {},
studentsloading: true,
expansioninfo: {
periods: {},
lines: {},
items: {},
},
groupinfo: {},
sorting: {
header: 'lastname',
asc: true,
}
};
},
watch: {
structure: {
immediate: true,
handler(structure) {
this.loadStudents(); // Reload the student list
// (Re)build expansion info structure
let firstperiod = true;
for (const period of structure.periods) {
const pid = period.period.id;
if (!this.expansioninfo.periods[pid]) {
// Use this.$set to make sure the properties are reactive.
this.$set(
this.expansioninfo.periods,
pid,
{
expanded: ((firstperiod && period.lines.length > 0) ? true : false),
}
);
this.$set(
this.expansioninfo.lines,
period.period.id,
{}
);
}
for (const line of period.lines) {
const lid = line.line.id;
if (!this.expansioninfo.lines[lid]) {
// Use this.$set to make sure the properties are reactive.
this.$set(
this.expansioninfo.lines[pid],
lid,
{
expanded: true,
}
);
}
for (const item of line.items) {
if (!this.expansioninfo.items[item.id]) {
// Use this.$set to make sure the properties are reactive.
this.$set(
this.expansioninfo.items,
item.id,
{
expanded: false,
}
);
}
}
}
firstperiod = false;
}
}
}
},
computed: {
sortedstudents() {
// Probably could make a deep copy for purity's sake, but this works just as well.
const students = this.students;
for (const group of this.students) {
group.users.sort((a, b) => {
let d = a;
let e = b;
if (!this.sorting.asc) {
d = b;
e = a;
}
if (this.sorting.header == "lastaccess") {
const dvalue = (d[this.sorting.header] ? d[this.sorting.header] : 0);
const evalue = (e[this.sorting.header] ? e[this.sorting.header] : 0);
return dvalue - evalue;
} else {
return String(d[this.sorting.header]).localeCompare(String(e[this.sorting.header]));
}
});
}
return students;
},
resultColCount() {
let count = 0;
for (const period of this.structure.periods) {
const pid = period.period.id;
if (!this.expansioninfo.periods[pid].expanded) {
// This period is not expanded. Make it 3 units wide
count += 2;
} else {
for (const line of period.lines) {
const lid = line.line.id;
if (!this.expansioninfo.lines[pid][lid].expanded) {
count += 1;
} else {
for (const item of line.items) {
if (!this.expansioninfo.items[item.id].expanded) {
count += 1;
} else {
count += 1 + conditions(item).length;
}
}
}
}
}
}
return count;
}
},
methods: {
loadStudents() {
const self = this;
self.studentsloading = true;
call([{
methodname: 'local_treestudyplan_all_associated_grouped',
args: {'studyplan_id': this.structure.studyplan.id}
}])[0].then((response) => {
self.students = response;
for (const group of self.students) {
self.$set(
self.groupinfo,
group.id,
{
expanded: true,
}
);
for (const student of group.users) {
self.$set(
self.studentresults,
student.id,
{
loading: true,
results: [],
}
);
call([{
methodname: 'local_treestudyplan_get_report_data',
args: {
pageid: self.structure.page.id,
userid: student.id,
firstperiod: self.structure.firstperiod,
lastperiod: self.structure.lastperiod,
}
}])[0].then((response) => {
self.studentresults[student.id].loading = false;
self.studentresults[student.id].results = response;
return;
}).catch(notification.exception);
}
}
self.studentsloading = false;
return;
}).catch(notification.exception);
},
expansionChanged(parm, id, val) {
if (parm[0] == 'p') {
parm = 'periods';
} else if (parm[0] == 'l') {
parm = 'lines';
} else {
parm = 'items';
}
if (parm == 'lines') {
this.expansioninfo[parm][id[0]][id[1]].expanded = val;
} else {
this.expansioninfo[parm][id].expanded = val;
}
},
groupExpansionChanged(group) {
this.groupinfo[group.id].expanded = !this.groupinfo[group.id].expanded;
},
toggleSort(header) {
if (this.sorting.header == header) {
this.sorting.asc = !this.sorting.asc;
} else {
this.sorting.header = header;
this.sorting.asc = true;
}
}
},
template: `
<table class='q-studyplanreport'
:style="'--resultColCount: '+resultColCount+';'">
<colgroup class="q-col-studentinfo">
<col class="q-name"></col>
<col class="q-lastaccess"></col>
</colgroup>
<colgroup class="q-col-resultinfo">
<col v-for="n in resultColCount"></col>
</colgroup>
<q-header
:sorting='sorting'
:structure='structure'
:expansion='expansioninfo'
@expansion='expansionChanged'
@togglesort="toggleSort"
></q-header>
<template v-if="!studentsloading">
<template v-for="group in sortedstudents">
<q-groupheading
v-if="group.users && group.users.length > 0"
:group="group"
:expanded="groupinfo[group.id].expanded"
@togglegroup="groupExpansionChanged"
:resultcolumns="resultColCount"
:studentinfocolumns="2"
></q-groupheading>
<template v-if='groupinfo[group.id].expanded'>
<q-studentresults v-for="(student,idx) in group.users"
:key="student.id"
:student='student'
:even="(idx%2==1)"
:structure='structure'
:results='studentresults[student.id].results'
:loading='studentresults[student.id].loading'
:expansion='expansioninfo'
></q-studentresults>
</template>
</template>
</template>
<q-inforow v-else
:resultcolumns="resultColCount"
:studentinfocolumns="2"><div class="spinner-border spinner-border-sm text-info" role="status"></div></q-inforow>
</table>
`,
});
Vue.component('q-header', {
props: {
structure: {
type: Object,
},
sorting: {
type: Object,
},
expansion: {
type: Object
},
},
data() {
return {
text: strings.header,
};
},
computed: {
},
methods: {
conditions(item) {
return conditionHeaders(item);
},
colspanPeriod(period) {
const pid = period.period.id;
if (this.expansion.periods[pid].expanded) {
let sum = 0;
for (const l of period.lines) {
sum += this.colspanLine(period, l);
}
return sum;
} else {
return 2;
}
},
colspanLine(period, line) {
const pid = period.period.id;
const lid = line.line.id;
if (this.expansion.lines[pid][lid].expanded) {
let sum = 0;
for (const i of line.items) {
sum += this.colspanItem(i);
}
return sum;
} else {
return 1;
}
},
colspanItem(item) {
if (this.expansion.items[item.id].expanded) {
const cs = this.conditions(item);
return 1 + cs.length;
} else {
return 1;
}
},
togglePeriod(period, val) {
if (val === undefined) {
val = !(this.expansion.periods[period.id].expanded);
}
this.$emit('expansion', 'periods', period.id, val);
},
toggleLine(period, line, val) {
if (val === undefined) {
val = !(this.expansion.lines[period.id][line.id].expanded);
}
this.$emit('expansion', 'lines', [period.id, line.id], val);
},
toggleItem(item, val) {
if (val === undefined) {
val = !(this.expansion.items[item.id].expanded);
}
this.$emit('expansion', 'items', item.id, val);
},
toggleSort(heading) {
this.$emit('togglesort', heading);
}
},
/* TODO: https://css-tricks.com/position-sticky-and-table-headers/ */
template: `
<thead class='q-header'>
<tr> <!-- period heading -->
<th rowspan='4' colspan='2' class='q-studentinfo q-generic'><span>{{text.students}}</span></th>
<th v-for="p in structure.periods"
:class="'q-period-heading '+ ((expansion.periods[p.period.id].expanded)?'expanded':'collapsed')"
:colspan='colspanPeriod(p)'
:rowspan='(expansion.periods[p.period.id].expanded && p.lines.length > 0)?1:5'
><span class="q-wrap"><a v-if='(p.lines.length > 0)' href='#' @click.prevent="togglePeriod(p.period)"
><i v-if="expansion.periods[p.period.id].expanded"
class='q-chevron fa fa-minus'></i
><i v-else class='q-chevron fa fa-plus'></i
>&nbsp;{{ p.period.fullname}}</a
><span v-else>{{ p.period.fullname}}</span></span
></th>
</tr>
<tr> <!-- line heading -->
<template v-for="p in structure.periods">
<template v-if="expansion.periods[p.period.id].expanded">
<th v-for="l in p.lines"
:class="'q-line-heading '
+ ((expansion.lines[p.period.id][l.line.id].expanded)?'expanded':'collapsed')"
:colspan="colspanLine(p,l)"
:rowspan='(expansion.lines[p.period.id][l.line.id].expanded)?1:4'
><span class="q-wrap"><fittext vertical maxsize="18pt"
><span class='q-label'
:title="l.line.shortname"
v-html="l.line.shortname"
></span
></fittext></span
></th>
</template>
</template>
</tr>
<tr> <!-- item heading -->
<template v-for="p in structure.periods">
<template v-if="expansion.periods[p.period.id].expanded">
<template v-for="l in p.lines">
<template v-if="expansion.lines[p.period.id][l.line.id].expanded">
<th v-for="item in l.items"
:class="'q-item-heading ' + ((expansion.items[item.id].expanded)?'expanded':'collapsed')"
:colspan="colspanItem(item)"
:rowspan='(expansion.items[item.id].expanded)?1:3'
><a class="q-wrap" href='#' @click.prevent="toggleItem(item)"
><div class="q-toggle"
><i v-if="expansion.items[item.id].expanded"
class='q-chevron fa fa-minus'></i
><i v-else
class='q-chevron fa fa-plus'></i
></div><div class="q-title"
><fittext vertical maxsize="12pt" minsize="9pt"
><span class='q-label'
:title="item.course.displayname"
v-html="item.course.displayname"
></span
></fittext
></div
></a
></th>
</template>
</template>
</template>
</template>
</tr>
<tr> <!-- condition heading -->
<template v-for="p in structure.periods">
<template v-if="expansion.periods[p.period.id].expanded">
<template v-for="l in p.lines">
<template v-if="expansion.lines[p.period.id][l.line.id].expanded">
<template v-for="item in l.items">
<template v-if="expansion.items[item.id].expanded">
<th class='q-condition-heading overall' rowspan="2"
><span class='q-wrap'>{{ text.overall }}</span></th>
<th v-for="c in conditions(item)"
rowspan="2"
class='q-condition-heading'
><span class="q-wrap"
><fittext vertical maxsize="14pt"><a class='q-label q-condition-label'
:title="c.tooltip" href="#" @click.prevent
v-b-tooltip.focus
v-html="c.name"></a
></fittext></span
></th>
</template>
</template>
</template>
</template>
</template>
</template>
</tr>
<tr> <!-- student info heading -->
<th class="q-studentinfo q-name">
<fittext maxsize="12pt"
><a href="#" @click.prevent="toggleSort('firstname')">{{text.firstname}}</a
><i v-if="sorting.header=='firstname' && sorting.asc" class='fa fa-sort-asc fa-fw'></i
><i v-else-if="sorting.header=='firstname' && !sorting.asc" class='fa fa-sort-desc fa-fw'></i>
/ <a href="#" @click.prevent="toggleSort('lastname')">{{text.lastname}}</a
><i v-if="sorting.header=='lastname' && sorting.asc" class='fa fa-sort-asc fa-fw'></i
><i v-else-if="sorting.header=='lastname' && !sorting.asc" class='fa fa-sort-desc fa-fw'></i
></fittext>
</th>
<th class="q-studentinfo q-email">
<fittext maxsize="12pt"
><a href="#" @click.prevent="toggleSort('lastaccess')">{{text.lastaccess}}</a
><i v-if="sorting.header=='lastaccess' && sorting.asc" class='fa fa-sort-asc fa-fw'></i
><i v-else-if="sorting.header=='lastaccess' && !sorting.asc" class='fa fa-sort-desc fa-fw'></i
></fittext>
</th>
</tr>
</thead>
`,
});
Vue.component('q-groupheading', {
props: {
group: {
type: Object,
},
resultcolumns: {
type: Number,
'default': 1
},
studentinfocolumns: {
type: Number,
'default': 1
},
expanded: {
type: Boolean,
}
},
data() {
return {
};
},
computed: {
},
methods: {
toggleGroup() {
this.$emit('togglegroup', this.group);
}
},
template: `
<tr class='q-groupheading'>
<th :colspan="studentinfocolumns"><a href="#" @click.prevent="toggleGroup"
><i v-if="expanded" class="fa fa-minus"></i
><i v-else class="fa fa-plus"></i
>&nbsp;{{group.label}}</a></th>
<td :colspan="resultcolumns"></td>
</tr>
`,
});
Vue.component('q-inforow', {
props: {
resultcolumns: {
type: Number,
'default': 1
},
studentinfocolumns: {
type: Number,
'default': 1
},
},
data() {
return {
};
},
computed: {
},
methods: {
},
template: `
<tr class='q-inforow'>
<td :colspan="studentinfocolumns"><slot></slot></td>
<td :colspan="resultcolumns"></td>
</tr>
`,
});
Vue.component('q-studentresults', {
props: {
student: {
type: Object,
},
structure: {
type: Object,
},
results: {
type: Array,
},
loading: {
type: Boolean,
'default': false
},
expansion: {
type: Object,
},
even: {
type: Boolean,
'default': false,
}
},
data() {
return {
text: strings.studentresults,
};
},
computed: {
lastaccess() {
if (this.student.lastaccess) {
return formatDatetime(this.student.lastaccess); // Takes date in milliseconds
} else {
return this.text.never;
}
}
},
methods: {
useritems(line) {
const list = [];
for (const item of line.items) {
let newitm = item;
for (const itm of this.results) {
if (item.id == itm.id) {
newitm = itm;
break;
}
}
list.push(newitm);
}
return list;
},
conditions(item) {
return conditions(item);
},
},
/* https://css-tricks.com/position-sticky-and-table-headers/ */
template: `
<tr :class="'q-student-results userrow ' + (even?'even':'odd')">
<td class='q-studentinfo q-name'><fittext maxsize="12pt">{{student.firstname}} {{student.lastname}}</fittext></td>
<td class='q-studentinfo q-email'><fittext maxsize="12pt">{{lastaccess}}</fittext></td>
<template v-for="p in structure.periods">
<template v-if="expansion.periods[p.period.id].expanded && p.lines.length > 0">
<template v-for="l in p.lines">
<template v-if="expansion.lines[p.period.id][l.line.id].expanded">
<template v-for="item in useritems(l)">
<td class='q-result overall'
><q-courseresult
:item="item"
:student="student"
:loading="loading"
></q-courseresult
></td>
<template v-if="expansion.items[item.id].expanded">
<td v-for="(c,idx) in conditions(item)"
class='q-result'
><q-conditionresult
:item="item"
:conditionidx="idx"
:student="student"
:loading="loading"
></q-conditionresult
></td>
</template>
</template>
</template>
<td v-else class='q-result collapsed'>&nbsp;</td>
</template>
</template>
<td v-else colspan="2" class='q-result collapsed'>&nbsp;</td>
</template>
</tr>
`,
});
Vue.component('q-courseresult', {
props: {
student: {
type: Object,
},
item: {
type: Object,
},
loading: {
type: Boolean,
'default': false
},
},
data() {
return {
text: strings.studentresults,
};
},
computed: {
hasprogressinfo() {
const course = this.item.course;
if (!course.enrolled) {
return false;
} else {
return (course.completion || course.competency || course.grades) ? true : false;
}
},
completion_icon() {
const completion = this.item.completion;
switch (completion) {
default: // Case "incomplete"
return "circle-o";
case "pending":
return "question-circle";
case "failed":
return "times-circle";
case "progress":
return "exclamation-circle";
case "completed":
return "check-circle";
case "good":
return "check-circle";
case "excellent":
return "check-circle";
}
},
},
methods: {
},
template: `
<span class='q-courseresult'>
<template v-if="loading">
<div class="spinner-border spinner-border-sm text-info" role="status"></div>
</template>
<template v-else-if='!item.course.enrolled'>
<i v-b-popover.top
class="fa fa-exclamation-triangle t-not-enrolled-alert"
:title="text.student_not_tracked"></i>
</template>
<template v-else-if="item.lineenrolled" >
<i v-b-popover.top
:class="'fa fa-'+completion_icon+
' r-completion-'+item.completion"
:title="text['completion_'+item.completion]"></i>
</template>
</span>
`,
});
Vue.component('q-conditionresult', {
props: {
student: {
type: Object,
},
item: {
type: Object,
},
loading: {
type: Boolean,
'default': false
},
conditionidx: {
type: Number,
}
},
data() {
return {
text: strings.studentresults,
};
},
computed: {
conditions() {
return conditions(this.item);
},
condition() {
if (this.conditionidx >= 0 && this.conditionidx < this.conditions.length) {
return this.conditions[this.conditionidx];
} else {
return null;
}
},
hasprogressinfo() {
const course = this.item.course;
if (!course.enrolled) {
return false;
} else {
return (course.completion || course.competency || course.grades);
}
},
completion_icon() {
const completion = this.condition_completion();
switch (completion) {
default: // Case "incomplete"
return "circle-o";
case "pending":
return "question-circle";
case "failed":
return "times-circle";
case "progress":
return "exclamation-circle";
case "completed":
return "check-circle";
case "good":
return "check-circle";
case "excellent":
return "check-circle";
}
},
condition_value() {
const course = this.item.course;
if (course.competency) {
if (this.condition.grade) {
// Return grade if possible.
return this.condition.grade;
}
} else if (course.completion) {
if (this.condition.grade) {
// Return grade if possible.
return this.condition.grade;
}
} else if (course.grades) {
return this.condition.grade;
}
// Fallback to completion icon.
const icon = this.completion_icon();
return `<i class='fa fa-${icon}'></i>`;
},
condition_completion() {
// Unify completion information
const course = this.item.course;
if (course.competency) {
const competency = this.condition;
if (competency.proficient && competency.courseproficient) {
return "completed";
} else if (competency.proficient) {
return "completed";
} else if (competency.proficient === false) {
return "failed";
} else if (competency.progress) {
return "progress";
} else {
return "incomplete";
}
} else if (course.completion) {
return this.condition.status;
} else if (course.grades) {
return this.condition.completion;
} else {
return "incomplete";
}
}
},
methods: {
},
template: `
<span class='q-conditionresult'>
<fittext v-if="item.lineenrolled" maxsize="10pt" singleline dynamic>
<template v-if="loading">
<div class="spinner-border spinner-border-sm text-info" role="status"></div>
</template>
<template v-else-if='!item.course.enrolled'>
<i class="fa fa-ellipsis-h"
:title="text.student_not_tracked"></i>
</template>
<template v-else>
<span
:class="'r-completion-'+condition_completion"
:title="text['completion_'+condition_completion]"
>{{condition_value}}</span
>
</template>
</fittext>
</span>
`,
});
},
};