fix delete dialog handling; new multiselect mode is now hidden behind a "Gesamtansicht" Button Switch; in Gesamtansicht remove Kontrolldatum select & Alle anzeigen and default to all LV-Teile selected; fix multiselect darkmode theme; align all ? tooltips to be the same component and enhanced that component with delayed sticky animation, then moved that to the core so we can ALL have fancy sticky tooltips; checked over existing phrasen for spelling mistakes; bunch more css adjustments; fixed a possible bug in getAnwForUid where only uid was verified and not person_id; renamed timeline tab and enabled it for students aswell -> configurable; 🤓;

This commit is contained in:
Johann Hoffmann
2026-10-07 19:10:43 +02:00
parent b4fc8dfd45
commit 0428c27bef
5 changed files with 1248 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
/* the panel of js/directives/inViewTooltip.js. place() sets its place, max width and max height
inside the viewport, so neither a scrolling container nor a modal body clips it */
.fhc-in-view-tooltip {
--fhc-in-view-tooltip-progress: var(--fhc-blue-primary, #0d6efd);
position: fixed;
z-index: 1080;
display: flex;
flex-direction: column;
width: max-content;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.375rem;
background-color: var(--bs-body-bg, #fff);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
color: var(--bs-body-color, #212529);
font-size: 0.875rem;
font-weight: normal;
line-height: 1.5;
text-align: left;
}
/* the dark primary reaches 2:1 on the dark panel, the highlight 3.9:1 */
html.dark .fhc-in-view-tooltip {
--fhc-in-view-tooltip-progress: var(--fhc-blue-primary-highlight, #6ea8fe);
}
/* the body scrolls, so the progress border stays on the panel edge */
.fhc-in-view-tooltip-body {
min-height: 0;
overflow-y: auto;
padding: 0.75rem 1rem;
white-space: pre-line; /* the text keeps its line breaks */
}
/* the hover timer of a sticky panel: after a pause the border fills up clockwise, then the panel sticks.
The 2 px stroke covers the 1 px border and 1 px inside it */
.fhc-in-view-tooltip-progress {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.fhc-in-view-tooltip-progress rect {
fill: none;
stroke: var(--fhc-in-view-tooltip-progress);
stroke-width: 2;
stroke-dasharray: var(--fhc-in-view-tooltip-perimeter);
stroke-dashoffset: var(--fhc-in-view-tooltip-perimeter);
}
.fhc-in-view-tooltip--holding .fhc-in-view-tooltip-progress rect {
animation: fhc-in-view-tooltip-hold var(--fhc-in-view-tooltip-hold-fill) ease-in var(--fhc-in-view-tooltip-hold-delay) forwards;
}
/* a click during the hover timer fills the rest of the border quickly */
.fhc-in-view-tooltip--pinned .fhc-in-view-tooltip-progress rect {
stroke-dashoffset: 0;
transition: stroke-dashoffset 0.15s linear;
}
@keyframes fhc-in-view-tooltip-hold {
to {
stroke-dashoffset: 0;
}
}
/* the ? icon of components/InViewHelp.js takes the color of its context, e.g. white in the blue
modal header. A class like text-muted overrides it */
.fhc-in-view-help-btn,
.fhc-in-view-help-btn:hover,
.fhc-in-view-help-btn:focus-visible,
.btn.fhc-in-view-help-btn:active {
color: inherit;
}
.fhc-in-view-help-btn {
line-height: 1;
text-decoration: none;
}
+45
View File
@@ -333,6 +333,25 @@ html.dark .btn-danger:hover {
filter: brightness(90%);
}
/* the dark primevue theme draws p-button-danger (e.g. FhcAlert.confirmDelete) light pink with black text,
* use the dark red of .btn-danger */
html.dark .p-button.p-button-danger {
color: white;
background: var(--fhc-red-60);
border-color: var(--fhc-red-60);
}
html.dark .p-button.p-button-danger:enabled:hover,
html.dark .p-button.p-button-danger:enabled:active {
color: white;
background: var(--fhc-red-70);
border-color: var(--fhc-red-70);
}
html.dark .p-button.p-button-danger:enabled:focus {
box-shadow: 0 0 0 0.2rem rgba(225, 110, 120, 0.5);
}
html.dark .text-danger {
color: var(--fhc-danger) !important;
}
@@ -364,6 +383,32 @@ html.dark .btn-outline-secondary {
--bs-btn-active-border-color: var(--fhc-white-90);
}
/* the primevue multiselect renders the check icon also in unchecked boxes and hides it by color:
* white on white in the light theme, but #151515 on #20262e in the dark theme */
html.dark .p-checkbox .p-checkbox-box:not(.p-highlight) .p-checkbox-icon {
color: transparent;
}
/* FhcAlert.confirm passes bootstrap classes to the primevue buttons. The primevue theme loads after
* bootstrap, its .p-button colors replace the .btn colors: every button blue */
.p-button.btn {
color: var(--bs-btn-color);
background: var(--bs-btn-bg);
border-color: var(--bs-btn-border-color);
}
.p-button.btn:enabled:hover {
color: var(--bs-btn-hover-color);
background: var(--bs-btn-hover-bg);
border-color: var(--bs-btn-hover-border-color);
}
.p-button.btn:enabled:active {
color: var(--bs-btn-active-color);
background: var(--bs-btn-active-bg);
border-color: var(--bs-btn-active-border-color);
}
html.dark .tabulator,
html.dark .tabulator .tabulator-header,
+34
View File
@@ -0,0 +1,34 @@
import InViewTooltip from '../directives/inViewTooltip.js';
/**
* ? icon with a help text in the in-view tooltip. The panel stays inside the viewport, also at the
* screen edge, in a modal and on a phone: a plain tooltip ran out of the screen there.
*
* Help texts are long, so the panel is sticky by default: it sticks after a hover of 1.4 s or a click,
* a tap toggles it on touch. sticky: false makes it a plain hover tooltip.
*
* The view needs public/css/components/inViewTooltip.css.
*
* Usage: <in-view-help :text="$p.t('category/phrase')" button-class="fs-5 text-body-secondary"></in-view-help>
*/
export default {
name: 'InViewHelp',
directives: {
tooltip: InViewTooltip
},
props: {
text: { type: String, default: '' },
sticky: { type: Boolean, default: true },
buttonClass: { type: [String, Array, Object], default: '' }
},
template: /*html*/ `
<button
type="button"
class="btn btn-link p-0 fhc-in-view-help-btn"
:class="buttonClass"
:aria-label="$p.t('ui/hilfe')"
v-tooltip="{ value: text, sticky }"
>
<i class="fa fa-circle-question" aria-hidden="true"></i>
</button>`
};
+352
View File
@@ -0,0 +1,352 @@
/**
* v-tooltip with a panel that stays in view, also at the screen edge, in a modal and on a phone.
*
* - The panel is fixed and stays inside the viewport. It opens below the anchor, above it when
* it fits only there or that side has more space. It grows away from the nearer viewport side.
* - On an anchor wider than the panel, e.g. a long table cell, it opens at the mouse, not at the anchor edge.
* - One panel at a time, appended to the body. A modal or a scrolling container cannot clip it.
* - A mouse hover or a keyboard focus opens it. Escape, a click outside or a scroll closes it.
*
* Sticky, for several sentences or a list: a short label is read before the hover pause ends, a long text is not.
* - After HOLD_DELAY_MS + HOLD_FILL_MS of hovering the panel sticks, the border fills up in the end.
* A click on the anchor makes it stick at once. Touch has no hover: a tap toggles the sticky panel.
* - A sticky panel stays until a click outside, Escape, a scroll or an opening bootstrap modal.
*
* The view needs public/css/components/inViewTooltip.css.
*
* Usage examples:
*
* 1) Directive, registered for the app or the component:
* app.directive('tooltip', InViewTooltip) or directives: { tooltip: InViewTooltip }
* <button v-tooltip="label"> a short label, hover only
* <span v-tooltip.sticky="longText"> several sentences or a list
* v-tooltip="{ value: text, disabled: bool, sticky: bool }", the modifier .top prefers the place above
*
* 2) Without a directive, e.g. a tabulator cell. The content is a text, a DOM node or a function:
* { event: 'cellMouseEnter', handler: (e, cell) => hoverTooltip(cell.getElement(), () => buildNode(cell), { delay: 300, sticky: true, closeOnClick: true }) },
* { event: 'cellMouseLeave', handler: (e, cell) => hideTooltip(cell.getElement()) }
*
* 3) A ? icon with a help text: components/InViewHelp.js
*/
// hover time until a sticky panel sticks: a pause without motion filters out a pointer that only passes by,
// then a short fill shows the pin. A long fill would draw the eye away from the text
const HOLD_DELAY_MS = 1000
const HOLD_FILL_MS = 400
const HIDE_DELAY_MS = 150 // the pointer can cross the gap from the anchor into the panel
const WATCH_MS = 250 // an anchor can leave the page without an unmount, e.g. in a hidden keep-alive tab
const PANEL_MAX_WIDTH = 512 // px
const PANEL_MARGIN = 16 // px between the panel and the viewport edge
const PANEL_GAP = 6 // px between the anchor and the panel
const PANEL_RADIUS = 5 // px, the inner radius of the panel border. The progress border follows it
const PANEL_POINTER_INSET = 16 // px, the horizontal padding of the panel: the text starts below the mouse
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
let active = null // the open panel, only one at a time
let pending = null // a delayed show
let panelCount = 0
// the last pointer. Its type tells a tap from a mouse also for the mouse events of tabulator, a tap fires
// emulated mouse events too: pointerover fires before them. Its place puts the panel next to the mouse
const pointer = { type: 'mouse', x: null, y: null }
const trackPointer = event => {
pointer.type = event.pointerType
pointer.x = event.clientX
pointer.y = event.clientY
}
for (const name of ['pointerover', 'pointermove', 'pointerdown'])
document.addEventListener(name, trackPointer, { capture: true, passive: true })
/**
* Opens the panel for an anchor element.
*
* content: a text, a DOM node or a function that returns one of them.
* options: prefer ('bottom' | 'top'), delay (ms), sticky (the hover timer pins the panel), pinned (sticks at once),
* closeOnClick (a click on the anchor closes the panel, e.g. a table cell with a click action)
*/
export function showTooltip(anchor, content, options = {}) {
cancelPending()
if (!options.delay) return open(anchor, content, options)
pending = { anchor, timer: setTimeout(() => { pending = null; open(anchor, content, options) }, options.delay) }
}
// for mouse events without a pointer type, e.g. the cell events of tabulator
export function hoverTooltip(anchor, content, options) {
if (pointer.type === 'mouse') showTooltip(anchor, content, options)
}
// closes the panel of the anchor after HIDE_DELAY_MS, a sticky panel stays
export function hideTooltip(anchor) {
if (pending?.anchor === anchor) cancelPending()
if (active?.anchor !== anchor || active.pinned) return
clearTimeout(active.hideTimer)
active.hideTimer = setTimeout(close, HIDE_DELAY_MS)
}
// closes the open panel, with an anchor only the panel of that anchor
export function closeTooltip(anchor) {
if (!anchor || pending?.anchor === anchor) cancelPending()
if (!anchor || active?.anchor === anchor) close()
}
function isTooltipPinned(anchor) {
return active?.anchor === anchor && active.pinned
}
// new content for an open panel, an empty text closes it
function updateTooltip(anchor, content) {
if (active?.anchor !== anchor) return
if (!content) return close()
render(active.panel, content)
place()
}
function open(anchor, content, { prefer = 'bottom', sticky = false, pinned = false, closeOnClick = false }) {
if (active?.anchor === anchor) {
clearTimeout(active.hideTimer)
if (pinned) pin()
return
}
close()
sticky = sticky || pinned
const panel = document.createElement('div')
panel.id = 'fhc-in-view-tooltip-' + ++panelCount
panel.className = 'fhc-in-view-tooltip'
panel.setAttribute('role', 'tooltip')
panel.innerHTML = '<div class="fhc-in-view-tooltip-body"></div>'
if (sticky) {
panel.style.setProperty('--fhc-in-view-tooltip-hold-delay', HOLD_DELAY_MS + 'ms')
panel.style.setProperty('--fhc-in-view-tooltip-hold-fill', HOLD_FILL_MS + 'ms')
panel.insertAdjacentHTML('beforeend', '<svg class="fhc-in-view-tooltip-progress" aria-hidden="true">'
+ '<rect width="100%" height="100%" rx="' + PANEL_RADIUS + '"></rect></svg>')
}
render(panel, content)
document.body.appendChild(panel)
// the pointer at the opening, while it is over the anchor. A keyboard focus has none
const rect = anchor.getBoundingClientRect()
const overAnchor = pointer.x !== null && pointer.x >= rect.left && pointer.x <= rect.right
&& pointer.y >= rect.top && pointer.y <= rect.bottom
active = {
anchor,
panel,
prefer,
pointerX: overAnchor ? pointer.x : null,
sticky,
closeOnClick,
pinned: false,
describedBy: anchor.getAttribute('aria-describedby'),
holdTimer: null,
hideTimer: null,
watchTimer: setInterval(() => {
if (!anchor.isConnected || !anchor.getClientRects().length) close()
}, WATCH_MS)
}
place()
anchor.setAttribute('aria-describedby', panel.id)
if (pinned) {
pin()
} else if (sticky) {
panel.classList.add('fhc-in-view-tooltip--holding')
active.holdTimer = setTimeout(pin, HOLD_DELAY_MS + HOLD_FILL_MS)
}
panel.addEventListener('pointerenter', onPanelEnter)
panel.addEventListener('pointerleave', onPanelLeave)
panel.addEventListener('click', onPanelClick)
document.addEventListener('pointerdown', onPointerDown, true)
document.addEventListener('keydown', onKeyDown, true)
// an anchor often opens a modal on click, the sticky panel must not cover it
document.addEventListener('show.bs.modal', close)
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', place)
}
function pin() {
if (!active || active.pinned || !active.sticky) return
clearTimeout(active.holdTimer)
clearTimeout(active.hideTimer)
active.pinned = true
active.panel.classList.remove('fhc-in-view-tooltip--holding')
active.panel.classList.add('fhc-in-view-tooltip--pinned')
}
function close() {
if (!active) return
const { anchor, panel, describedBy } = active
clearTimeout(active.holdTimer)
clearTimeout(active.hideTimer)
clearInterval(active.watchTimer)
active = null
panel.remove()
if (describedBy) anchor.setAttribute('aria-describedby', describedBy)
else anchor.removeAttribute('aria-describedby')
document.removeEventListener('pointerdown', onPointerDown, true)
document.removeEventListener('keydown', onKeyDown, true)
document.removeEventListener('show.bs.modal', close)
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', place)
}
function cancelPending() {
if (!pending) return
clearTimeout(pending.timer)
pending = null
}
function render(panel, content) {
if (typeof content === 'function') content = content()
const body = panel.querySelector('.fhc-in-view-tooltip-body')
if (content instanceof Node) body.replaceChildren(content)
else body.textContent = content
}
function place() {
if (!active) return
const { anchor, panel, prefer } = active
const rect = anchor.getBoundingClientRect()
const viewWidth = document.documentElement.clientWidth
const viewHeight = document.documentElement.clientHeight
// measure the natural size first
panel.style.maxWidth = Math.min(PANEL_MAX_WIDTH, viewWidth - 2 * PANEL_MARGIN) + 'px'
panel.style.maxHeight = ''
panel.style.left = '0px'
panel.style.top = '0px'
panel.style.bottom = ''
const width = panel.offsetWidth
const height = panel.offsetHeight
const space = {
bottom: viewHeight - rect.bottom - PANEL_GAP - PANEL_MARGIN,
top: rect.top - PANEL_GAP - PANEL_MARGIN
}
const other = prefer === 'top' ? 'bottom' : 'top'
let side = prefer
if (space[prefer] < height) side = space[other] >= height || space[other] > space[prefer] ? other : prefer
// a panel at least as wide as the anchor starts at the anchor edge, grows away from the nearer viewport side
// and spans the whole anchor. On a wider anchor that edge can be far from the mouse: the text starts below
// the pointer, the clamp moves the panel to the left only at the right viewport edge
const { pointerX } = active
let left
if (pointerX === null || width >= rect.width) left = rect.left + rect.width / 2 < viewWidth / 2 ? rect.left : rect.right - width
else left = pointerX - PANEL_POINTER_INSET
panel.style.left = clamp(left, PANEL_MARGIN, viewWidth - PANEL_MARGIN - width) + 'px'
panel.style.maxHeight = space[side] + 'px'
if (side === 'bottom') {
panel.style.top = rect.bottom + PANEL_GAP + 'px'
} else {
panel.style.top = ''
panel.style.bottom = viewHeight - rect.top + PANEL_GAP + 'px'
}
if (!active.sticky) return
// the dash of the progress border is as long as the rounded rect along the inner edge of the 1 px border.
// Rounded up, else a gap stays at its start: clientWidth drops the fraction of the width
const box = panel.getBoundingClientRect()
const perimeter = 2 * (box.width + box.height - 4) - (8 - 2 * Math.PI) * PANEL_RADIUS
panel.style.setProperty('--fhc-in-view-tooltip-perimeter', Math.ceil(perimeter) + 1)
}
function onPanelEnter() {
cancelPending()
clearTimeout(active.hideTimer)
}
function onPanelLeave(event) {
if (event.pointerType === 'mouse') hideTooltip(active.anchor)
}
// a click into the panel closes it, a text selection keeps it
function onPanelClick() {
if (!String(window.getSelection())) close()
}
function onPointerDown(event) {
const { anchor, panel, closeOnClick } = active
if (panel.contains(event.target)) return
if (anchor.contains(event.target) && !closeOnClick) return
cancelPending()
close()
}
// Escape closes only the panel, not a modal around the anchor
function onKeyDown(event) {
if (event.key !== 'Escape') return
event.stopPropagation()
close()
}
// the panel is fixed, a scroll moves the anchor away from it. A scroll inside the panel keeps it
function onScroll(event) {
if (!active.panel.contains(event.target)) close()
}
// reads the binding: "text" or { value, disabled, sticky }, the modifiers .sticky and .top
function read(state, binding) {
const value = binding.value
const isObject = value && typeof value === 'object'
state.text = (isObject ? (value.disabled ? '' : value.value) : value) ?? ''
state.sticky = !!(binding.modifiers.sticky || isObject && value.sticky)
state.prefer = binding.modifiers.top ? 'top' : 'bottom'
}
export default {
mounted(el, binding) {
const state = el._fhcInViewTooltip = { pointerType: 'mouse' }
read(state, binding)
const show = options => {
if (state.text) showTooltip(el, state.text, { prefer: state.prefer, sticky: state.sticky, ...options })
}
state.listeners = {
pointerenter: event => { if (event.pointerType === 'mouse') show() },
pointerleave: event => { if (event.pointerType === 'mouse') hideTooltip(el) },
pointerdown: event => { state.pointerType = event.pointerType },
// a mouse click or a key pins a sticky panel at once, a tap toggles it
click: () => {
if (!state.sticky) return
if (state.pointerType !== 'mouse' && isTooltipPinned(el)) closeTooltip(el)
else show({ pinned: true })
state.pointerType = 'mouse' // a key click has no pointerdown
},
focusin: event => { if (event.target.matches(':focus-visible')) show() },
focusout: () => hideTooltip(el)
}
// the click captures: it pins the panel before the click action of the anchor runs, so a modal
// that this action opens closes the panel again. The pointer events must not capture the children
for (const [name, listener] of Object.entries(state.listeners)) el.addEventListener(name, listener, name === 'click')
},
updated(el, binding) {
const state = el._fhcInViewTooltip
const text = state.text
read(state, binding)
if (state.text !== text) updateTooltip(el, state.text)
},
beforeUnmount(el) {
const state = el._fhcInViewTooltip
closeTooltip(el)
for (const [name, listener] of Object.entries(state.listeners)) el.removeEventListener(name, listener, name === 'click')
delete el._fhcInViewTooltip
}
}
+736
View File
@@ -62578,6 +62578,742 @@ I have been informed that I am under no obligation to consent to the transmissio
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'anwGesamtansicht',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Gesamtansicht',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Combined view',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'tooltipAnwGesamtansicht',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Mehrere LV-Teile gemeinsam anzeigen und exportieren',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Show and export several teaching units together',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'anwGesamtansichtInfoV3',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Read only: Nur Vergleichen von LV-Teilen dieser LV oder Export möglich. Anwesenheitskontrollen sind in diesem Modus nicht möglich.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Read only: compare the teaching units of this course or export data. Attendance checks are not possible in this mode.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'anwGesamtansichtBeenden',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Zurück zu einem LV-Teil',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Back to one teaching unit',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'anwWeitereLvTeileAnzeigen',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Weitere LV-Teile dieser LV anzeigen',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Show other teaching units of this course',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'anwWeitereLvTeileAusblenden',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Weitere LV-Teile ausblenden',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Hide other teaching units',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'anwLvTeileAnzahl',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => '{0} von {1} LV-Teilen',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => '{0} of {1} teaching units',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'jetztStartenV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Jetzt starten',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Start now',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'errorDegeneratingQRCodeV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Fehler beim Löschen des alten QR-Codes.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Error deleting the old QR code.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'errorSavingNewQRCodeV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Fehler beim Anlegen eines neuen QR-Codes.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Error saving a new QR code.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'entschuldigungenAnzahlV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => '{0} von {1} Entschuldigungen',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => '{0} of {1} excuse notes',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'tooltipCsvV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Tabelle als CSV-Datei exportieren',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Export table as CSV file',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'errorStartAnwKontrolleV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Fehler beim Versuch, eine neue Anwesenheitskontrolle zu starten.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Error when trying to start a new attendance check.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'tooltipAnwTimelineV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Die "Digitale Anwesenheiten Timeline" ist ein experimentelles Feature, welches eine zeitliche Visualisierung von Entschuldigungszeiträumen und Kontrollzeiträumen im Kontext eines Studenten anzeigt.
Wenn Sie auf eine aufgelistete Entschuldigung oder Kontrolle klicken, versucht die Timeline diese zu finden, falls diese im angegebenen Zeitraum liegt, welcher standardmäßig das gesamte aktuelle Jahr beträgt. Sie können diese Grenzen manuell anpassen.
Wenn man innerhalb der Timeline scrollt, ändert sich der visuelle Abstand zwischen einzelnen Tagen.
Bei Klick auf eine gerenderte Datumsreichweite in der Timeline listet sich oben rechts eine Liste von zugehörigen Attributen auf, welche Administratoren und Entwicklern in Fehlerfällen helfen sollen, den Zustand der digitalen Anwesenheiten im Kontext eines Studierenden nachzuvollziehen.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The "Digital Attendance Timeline" is an experimental feature that displays a temporal visualization of excuse periods and check-in periods in the context of a student.
When you click on a listed excuse or check-in period, the timeline attempts to find it if it falls within the specified period, which by default is the entire current year. You can adjust these boundaries manually.
Scrolling within the timeline changes the visual spacing between individual days.
Clicking on a rendered date range in the timeline displays a list of associated attributes in the upper right corner. These attributes are intended to help administrators and developers understand the status of digital attendance in the context of a student in case of errors.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'errorLoadingAnwesenheitenV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Fehler beim Laden der Anwesenheitsdaten.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Error loading the attendance data.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'errorCalculatingAnwQuotaV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Fehler bei der Berechnung der Anwesenheitsquote.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Error calculating the attendance rate.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'anwNotizUpdatedV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Notiz wurde erfolgreich bearbeitet.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Note edited successfully.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'errorPersonStudentIDMismatchV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Die Person passt nicht zur Studierenden-ID.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The person does not match the student ID.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'warningEnterVonZeitV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Bitte Von-Zeit eingeben.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Please enter the start time.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'warningEnterBisZeitV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Bitte Bis-Zeit eingeben.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Please enter the end time.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'allowedV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Aktuell prüfbar',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Currently checkable',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'kontrolleDatumOutOfRangeV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Das Datum liegt außerhalb des erlaubten Kontrollzeitraums!',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The date is outside the permitted period for attendance checks!',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'noLePreselectTermineTooOldV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Keine intelligente LV-Teil-Vorauswahl möglich, da sämtliche Termine außerhalb des erlaubten Kontrollzeitraums liegen. Bitte wählen Sie den gewünschten LV-Teil sorgfältig aus.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'No automatic pre-selection of the teaching unit is possible, because all dates are outside the permitted period for attendance checks. Please select the teaching unit carefully.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'anwesendV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'anwesend',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'present',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'tooltipDeleteKontrolleV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Anwesenheitskontrolle löschen. Die dazugehörigen Statuseinträge der Studierenden werden auch gelöscht!',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Delete the attendance check. The related status entries of the students are also deleted!',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'successDeleteKontrolleEntryAnDatumV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Anwesenheitskontrolle für LV-Teil {le_id} am {day}.{month}.{year} erfolgreich gelöscht.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Attendance check for teaching unit {le_id} on {day}.{month}.{year} deleted successfully.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'zeitNichtAusStundenplanBeginnV3',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Unterrichtsbeginn entspricht keinem Unterrichtstermin laut LV-Plan.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The lesson start time does not match a lesson in the timetable.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'zeitNichtAusStundenplanEndeV3',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Unterrichtsende entspricht keinem Unterrichtstermin laut LV-Plan.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The lesson end time does not match a lesson in the timetable.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'tooltipStudentEntschuldigungV2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Sie können eine Entschuldigung für einen Zeitraum von bis zu {0} Tagen in die Vergangenheit und bis zum Ende des aktuellen Semesters hochladen. Die erlaubten Dateitypen für das Dokument sind .pdf, .png und .jpg.
Sobald Sie eine Entschuldigung hochladen, wird die zugehörige Studiengangsassistenz informiert und Ihr Anliegen überprüft. Solange Ihre Entschuldigung noch keinen akzeptierten oder abgelehnten Status erhalten hat, steht es Ihnen frei, diese inklusive Datei zu löschen. Sobald sie entweder akzeptiert oder abgelehnt wurde, können Sie den Eintrag nicht mehr löschen.
Bei einer akzeptierten Entschuldigung werden sämtliche digitalen Anwesenheiten in diesem Zeitraum als positiv gewertet. Eine abgelehnte Entschuldigung hat keine Auswirkungen auf Ihre Anwesenheitsquote.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'You can upload an excuse note for a period of up to {0} days in the past and up to the end of the current semester. The permitted file types for the document are .pdf, .png and .jpg.
As soon as you upload an excuse note, the responsible study program assistant is informed and reviews your request. As long as your excuse note is not accepted or declined, you can delete it together with its file. After it is accepted or declined, you can no longer delete the entry.
If an excuse note is accepted, all digital attendances in this period count as positive. A declined excuse note has no effect on your attendance rate.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'tooltipLektorStartKontrolleV5',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Um eine Anwesenheitskontrolle für Ihre ausgewählte Unterrichtsgruppe durchzuführen, wählen Sie bitte einen Termin aus dem LV-Plan aus oder geben händisch die gewünschte Gültigkeitsdauer der Kontrolle an.
Die Gültigkeitsdauer bestimmt die Gewichtung der Anwesenheit in Relation zum Gesamtausmaß. Sie können diese aber nach eigenem Ermessen anpassen und müssen sich nicht streng an die Termine im LV-Plan halten.
Sie können pro Tag und Unterrichtsgruppe mehrere Anwesenheitskontrollen durchführen, solange sich ihre Zeiten nicht überschneiden und jede Kontrolle mindestens {0} Minuten dauert.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'To carry out an attendance check for your selected class group, please select a date from the timetable or manually enter the desired validity period of the check.
The validity period determines the weighting of the attendance in relation to the total extent. You can adjust it at your own discretion and do not have to stick strictly to the dates in the timetable.
You can carry out more than one attendance check per day and class group, as long as their times do not overlap and each check lasts at least {0} minutes.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'tooltipAssistenzV3',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Im Entschuldigungsmanagement können Sie als Studiengangsassistenz beziehungsweise als Administrator die von Studenten hochgeladenen Entschuldigungsdokumente überprüfen und den Status entsprechend vergeben.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'As a study program assistant or administrator, you can check the excuse note documents that students upload in the excuse note management and set their status.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'anwTimelineV3',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Timeline',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Timeline',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'tooltipAnwTimelineV3',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Die Timeline zeigt die Entschuldigungen und Anwesenheitskontrollen auf einer Zeitachse. Die Farbe eines Balkens zeigt den Status.
Zoomen Sie mit dem Mausrad oder mit den Lupen-Schaltflächen. Ziehen Sie die Zeitachse, um den Zeitraum zu verschieben. Die Datumsfelder setzen den Zeitraum direkt. Der Streifen unter der Zeitachse zeigt die Lage des Zeitraums in allen Daten.
Klicken Sie auf einen Balken oder einen Listeneintrag. Die Fußzeile zeigt dann die Details des Eintrags. Ein Klick auf einen Listeneintrag verschiebt die Zeitachse zum Eintrag.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The timeline shows the excuse notes and attendance checks on a time axis. The color of a bar shows the status.
Use the mouse wheel or the magnifier buttons to zoom. Drag the time axis to move the period. The date fields set the period directly. The strip below the time axis shows the position of the period in all data.
Click a bar or a list entry. The footer then shows the details of the entry. A click on a list entry also moves the time axis to the entry.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'errorNoEntschuldigungFound',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Die Entschuldigung existiert nicht.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The excuse note does not exist.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'oldEntsWithoutDocumentFound',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Entschuldigungen zur Befreiung der Anwesenheitspflicht: Dokumente fehlen',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Excuse notes for digital attendances: documents are missing',
'description' => '',
'insertvon' => 'system'
)
)
),
// ### End Anwesenheiten Sprint 25.2
// generic tabulator phrases START
array(