Software-Sektion + Drucker-Verwaltung + Slot-System + Selbstlern-Loop

Vier zusammenhaengende Iterationen, die das Funktions-Geruest der AG aufbauen.
Alle 6 Migrationen sind idempotent und legen Schema-Erweiterungen + initiale
Seeds an (Mock-Projekte nur, falls Profil 100 existiert — auf prod nicht).

Software-Sektion (Etappe 3):
  - software_seite-Tabelle: schlanker Karten-Katalog (slug, titel, urls,
    plattform, kategorie, sortierung, Cover-BLOB)
  - 7 Initial-Karten (Bambu Studio/Handy, Printables, MakerWorld,
    TinkerCAD, Onshape, Gridfinity-Generator)
  - Admin-Verwaltung: Liste, Anlegen-Form, Bearbeiten, Aktiv-Toggle,
    Hart-Loeschen, Bild-Upload via Crop-Tool (Strg+V/Drag&Drop, 400x400)

Drucker-Verwaltung (Etappe 5-vorgezogen):
  - drucker.freigegeben + gesperrt_grund (Lehrer-Sperre fuer Reparatur)
  - drucker.manual_url + troubleshooting_url (Override fuer Modell-Defaults)
  - SuS-View /drucker (eingeloggt): Kacheln + Sperre-Banner + Belegung
  - Admin /admin/drucker: Status-Override (idle/printing/failed/...),
    Sperren/Freigeben mit Grund, Inline-Edit fuer URLs

Slot-System / Reservierung (Etappe 7-vorgezogen):
  - profil.reservieren_freigeschaltet (Lehrer schaltet manuell frei,
    ersetzt L1-Gate-Automatismus)
  - services/slots.py: Konflikt-Check, Belegungs-Helper
  - SuS-Routen /cockpit/slot/neu (mit Konflikt-Check), /cockpit/slots,
    Stornieren
  - Admin /admin/slots Uebersicht + /admin/slot/neu (Lehrer reserviert
    fuer SuS) + Hart-Loeschen + Profil-Freigabe-Toggle

Selbstlern-Loop (Etappe 6 — Kern):
  - projekt-Spalten: 3-Achsen-Eval (Gesamt + Planung + Ausfuehrung),
    was_klappte_nicht, cover_blob, modellier_software_slug,
    drucker_id_used, makerworld_url
  - Regel: max. 1 aktives Projekt pro SuS, neues nur nach Freigabe
    (auto bei zufrieden/mittel, Lehrer-Review bei gelernt)
  - SuS-Werkstatt-Sektion: Titel + Beschreibung + Software-Dropdown +
    Drucker-Dropdown + MakerWorld-URL + Files-Upload (drag&drop,
    base64, max 30 MB)
  - Cover-Bild via Crop-Tool (Re-use admin_software_bild-Pattern)
  - Abschluss-Block: Pflicht Cover + mindestens 1 Datei
  - Eval-Form: visuelle 3x3 Radio-Karten + 2 Freitexte
  - Lehrer-Review: /admin/projekte mit Filter (wartet/in_arbeit/
    abgeschlossen/rueckfrage), Aktionen freigeben/rueckfrage/loeschen
  - Oeffentlicher Katalog /projekte: Sortier-Tabelle (Titel/Person/
    Status/Erfolg/Datum), aufklappbare Detail mit Cover/Beschreibung/
    Datei-Downloads/MakerWorld-Link
  - Wiederhol-Logik: "Sowas mache ich auch" via Verknuepfung
    (vorlage_projekt_id), bidirektionale Lineage-Banner

Cockpit-Refactoring:
  - Projekt-Karte ganz oben (vor Onboarding) mit "Hier weitermachen"
  - Check-In-Quick-Actions: nach Auswahl direkt zu /cockpit/projekt/neu,
    /drucker, /lektionen, /cockpit/projekt/<aktiv>
  - "Modus aendern"-Button im Eingecheckt-Banner
  - Admin-Dashboard-Hub mit Karten: SuS-Profile, Software, Lektionen,
    Drucker, Reservierungen, Projekte (Badge bei wartenden), Alph

Bug-Fixes:
  - Admin-Login redirected jetzt auf /admin/dashboard statt /admin/profile
  - app.py: neue Jinja-Filter iso_format + dauer_min
  - .gitignore: .claude/ ausschliessen (lokale launch.json)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 19:04:18 +02:00
parent 66f6dd96dc
commit 8cbd803328
40 changed files with 5721 additions and 55 deletions

View File

@@ -0,0 +1,279 @@
{% extends "base.html" %}
{% block title %}Projekte{% endblock %}
{% block content %}
<style>
.kat-tabelle { width: 100%; border-collapse: collapse; }
.kat-tabelle th {
background: #F2F3F4; padding: 0.6rem 0.5rem;
text-align: left; font-size: 0.85rem; cursor: pointer;
user-select: none; white-space: nowrap;
}
.kat-tabelle th:hover { background: #E5E7E8; }
.kat-tabelle th .sort-pfeil {
display: inline-block; margin-left: 0.3rem; opacity: 0.3; font-size: 0.7rem;
}
.kat-tabelle th.sort-aktiv .sort-pfeil { opacity: 1; color: var(--primary); }
.kat-tabelle td {
padding: 0.5rem; border-bottom: 1px solid var(--border);
vertical-align: middle; font-size: 0.9rem;
}
.kat-zeile { cursor: pointer; }
.kat-zeile:hover { background: #F8F9FA; }
.kat-zeile.offen { background: #EBF5FB; }
.kat-thumb {
width: 40px; height: 40px; border-radius: 6px; object-fit: cover;
background: linear-gradient(135deg, #ECF0F1, #D5DBDB);
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem; color: var(--text-light);
}
.kat-thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: 6px; }
.kat-detail { display: none; background: #F8F9FA; }
.kat-detail.offen { display: table-row; }
.kat-detail-inner {
display: flex; gap: 1.5rem; padding: 1rem 0.5rem; flex-wrap: wrap;
}
.kat-detail-bild img {
max-width: 260px; max-height: 260px; border-radius: 8px;
border: 1px solid var(--border); background: #fff;
}
.kat-detail-bild-platzhalter {
width: 260px; height: 200px; border-radius: 8px;
background: linear-gradient(135deg, #ECF0F1, #D5DBDB);
display: flex; align-items: center; justify-content: center;
font-size: 3rem; color: var(--text-light);
}
.kat-detail-info { flex: 1; min-width: 260px; }
.kat-detail-info h3 { margin-top: 0; }
.kat-detail-info .meta {
font-size: 0.85rem; color: var(--text-light); margin-bottom: 0.6rem;
}
.kat-dateien { margin-top: 0.5rem; }
.kat-dateien a {
display: inline-block; margin: 0.2rem 0.4rem 0.2rem 0;
padding: 0.2rem 0.6rem; background: #fff; border: 1px solid var(--border);
border-radius: 4px; text-decoration: none; font-size: 0.85rem; color: var(--text);
}
.kat-dateien a:hover { background: #ECF0F1; }
</style>
<h1>Projekte</h1>
<p style="color: var(--text-light);">
Was die AG gerade macht — laufende und fertige Projekte. Klick auf eine Zeile fuer Details.
Spalten-Header klicken zum Sortieren.
</p>
{% if projekte %}
<div class="card" style="padding: 0;">
<table class="kat-tabelle" id="kat-tabelle">
<thead>
<tr>
<th></th>
<th data-sort="titel">Titel<span class="sort-pfeil">▲▼</span></th>
<th data-sort="person">Wer<span class="sort-pfeil">▲▼</span></th>
<th data-sort="status">Status<span class="sort-pfeil">▲▼</span></th>
<th data-sort="erfolg">Erfolg<span class="sort-pfeil">▲▼</span></th>
<th data-sort="datum">Datum<span class="sort-pfeil">▲▼</span></th>
<th title="MakerWorld">MW</th>
</tr>
</thead>
<tbody>
{% for p in projekte %}
{# ====== Kompakte Zeile ====== #}
<tr class="kat-zeile" data-projekt="{{ p.id }}"
data-titel="{{ p.titel|lower }}"
data-person="{{ (p.vorname or '')|lower }}"
data-status="{{ p.status_lebenszyklus }}"
data-erfolg="{% if p.selbsteinschaetzung == 'zufrieden' %}1{% elif p.selbsteinschaetzung == 'mittel' %}2{% elif p.selbsteinschaetzung == 'gelernt' %}3{% else %}9{% endif %}"
data-datum="{{ p.abgeschlossen_am or p.geaendert_am or '' }}">
<td>
<div class="kat-thumb">
{% if p.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=p.id) }}" alt="">
{% else %}🎯{% endif %}
</div>
</td>
<td><strong>{{ p.titel }}</strong></td>
<td>{{ tier_emoji(p.tier or '') }} {{ p.vorname or '—' }}</td>
<td>
{% if p.status_lebenszyklus == 'in_arbeit' %}<span class="badge badge-mittel">🔧 laeuft</span>
{% else %}<span class="badge badge-anfaenger">✅ fertig</span>{% endif %}
</td>
<td>
{% if p.selbsteinschaetzung == 'zufrieden' %}🟢
{% elif p.selbsteinschaetzung == 'mittel' %}🟡
{% elif p.selbsteinschaetzung == 'gelernt' %}🔴
{% else %}—{% endif %}
</td>
<td style="font-size: 0.8rem; color: var(--text-light); white-space: nowrap;">
{{ (p.abgeschlossen_am or p.geaendert_am or '')|iso_format('%d.%m.%y') }}
</td>
<td style="text-align: center;">
{% if p.makerworld_url %}
<a href="{{ p.makerworld_url }}" target="_blank" rel="noopener noreferrer"
onclick="event.stopPropagation();" title="Auf MakerWorld" style="text-decoration: none;">🌐</a>
{% endif %}
</td>
</tr>
{# ====== Detail-Zeile (versteckt bis Klick) ====== #}
<tr class="kat-detail" data-fuer="{{ p.id }}">
<td colspan="7">
<div class="kat-detail-inner">
<div class="kat-detail-bild">
{% if p.hat_cover %}
<img src="{{ url_for('api.projekt_cover', projekt_id=p.id) }}" alt="">
{% else %}
<div class="kat-detail-bild-platzhalter">🎯</div>
{% endif %}
</div>
<div class="kat-detail-info">
<h3>{{ p.titel }}</h3>
<div class="meta">
{{ tier_emoji(p.tier or '') }} <strong>{{ p.vorname or '—' }}</strong>
{% if p.software_titel %} · 🖥️ {{ p.software_titel }}{% endif %}
{% if p.drucker_name %} · 🖨️ {{ p.drucker_name }}{% endif %}
{% if p.abgeschlossen_am %} · abgeschlossen {{ p.abgeschlossen_am|iso_format('%d.%m.%Y') }}{% endif %}
</div>
{% if p.beschreibung_md %}
<div style="margin-bottom: 0.8rem;">{{ p.beschreibung_md|markdown }}</div>
{% else %}
<p style="color: var(--text-light);"><em>Keine Beschreibung.</em></p>
{% endif %}
{% if p.dateien %}
<div class="kat-dateien">
<strong style="font-size: 0.85rem;">Dateien:</strong>
{% for d in p.dateien %}
<a href="{{ url_for('api.projekt_datei_download', projekt_id=p.id, datei_id=d.id) }}">
📎 {{ d.dateiname }}
<span style="color: var(--text-light); font-size: 0.75rem;">
({{ d.art }}, {{ (d.groesse_bytes // 1024) if d.groesse_bytes else 0 }} KB)
</span>
</a>
{% endfor %}
</div>
{% endif %}
{% if p.makerworld_url %}
<div style="margin-top: 0.8rem;">
<a href="{{ p.makerworld_url }}" target="_blank" rel="noopener noreferrer"
class="btn btn-secondary" style="font-size: 0.85rem;">
🌐 Auf MakerWorld ansehen ↗
</a>
</div>
{% endif %}
{# ====== "Sowas mache ich auch"-Button ====== #}
<div style="margin-top: 1rem; padding-top: 0.8rem; border-top: 1px solid var(--border);">
{% if p.profil_id == eigene_profil_id %}
<span style="font-size: 0.85rem; color: var(--text-light); font-style: italic;">
Dein eigenes Projekt.
</span>
{% elif darf_wiederholen %}
<form method="post"
action="{{ url_for('profil.projekt_aus_vorlage', vorlage_id=p.id) }}"
onclick="event.stopPropagation();"
onsubmit="return confirm('Neues Projekt mit Vorlage \'{{ p.titel }}\' anlegen?')">
<button class="btn btn-primary" style="font-size: 0.9rem;">
+ Sowas mache ich auch
</button>
</form>
{% else %}
<span style="font-size: 0.85rem; color: var(--text-light);">
{{ wiederhol_sperr_grund }}
</span>
{% endif %}
</div>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card">
<p style="color: var(--text-light);">Noch keine Projekte im Katalog.</p>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
(function() {
var tabelle = document.getElementById('kat-tabelle');
if (!tabelle) return;
var tbody = tabelle.querySelector('tbody');
// ===== Aufklappen =====
tabelle.querySelectorAll('.kat-zeile').forEach(function(zeile) {
zeile.addEventListener('click', function(e) {
if (e.target.tagName === 'A') return; // MakerWorld-Link nicht abfangen
var pid = zeile.dataset.projekt;
var detail = tabelle.querySelector('.kat-detail[data-fuer="' + pid + '"]');
if (!detail) return;
var offen = detail.classList.toggle('offen');
zeile.classList.toggle('offen', offen);
});
});
// ===== Sortieren =====
var sortRichtung = {}; // key -> 'asc' | 'desc'
function sortiereNach(key) {
var richtung = sortRichtung[key] === 'asc' ? 'desc' : 'asc';
sortRichtung = {}; // nur eine Sortierung aktiv
sortRichtung[key] = richtung;
// Zeilen-Paare einsammeln: [kat-zeile, kat-detail]
var paare = [];
tabelle.querySelectorAll('.kat-zeile').forEach(function(zeile) {
var pid = zeile.dataset.projekt;
var detail = tabelle.querySelector('.kat-detail[data-fuer="' + pid + '"]');
paare.push({ zeile: zeile, detail: detail, key: zeile.dataset[key] || '' });
});
// Sortier-Helper: erfolg + status sind numerisch/string-codiert, titel/person/datum string-lexikografisch
paare.sort(function(a, b) {
var va = a.key, vb = b.key;
if (key === 'erfolg') { va = parseInt(va) || 9; vb = parseInt(vb) || 9; }
if (va < vb) return richtung === 'asc' ? -1 : 1;
if (va > vb) return richtung === 'asc' ? 1 : -1;
return 0;
});
// Neu in tbody einhaengen (paar fuer paar — Detail folgt direkt)
paare.forEach(function(p) {
tbody.appendChild(p.zeile);
if (p.detail) tbody.appendChild(p.detail);
});
// Sortier-Indikator
tabelle.querySelectorAll('th[data-sort]').forEach(function(th) {
th.classList.remove('sort-aktiv');
var pfeil = th.querySelector('.sort-pfeil');
if (pfeil) pfeil.textContent = '▲▼';
});
var aktivTh = tabelle.querySelector('th[data-sort="' + key + '"]');
if (aktivTh) {
aktivTh.classList.add('sort-aktiv');
aktivTh.querySelector('.sort-pfeil').textContent = richtung === 'asc' ? '▲' : '▼';
}
}
tabelle.querySelectorAll('th[data-sort]').forEach(function(th) {
th.addEventListener('click', function() {
sortiereNach(th.dataset.sort);
});
});
// Initial: nach Datum absteigend (neueste oben)
sortiereNach('datum');
sortiereNach('datum'); // zweimal -> desc
})();
</script>
{% endblock %}