'+(m.count>40?'Große Charge: die Codes gehen automatisch als Datei-Anhang mit.':'Die Codes stehen im Text.')+' Der Text ist auch für den Selbstversand kopierbar.
'
+ + '
'+(m.count>40?'Große Charge: die Codes gehen automatisch als .txt- und .csv-Anhang mit.':'Die Codes stehen im Text.')+' Der Text ist auch für den Selbstversand kopierbar.
'
+ '
'
+ '';
document.getElementById('iss-result').innerHTML = h;
diff --git a/App/php/api/licenses.php b/App/php/api/licenses.php
index 2dc6000..b25531a 100644
--- a/App/php/api/licenses.php
+++ b/App/php/api/licenses.php
@@ -215,19 +215,19 @@ if ($method === 'GET') {
if (!$class) Response::error('Klasse nicht gefunden', 404);
$licenses = $db->fetchAll(
- 'SELECT l.id, l.code, l.school_year, l.student_id, l.redeemed_at,
+ 'SELECT l.id, l.code, l.school_year, l.student_id, l.redeemed_at, l.valid_until,
s.username as student_name, s.display_name as student_display
FROM licenses l
JOIN students s ON s.id = l.student_id
WHERE s.class_id = ?
- ORDER BY s.username',
+ ORDER BY l.redeemed_at, l.id',
[$classId]
);
// Auch freie Lizenzen des Lehrers (noch nicht zugewiesen), storniert ausgeschlossen.
// Reihenfolge = id (= Reihenfolge, in der die Codes ausgegeben/reinkopiert wurden).
$freeLicenses = $db->fetchAll(
- 'SELECT id, code, school_year FROM licenses WHERE teacher_id = ? AND student_id IS NULL AND canceled_at IS NULL ORDER BY id',
+ 'SELECT id, code, school_year, redeemed_at, valid_until FROM licenses WHERE teacher_id = ? AND student_id IS NULL AND canceled_at IS NULL ORDER BY id',
[$teacherId]
);
@@ -484,7 +484,14 @@ if ($method === 'POST') {
$html = Mailer::renderPlainBody($textBody);
$attach = [];
if (count($codeList) > 40) {
- $attach[] = ['content' => implode("\r\n", $codeList) . "\r\n", 'name' => 'lizenzcodes.txt', 'type' => 'text/plain'];
+ // Reine Code-Liste (eine pro Zeile) als .txt UND .csv anhängen.
+ // Dateiname wie beim Download: _geograsim_.
+ $codesTxt = implode("\r\n", $codeList) . "\r\n";
+ $bRow = $db->fetchOne("SELECT order_no FROM license_batches WHERE id = ?", [$bid]);
+ $ord = ($bRow && !empty($bRow['order_no'])) ? preg_replace('/[^A-Za-z0-9_\-]/', '_', $bRow['order_no']) : date('Y-m-d-H-i');
+ $fbase = $ord . '_geograsim_' . $bid;
+ $attach[] = ['content' => $codesTxt, 'name' => $fbase . '.txt', 'type' => 'text/plain'];
+ $attach[] = ['content' => $codesTxt, 'name' => $fbase . '.csv', 'type' => 'text/csv'];
}
$db->execute(
"INSERT INTO license_mails (batch_id, to_email, subject, body_text, body_html, provider, status, created_by) VALUES (?,?,?,?,?,?,?,?)",
diff --git a/App/teacher.html b/App/teacher.html
index 999dbbd..f7e0399 100644
--- a/App/teacher.html
+++ b/App/teacher.html
@@ -3872,11 +3872,12 @@ async function loadStudents() {
if (students.error) students = [];
// Lizenzen laden für Anzeige
var licData = await api('licenses?class_id=' + currentClassId);
- var assignedMap = {};
- if (licData.assigned) licData.assigned.forEach(function(l) { assignedMap[l.student_id] = l.code; });
+ var assignedMap = {}, unlic = {};
+ if (licData.assigned) licData.assigned.forEach(function(l) { assignedMap[l.student_id] = l; });
+ if (licData.unlicensed) licData.unlicensed.forEach(function(u) { unlic[u.id] = 1; });
- // Count without license
- var withoutLicense = students.filter(function(s) { return !assignedMap[s.id]; }).length;
+ // Count ohne GÜLTIGE Lizenz
+ var withoutLicense = students.filter(function(s) { return unlic[s.id]; }).length;
var html = '
Klasse — ' + esc(currentClass?.name || '') + '
';
html += '
';
@@ -3903,8 +3904,11 @@ async function loadStudents() {
}
var name = s.display_name || s.username;
if (s.first_name || s.last_name) name = [s.first_name, s.last_name].filter(Boolean).join(' ');
- var lic = assignedMap[s.id];
- var licHtml = lic ? ''+lic+'' : '—';
+ var la = assignedMap[s.id];
+ var hasValid = !unlic[s.id];
+ var licHtml = hasValid
+ ? '✓'
+ : 'fehlt';
var easyChecked = s.easy_language ? ' checked' : '';
var pinHtml;
if (s.password_plaintext) {
@@ -3915,7 +3919,7 @@ async function loadStudents() {
pinHtml = 'selbst gesetzt';
}
html += '
';
- html += '
';
+ html += '
';
html += '
'+emoji+'
';
html += '
'+esc(s.username)+'
';
html += '
'+esc(name)+'
';
@@ -5441,6 +5445,8 @@ function renderFluggesellschaftRun(dt, dur, r, d) {
}
// === LIZENZEN ===
+function fmtLicDate(d) { if (!d) return '—'; var dt = new Date(String(d).replace(' ','T')); return isNaN(dt) ? '—' : dt.toLocaleDateString('de-AT'); }
+
async function loadLicenses() {
var r = await api('licenses?class_id=' + currentClassId);
if (r.error) r = {assigned:[], free:[]};
@@ -5474,11 +5480,12 @@ async function loadLicenses() {
// Zugewiesene Lizenzen
html += '
Zugewiesene Lizenzen
';
if (r.assigned && r.assigned.length > 0) {
- html += '
Schüler*in
Lizenzcode
Schuljahr
';
- r.assigned.forEach(function(l) {
- html += '
'+esc(l.student_display || l.student_name)+'
';
+ html += '
#
Schüler*in
Lizenzcode
Eingelöst am
Gültig bis
';
+ r.assigned.forEach(function(l, i) {
+ html += '
'+(i+1)+'
'+esc(l.student_display || l.student_name)+'
';
html += '
'+l.code+'
';
- html += '
'+l.school_year+'
';
+ html += '
'+fmtLicDate(l.redeemed_at)+'
';
+ html += '
'+fmtLicDate(l.valid_until)+'
';
});
html += '
';
} else {
@@ -5489,10 +5496,11 @@ async function loadLicenses() {
// Freie Lizenzen
html += '