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>
280 lines
13 KiB
HTML
280 lines
13 KiB
HTML
{% 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 %}
|