getDateStyleClass evaluation also with precise luxon calculation on all pages; qgate12 status col, next/prev termin col on betreuer page; table persistence on mitarbeiter page; same rowheight on betreuer table as in assistenz to achieve similar UX;

This commit is contained in:
Johann Hoffmann
2026-02-18 13:00:19 +01:00
parent 328affa35c
commit 6f28696556
4 changed files with 320 additions and 98 deletions
@@ -270,48 +270,13 @@ export const AbgabeMitarbeiterDetail = {
window.open(FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + url)
// this.$api.call(ApiAbgabe.getStudentProjektarbeitAbgabeFile(termin.paabgabe_id, this.projektarbeit.student_uid))
},
convertDateToIsoString(date) {
// 1. Check if it is a Date object AND if the date value is valid (not 'Invalid Date')
if (param instanceof Date && !isNaN(param.getTime())) {
const year = param.getFullYear();
// getMonth() is 0-indexed, so we add 1.
const month = param.getMonth() + 1;
const day = param.getDate();
// Helper to pad single-digit numbers with a leading zero
const pad = (num) => String(num).padStart(2, '0');
// Return the formatted string: YYYY-MM-DD
return `${year}-${pad(month)}-${pad(day)}`;
}
// If it's not a valid Date, return the original parameter
return param;
},
dateDiffInDays(datumParam){
let datum = datumParam
if(datumParam instanceof Date && !isNaN(datum.getTime()))
{
const year = datumParam.getFullYear();
const month = datumParam.getMonth() + 1; // getMonth() is 0-indexed
const day = datumParam.getDate();
const pad = (num) => String(num).padStart(2, '0');
datum = `${year}-${pad(month)}-${pad(day)}`
}
const dateToday = luxon.DateTime.now().startOf('day');
const dateDatum = luxon.DateTime.fromISO(datum).startOf('day');
const duration = dateDatum.diff(dateToday, 'days');
return duration.values.days;
},
getDateStyleClass(termin) {
const datum = new Date(termin.datum)
const abgabedatum = new Date(termin.abgabedatum)
termin.diffindays = this.dateDiffInDays(termin.datum)
const isLate = termin.abgabedatum && abgabedatum > datum;
const zone = 'Europe/Vienna';
const today = luxon.DateTime.now().setZone(zone);
const datum = luxon.DateTime.fromISO(termin.datum, { zone }).endOf('day');
const abgabedatum = termin.abgabedatum ? luxon.DateTime.fromISO(termin.abgabedatum, { zone }) : null;
termin.diffindays = datum.diff(today, 'days').days;
const isLate = abgabedatum && abgabedatum > datum;
// GRADE STATUS
if (termin.note) {
@@ -396,6 +361,7 @@ export const AbgabeMitarbeiterDetail = {
}
},
formatDate(dateParam) {
// unsafe for datepickers, dont use there
const date = new Date(dateParam)
// handle missing leading 0
const padZero = (num) => String(num).padStart(2, '0');
@@ -476,7 +442,6 @@ export const AbgabeMitarbeiterDetail = {
termin.kurzbz = ''
}
}
},
computed: {
getAllowedToCreateNewTermin() {
@@ -626,7 +591,6 @@ export const AbgabeMitarbeiterDetail = {
return ''
},
getProjektarbeitStudent(){
if(this.projektarbeit?.student) return this.$capitalize(this.$p.t('person/student')) + ': ' + this.projektarbeit.student
return ''
@@ -671,7 +635,6 @@ export const AbgabeMitarbeiterDetail = {
this.form.schlagwoerter_en = newVal.schlagwoerter_en ?? ''
this.form.kontrollschlagwoerter = newVal.kontrollschlagwoerter ?? ''
this.form.seitenanzahl = newVal.seitenanzahl ?? 1
},
},
created() {
@@ -9,15 +9,6 @@ import AbgabeterminStatusLegende from "./StatusLegende.js";
import FhcOverlay from "../../Overlay/FhcOverlay.js";
import { splitMailsHelper } from "../../../helpers/EmailHelpers.js"
// spoofed date testing
// const todayISO = '2025-08-08'
// const today = new Date(todayISO)
// const now = luxon.DateTime.fromISO(todayISO)
// prod code
const today = new Date()
const now = luxon.DateTime.now()
export const AbgabetoolAssistenz = {
name: "AbgabetoolAssistenz",
components: {
@@ -386,6 +377,8 @@ export const AbgabetoolAssistenz = {
},
checkAbgabetermineProjektarbeit(projekt) {
const now = luxon.DateTime.now()
// calculate Abgabetermin time diff to now and assign last and next to projekt
projekt.abgabetermine.forEach(termin => {
@@ -393,7 +386,7 @@ export const AbgabetoolAssistenz = {
// while already looping through each termin, calculate datestyle beforehand
termin.dateStyle = this.getDateStyleClass(termin)
const date = luxon.DateTime.fromISO(termin.datum)
const date = luxon.DateTime.fromISO(termin.datum).endOf('day')
termin.diffMs = date.toMillis() - now.toMillis(); // positive = future, negative = past
if (termin.diffMs < 0) {
@@ -770,22 +763,13 @@ export const AbgabetoolAssistenz = {
this.$refs.modalContainerAbgabeDetail.show()
},
dateDiffInDays(datum){
const dateToday = luxon.DateTime.now().startOf('day');
const dateDatum = luxon.DateTime.fromISO(datum).startOf('day');
const duration = dateDatum.diff(dateToday, 'days');
return duration.values.days;
},
getDateStyleClass(termin) {
const datum = new Date(termin.datum)
const abgabedatum = new Date(termin.abgabedatum)
termin.diffindays = this.dateDiffInDays(termin.datum)
const isLate = termin.abgabedatum && abgabedatum > datum;
const zone = 'Europe/Vienna';
const today = luxon.DateTime.now().setZone(zone);
const datum = luxon.DateTime.fromISO(termin.datum, { zone }).endOf('day');
const abgabedatum = termin.abgabedatum ? luxon.DateTime.fromISO(termin.abgabedatum, { zone }) : null;
termin.diffindays = datum.diff(today, 'days').days;
const isLate = abgabedatum && abgabedatum > datum;
// GRADE STATUS
if (termin.note) {
@@ -79,7 +79,7 @@ export const AbgabetoolMitarbeiter = {
placeholder: Vue.computed(() => this.$p.t('global/noDataAvailable')),
selectable: true,
selectableCheck: this.selectionCheck,
rowHeight: 80,
rowHeight: 40,
columns: [
{
formatter: function (cell, formatterParams, onRendered) {
@@ -144,9 +144,14 @@ export const AbgabetoolMitarbeiter = {
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4stg'))), field: 'stg', headerFilter: true, formatter: this.centeredTextFormatter, widthGrow: 1},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4sem'))), field: 'studiensemester_kurzbz', headerFilter: true, formatter: this.centeredTextFormatter, widthGrow: 1},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4titel'))), field: 'titel', headerFilter: true, formatter: this.centeredTextFormatter, maxWidth: 500, widthGrow: 8},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4betreuerart'))), field: 'betreuerart_beschreibung',formatter: this.centeredTextFormatter, widthGrow: 1}
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4betreuerart'))), field: 'betreuerart_beschreibung',formatter: this.centeredTextFormatter, widthGrow: 1},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4prevAbgabetermin'))), headerFilter: true, field: 'prevTermin', formatter: this.abgabterminFormatter, widthGrow: 1, width: 220, tooltip: false},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4nextAbgabetermin'))), headerFilter: true, field: 'nextTermin', formatter: this.abgabterminFormatter, widthGrow: 1, width: 220, tooltip: false},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4qgate1Status'))), headerFilter: true, field: 'qgate1Status', formatter: this.centeredTextFormatter, widthGrow: 1, width: 220, tooltip: false},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4qgate2Status'))), headerFilter: true, field: 'qgate2Status', formatter: this.centeredTextFormatter, widthGrow: 1, width: 220, tooltip: false}
],
persistence: false,
persistenceID: 'abgabeTableBetreuer2026-02-18'
},
abgabeTableEventHandlers: [{
event: "tableBuilt",
@@ -182,6 +187,290 @@ export const AbgabetoolMitarbeiter = {
]};
},
methods: {
loadState() {
return JSON.parse(localStorage.getItem(this.abgabeTableOptions.persistenceID) || "null");
},
saveState(table) {
// avoid storing state after first restore part happened
if(!this.stateRestored) return
const rawLayout = table.getColumnLayout();
const state = {
columns: rawLayout.map(col => ({
field: col.field,
visible: col.visible,
width: col.width,
})),
sort: table.getSorters().map(s => ({
field: s.field,
dir: s.dir,
})),
filters: table.getFilters(),
headerFilters: table.getHeaderFilters()
};
localStorage.setItem(this.abgabeTableOptions.persistenceID, JSON.stringify(state));
},
handleTableBuilt() {
const table = this.$refs.abgabeTable.tabulator
this.tableBuiltResolve()
table.on("columnMoved", () => {
this.saveState(table);
});
table.on("columnResized", () => {
this.saveState(table);
});
table.on("columnVisibilityChanged", () => {
this.saveState(table);
});
table.on("filterChanged", () => {
this.saveState(table);
});
table.on("headerFilterChanged", () => {
this.saveState(table);
});
table.on("dataSorted", () => {
this.saveState(table);
});
table.on("columnSorted", () => {
this.saveState(table);
});
table.on("sortersChanged", () => {
this.saveState(table);
});
const saved = this.loadState();
table.on("renderComplete", () => {
if(!this.stateRestored) {
if (saved?.columns && !this.colLayoutRestored) {
const layout = saved.columns.map(col => ({
field: col.field,
width: col.width,
visible: col.visible,
// add more if needed, but keep it simple
}));
table.setColumnLayout(layout);
this.colLayoutRestored = true;
}
if (saved?.filters && !this.filtersRestored) {
this.filtersRestored = true // instantly avoid retriggers
table.setFilter(saved.filters);
}
if (saved?.headerFilters && !this.headerFiltersRestored) {
this.headerFiltersRestored = true // instantly avoid retriggers
for (let hf of saved.headerFilters) {
table.setHeaderFilterValue(hf.field, hf.value);
}
}
if (saved?.sort?.length && !this.sortRestored) {
this.sortRestored = true;
setTimeout(() => {
const sortList = saved.sort.map(s => {
const col = table.columnManager.findColumn(s.field);
if (!col) {
return null;
}
return { column: col, dir: s.dir };
}).filter(Boolean);
table.setSort(sortList);
}, 100);
}
this.stateRestored = true
}
});
},
checkQualityGateStatus(projekt) {
// TODO: might refine the representation of these states and maybe refactor code a little
const qgate1Termine = []
const qgate2Termine = []
projekt.qgate1Status = this.$p.t('abgabetool/c4keinTerminVorhanden')// 'Kein Termin vorhanden'
projekt.qgate1StatusRank = 0
projekt.qgate2Status = this.$p.t('abgabetool/c4keinTerminVorhanden')
projekt.qgate2StatusRank = 0
projekt.abgabetermine.forEach(termin => {
if(termin.paabgabetyp_kurzbz == 'qualgate1') qgate1Termine.push(termin)
if(termin.paabgabetyp_kurzbz == 'qualgate2') qgate2Termine.push(termin)
})
// calculate qgateStatusRank and display the highest order status rank of all quality gate termine until one
// counts as passed, which is just a positive note no matter if anything has been uploaded
// reuse luxon calculated diffMs (termin.datum in relation to today) from previous datestyle check
qgate1Termine.forEach(qgate => {
if(qgate.note != null && projekt.qgate1StatusRank <= 5) {
const noteOpt = this.notenOptions.find(opt => opt.note == qgate.note)
if(noteOpt.positiv) {
projekt.qgate1Status = this.$p.t('abgabetool/c4positivBenotet')
projekt.qgate1StatusRank = 5
} else {
projekt.qgate1Status = this.$p.t('abgabetool/c4negativBenotet')
projekt.qgate1StatusRank = 4
}
} else if (qgate.note == null && projekt.qgate1StatusRank <= 3) {
projekt.qgate1Status = this.$p.t('abgabetool/c4notYetGraded')
projekt.qgate1StatusRank = 3
} else if(qgate.upload_allowed == true && qgate.abgabedatum == null && projekt.qgate1StatusRank <= 2) {
projekt.qgate1Status = this.$p.t('abgabetool/c4notSubmitted')
projekt.qgate1StatusRank = 2
} else if (qgate.upload_allowed == false && qgate.diffMs <= 0 && projekt.qgate1StatusRank <= 1) {
projekt.qgate1Status = this.$p.t('abgabetool/c4notHappenedYet')
projekt.qgate1StatusRank = 1
}
})
qgate2Termine.forEach(qgate => {
if(qgate.note != null && projekt.qgate1StatusRank <= 5) {
const noteOpt = this.notenOptions.find(opt => opt.note == qgate.note)
if(noteOpt.positiv) {
projekt.qgate2Status = this.$p.t('abgabetool/c4positivBenotet')
projekt.qgate2StatusRank = 5
} else {
projekt.qgate2Status = this.$p.t('abgabetool/c4negativBenotet')
projekt.qgate2StatusRank = 4
}
} else if (qgate.note == null && projekt.qgate2StatusRank <= 3) {
projekt.qgate2Status = this.$p.t('abgabetool/c4notYetGraded')
projekt.qgate2StatusRank = 3
} else if(qgate.upload_allowed == true && qgate.abgabedatum == null && projekt.qgate2StatusRank <= 2) {
projekt.qgate2Status = this.$p.t('abgabetool/c4notSubmitted')
projekt.qgate2StatusRank = 2
} else if (qgate.upload_allowed == false && qgate.diffMs <= 0 && projekt.qgate2StatusRank <= 1) {
projekt.qgate2Status = this.$p.t('abgabetool/c4notHappenedYet')
projekt.qgate2StatusRank = 1
}
})
},
checkAbgabetermineProjektarbeit(projekt) {
const now = luxon.DateTime.now()
// calculate Abgabetermin time diff to now and assign last and next to projekt
projekt.abgabetermine.forEach(termin => {
// while already looping through each termin, calculate datestyle beforehand
termin.dateStyle = this.getDateStyleClass(termin)
const date = luxon.DateTime.fromISO(termin.datum).endOf('day')
termin.diffMs = date.toMillis() - now.toMillis(); // positive = future, negative = past
if (termin.diffMs < 0) {
if (!projekt.prevTermin ||
termin.diffMs > projekt.prevTermin.diffMs // larger (less negative) = closer to now
) {
projekt.prevTermin = termin;
}
} else if (termin.diffMs > 0) {
if (!projekt.nextTermin ||
termin.diffMs < projekt.nextTermin.diffMs // smaller positive = closer to now
) {
projekt.nextTermin = termin;
}
}
})
// seperate check for quality gates
this.checkQualityGateStatus(projekt)
},
getDateStyleClass(termin) {
const zone = 'Europe/Vienna';
const today = luxon.DateTime.now().setZone(zone);
const datum = luxon.DateTime.fromISO(termin.datum, { zone }).endOf('day');
const abgabedatum = termin.abgabedatum ? luxon.DateTime.fromISO(termin.abgabedatum, { zone }) : null;
termin.diffindays = datum.diff(today, 'days').days;
const isLate = abgabedatum && abgabedatum > datum;
// GRADE STATUS
if (termin.note) {
if (termin.note.positiv) return 'bestanden';
return 'nichtbestanden';
}
// ACTION REQUIRED FOR GRADE
if (termin.bezeichnung?.benotbar && datum < today) {
return 'beurteilungerforderlich';
}
// SUBMISSION STATUS
if (termin.upload_allowed) {
if (termin.abgabedatum) {
return isLate ? 'verspaetet' : 'abgegeben';
}
// no submission yet
if (datum < today) return 'verpasst';
if (termin.diffindays <= 12) return 'abzugeben';
return 'standard';
}
// GENERIC STATUS
return datum < today ? 'verpasst' : 'standard';
},
abgabterminFormatter(cell) {
const val = cell.getValue()
if(val) {
let icon = ''
switch(val.dateStyle) {
case 'verspaetet':
icon = '<i class="fa-solid fa-triangle-exclamation"></i>'
break
case 'verpasst':
icon = '<i class="fa-solid fa-calendar-xmark"></i>'
break
case 'abzugeben':
icon = '<i class="fa-solid fa-hourglass-half"></i>'
break
case 'standard':
icon = '<i class="fa-solid fa-clock"></i>'
break
case 'abgegeben':
icon = '<i class="fa-solid fa-paperclip"></i>'
break
case 'beurteilungerfolderlich':
icon = '<i class="fa-solid fa-list-check"></i>'
break
case 'bestanden':
icon = '<i class="fa-solid fa-check"></i>'
break
case 'nichtbestanden':
icon = '<i class="fa-solid fa-circle-exclamation"></i>'
break
}
const bezeichnung = val.bezeichnung?.bezeichnung ?? val.bezeichnung
return '<div style="display: flex; height: 100%">' +
'<div class=' + val.dateStyle + "-header" + ' style="width:48px; height: 100%; padding: 0px; display: flex; align-items: center; justify-content: center;">' +
icon +
'</div>' +
'<div style="margin-left: 4px;">' +
'<p style="max-width: 100%; word-wrap: break-word; white-space: normal;">'+bezeichnung+' - '+ this.formatDate(val.datum)+'</p>' +
'</div>'+
'</div>'
} else {
return ''
}
},
selectHandler(e, cell) {
const row = cell.getRow();
@@ -294,9 +583,9 @@ export const AbgabetoolMitarbeiter = {
return str
},
isPastDate(date) {
const deadline = luxon.DateTime.fromISO(date, { zone: 'Europe/Berlin' });
const nowInBerlin = luxon.DateTime.now().setZone('Europe/Berlin');
return nowInBerlin > deadline;
const deadline = luxon.DateTime.fromISO(date, { zone: 'Europe/Vienna' }).endOf('day');
const nowInVienna = luxon.DateTime.now().setZone('Europe/Vienna');
return nowInVienna > deadline;
},
setDetailComponent(details){
this.loading=true
@@ -381,11 +670,13 @@ export const AbgabetoolMitarbeiter = {
return (projekt.typ + projekt.kurzbz)?.toUpperCase()
},
setupData(data){
this.projektarbeiten = data[0]
this.domain = data[1]
this.tableData = data[0]?.retval?.map(projekt => {
this.checkAbgabetermineProjektarbeit(projekt)
projekt.selectable = projekt.betreuerart_kurzbz !== 'Zweitbegutachter'
return {
@@ -601,6 +892,7 @@ export const AbgabetoolMitarbeiter = {
@click:new=openAddSeriesModal
:tabulator-options="abgabeTableOptions"
:tabulator-events="abgabeTableEventHandlers"
@tableBuilt="handleTableBuilt"
tableOnly
:sideMenu="false"
:useSelectionSpan="false"
@@ -48,30 +48,13 @@ export const AbgabetoolStudent = {
};
},
methods: {
dateDiffInDays(datumParam) {
let datum = datumParam
if(datumParam instanceof Date && !isNaN(datum.getTime()))
{
const year = datumParam.getFullYear();
const month = datumParam.getMonth() + 1; // getMonth() is 0-indexed
const day = datumParam.getDate();
const pad = (num) => String(num).padStart(2, '0');
datum = `${year}-${pad(month)}-${pad(day)}`
}
const dateToday = luxon.DateTime.now().startOf('day');
const dateDatum = luxon.DateTime.fromISO(datum).startOf('day');
const duration = dateDatum.diff(dateToday, 'days');
return duration.values.days;
},
getDateStyleClass(termin) {
const datum = new Date(termin.datum)
const abgabedatum = new Date(termin.abgabedatum)
termin.diffindays = this.dateDiffInDays(termin.datum)
const isLate = termin.abgabedatum && abgabedatum > datum;
const zone = 'Europe/Vienna';
const today = luxon.DateTime.now().setZone(zone);
const datum = luxon.DateTime.fromISO(termin.datum, { zone }).endOf('day');
const abgabedatum = termin.abgabedatum ? luxon.DateTime.fromISO(termin.abgabedatum, { zone }) : null;
termin.diffindays = datum.diff(today, 'days').days;
const isLate = abgabedatum && abgabedatum > datum;
// GRADE STATUS
if (termin.note) {