update
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
AzA Intern – Profil/Benutzer/ODT Backup - 20260521_131132
|
||||
=============================================
|
||||
|
||||
Rollback:
|
||||
Remove-Item -Recurse -Force "c:\Users\surov\Documents\AZA_GIT\aza\AzA march 2026\intern_portal"
|
||||
Copy-Item -Recurse "c:\Users\surov\Documents\AZA_GIT\aza\AzA march 2026\backup_aza_intern_profile_users_odt_20260521_131132\intern_portal" "c:\Users\surov\Documents\AZA_GIT\aza\AzA march 2026\intern_portal"
|
||||
@@ -0,0 +1,4 @@
|
||||
# AzA Intern – .env Vorlage (kopieren nach .env, Werte setzen)
|
||||
AZA_INTERN_SESSION_SECRET=
|
||||
AZA_INTERN_ADMIN_USER=
|
||||
AZA_INTERN_ADMIN_PASSWORD=
|
||||
6
AzA march 2026/backup_aza_intern_profile_users_odt_20260521_131132/intern_portal/.gitignore
vendored
Normal file
6
AzA march 2026/backup_aza_intern_profile_users_odt_20260521_131132/intern_portal/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
uploads/
|
||||
backups/
|
||||
intern.db
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,13 @@
|
||||
# AzA Intern Portal – Caddy-Snippet (Referenz)
|
||||
# Erst anwenden, wenn DNS für intern.aza-medwork.ch auf den Server zeigt.
|
||||
# Vorher: bestehende Caddyfile sichern!
|
||||
#
|
||||
# In /etc/caddy/Caddyfile ergänzen (NICHT die bestehenden Routen überschreiben):
|
||||
|
||||
intern.aza-medwork.ch {
|
||||
reverse_proxy 127.0.0.1:8088
|
||||
}
|
||||
|
||||
# Danach:
|
||||
# caddy validate --config /etc/caddy/Caddyfile
|
||||
# systemctl reload caddy
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p uploads backups
|
||||
|
||||
ENV AZA_INTERN_DB_PATH=/data/intern.db
|
||||
ENV AZA_INTERN_UPLOAD_DIR=/data/uploads
|
||||
ENV AZA_INTERN_BACKUP_DIR=/data/backups
|
||||
|
||||
EXPOSE 8088
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8088"]
|
||||
@@ -0,0 +1,63 @@
|
||||
AzA Intern Portal – Deployment auf Hetzner
|
||||
==========================================
|
||||
|
||||
Ziel-URL: https://intern.aza-medwork.ch
|
||||
Server-Pfad: /root/aza-intern-portal
|
||||
|
||||
Voraussetzungen
|
||||
---------------
|
||||
- Docker und docker-compose auf dem Server
|
||||
- Caddy als Reverse-Proxy (bestehend)
|
||||
- DNS A-Record: intern.aza-medwork.ch -> Server-IP (178.104.51.177)
|
||||
|
||||
Schritte
|
||||
--------
|
||||
1. Backup erstellen (vor Deploy):
|
||||
mkdir -p /root/backups/aza_intern_portal_predeploy_YYYYMMDD_HHMMSS
|
||||
cp -a /root/aza-intern-portal /root/backups/.../ (falls vorhanden)
|
||||
|
||||
2. Verzeichnis anlegen und Dateien hochladen:
|
||||
mkdir -p /root/aza-intern-portal
|
||||
(rsync/scp des intern_portal-Inhalts)
|
||||
|
||||
3. .env anlegen (Werte NICHT ins Repo committen):
|
||||
cp .env.example .env
|
||||
AZA_INTERN_SESSION_SECRET=<zufälliger 64-Zeichen-Hex-String>
|
||||
AZA_INTERN_ADMIN_USER=<admin-benutzername>
|
||||
AZA_INTERN_ADMIN_PASSWORD=<sicheres-passwort>
|
||||
|
||||
4. Docker starten:
|
||||
cd /root/aza-intern-portal
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
|
||||
5. Healthcheck:
|
||||
curl -s http://127.0.0.1:8088/health
|
||||
|
||||
6. Caddy (nur wenn DNS bereit):
|
||||
- Caddyfile sichern: cp /etc/caddy/Caddyfile /root/backups/.../Caddyfile.bak
|
||||
- Snippet aus Caddyfile.snippet ergänzen
|
||||
- caddy validate --config /etc/caddy/Caddyfile
|
||||
- systemctl reload caddy
|
||||
|
||||
7. Im Browser testen: https://intern.aza-medwork.ch
|
||||
|
||||
Wichtig
|
||||
-------
|
||||
- /root/aza-app (produktive AzA-App) NICHT verändern
|
||||
- Portal läuft nur auf 127.0.0.1:8088, extern nur via Caddy
|
||||
- Keine Patientendaten, API-Keys oder Passwörter hochladen
|
||||
|
||||
Backup (manuell)
|
||||
----------------
|
||||
Regelmässig sichern:
|
||||
/data/intern.db (Docker-Volume aza_intern_data)
|
||||
/data/uploads/
|
||||
|
||||
Beispiel:
|
||||
docker compose exec aza-intern ls /data
|
||||
docker cp aza-intern-portal:/data/intern.db ./backups/intern_YYYYMMDD.db
|
||||
|
||||
2FA
|
||||
---
|
||||
Für eine spätere Version vorgesehen. Session-Cookies sind httpOnly (Starlette SessionMiddleware).
|
||||
@@ -0,0 +1,50 @@
|
||||
AzA Intern Portal – Restore / Rollback
|
||||
======================================
|
||||
|
||||
Lokal (Windows)
|
||||
---------------
|
||||
Backup-Ordner: backup_aza_intern_portal_YYYYMMDD_HHMMSS\
|
||||
- intern_portal\ (falls vor Deploy vorhanden)
|
||||
- README_RESTORE.txt
|
||||
|
||||
Rollback lokal:
|
||||
1. intern_portal\ löschen oder umbenennen
|
||||
2. Aus Backup wiederherstellen:
|
||||
xcopy /E /I backup_...\intern_portal intern_portal
|
||||
|
||||
Hetzner – Rollback nach fehlgeschlagenem Deploy
|
||||
------------------------------------------------
|
||||
1. Container stoppen:
|
||||
cd /root/aza-intern-portal
|
||||
docker compose down
|
||||
|
||||
2. Aus Pre-Deploy-Backup wiederherstellen:
|
||||
rm -rf /root/aza-intern-portal
|
||||
cp -a /root/backups/aza_intern_portal_predeploy_YYYYMMDD_HHMMSS/aza-intern-portal /root/
|
||||
|
||||
3. Caddy-Rollback (falls Caddyfile geändert):
|
||||
cp /root/backups/.../Caddyfile.bak /etc/caddy/Caddyfile
|
||||
caddy validate --config /etc/caddy/Caddyfile
|
||||
systemctl reload caddy
|
||||
|
||||
4. Neu starten:
|
||||
cd /root/aza-intern-portal
|
||||
docker compose up -d
|
||||
|
||||
Datenbank-Restore
|
||||
-----------------
|
||||
docker compose down
|
||||
docker cp ./backups/intern_YYYYMMDD.db aza-intern-portal:/data/intern.db
|
||||
docker compose up -d
|
||||
|
||||
Uploads-Restore
|
||||
---------------
|
||||
docker cp ./backups/uploads/. aza-intern-portal:/data/uploads/
|
||||
|
||||
Komplett entfernen (nur Intern-Portal)
|
||||
--------------------------------------
|
||||
cd /root/aza-intern-portal && docker compose down -v
|
||||
rm -rf /root/aza-intern-portal
|
||||
(Caddy-Snippet für intern.aza-medwork.ch manuell entfernen, falls gesetzt)
|
||||
|
||||
Produktive /root/aza-app wird dabei NICHT berührt.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
aza-intern:
|
||||
build: .
|
||||
container_name: aza-intern-portal
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8088:8088"
|
||||
volumes:
|
||||
- aza_intern_data:/data
|
||||
environment:
|
||||
- AZA_INTERN_SESSION_SECRET=${AZA_INTERN_SESSION_SECRET}
|
||||
- AZA_INTERN_ADMIN_USER=${AZA_INTERN_ADMIN_USER:-}
|
||||
- AZA_INTERN_ADMIN_PASSWORD=${AZA_INTERN_ADMIN_PASSWORD:-}
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
volumes:
|
||||
aza_intern_data:
|
||||
@@ -0,0 +1,8 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
jinja2==3.1.5
|
||||
python-multipart==0.0.20
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.2.1
|
||||
itsdangerous==2.2.0
|
||||
aiofiles==24.1.0
|
||||
@@ -0,0 +1,443 @@
|
||||
:root {
|
||||
--aza-blue: #1a5f8a;
|
||||
--aza-blue-dark: #134a6b;
|
||||
--aza-blue-light: #e8f2f8;
|
||||
--aza-accent: #2d8bc9;
|
||||
--text: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--border: #cbd5e1;
|
||||
--bg: #f4f7fa;
|
||||
--white: #ffffff;
|
||||
--success: #059669;
|
||||
--warning: #d97706;
|
||||
--danger: #dc2626;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--aza-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: var(--aza-blue-dark);
|
||||
color: var(--white);
|
||||
padding: 1.5rem 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 0 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sidebar-brand h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-brand small {
|
||||
opacity: 0.75;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.sidebar nav a {
|
||||
display: block;
|
||||
padding: 0.65rem 1.25rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.sidebar nav a:hover,
|
||||
.sidebar nav a.active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
padding: 1rem 1.25rem;
|
||||
margin-top: auto;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.85;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 1.5rem 2rem;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--aza-blue-dark);
|
||||
}
|
||||
|
||||
.hint-banner {
|
||||
background: var(--aza-blue-light);
|
||||
border-left: 4px solid var(--aza-blue);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--white);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--aza-blue);
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--aza-blue);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--aza-blue-dark);
|
||||
text-decoration: none;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--white);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--aza-blue-light);
|
||||
color: var(--aza-blue-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-neu { background: #dbeafe; color: #1e40af; }
|
||||
.badge-arbeit { background: #fef3c7; color: #92400e; }
|
||||
.badge-warten { background: #fce7f3; color: #9d174d; }
|
||||
.badge-erledigt { background: #d1fae5; color: #065f46; }
|
||||
.badge-archiv { background: #e2e8f0; color: #475569; }
|
||||
|
||||
.badge-niedrig { background: #e2e8f0; color: #475569; }
|
||||
.badge-normal { background: #dbeafe; color: #1e40af; }
|
||||
.badge-hoch { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
.alert {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: var(--aza-blue-light);
|
||||
color: var(--aza-blue-dark);
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, var(--aza-blue-dark) 0%, var(--aza-blue) 100%);
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: var(--white);
|
||||
padding: 2rem;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-box h1 {
|
||||
text-align: center;
|
||||
color: var(--aza-blue-dark);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.login-box .subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.comment-list {
|
||||
list-style: none;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.comment-list li {
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.comment-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: var(--aza-blue-light);
|
||||
color: var(--aza-blue);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--aza-blue-light);
|
||||
border-radius: var(--radius);
|
||||
border-left: 3px solid var(--aza-blue);
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2rem 1.5rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: var(--white);
|
||||
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.drop-zone:hover,
|
||||
.drop-zone:focus {
|
||||
outline: none;
|
||||
border-color: var(--aza-accent);
|
||||
background: var(--aza-blue-light);
|
||||
}
|
||||
|
||||
.drop-zone-active {
|
||||
border-color: var(--aza-blue);
|
||||
background: var(--aza-blue-light);
|
||||
box-shadow: 0 0 0 3px rgba(26, 95, 138, 0.15);
|
||||
}
|
||||
|
||||
.drop-zone-icon {
|
||||
font-size: 2rem;
|
||||
color: var(--aza-blue);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.drop-zone-title {
|
||||
font-weight: 600;
|
||||
color: var(--aza-blue-dark);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.drop-zone-sub,
|
||||
.drop-zone-types {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.drop-zone-types {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.upload-meta {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.upload-status {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sidebar nav a {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var ALLOWED = [".pdf", ".png", ".jpg", ".jpeg", ".webp", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".zip"];
|
||||
var MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
function ext(name) {
|
||||
var i = name.lastIndexOf(".");
|
||||
return i >= 0 ? name.slice(i).toLowerCase() : "";
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
return (bytes / 1024).toFixed(1) + " KB";
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
var d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function showStatus(el, type, message) {
|
||||
el.hidden = false;
|
||||
el.className = "upload-status alert alert-" + type;
|
||||
el.textContent = message;
|
||||
}
|
||||
|
||||
function buildFileRow(file, mode) {
|
||||
var tr = document.createElement("tr");
|
||||
tr.dataset.fileId = String(file.id);
|
||||
if (mode === "task") {
|
||||
tr.innerHTML =
|
||||
"<td>" + escapeHtml(file.original_filename) + "</td>" +
|
||||
"<td>" + formatSize(file.size_bytes) + "</td>" +
|
||||
"<td>" + escapeHtml(file.created_at) + " (" + escapeHtml(file.uploader || "") + ")</td>" +
|
||||
'<td><a href="/files/' + file.id + '/download" class="btn btn-sm btn-primary">Download</a></td>';
|
||||
return tr;
|
||||
}
|
||||
var tags = (file.tags || []).map(function (t) {
|
||||
return '<span class="tag">' + escapeHtml(t) + "</span>";
|
||||
}).join("");
|
||||
tr.innerHTML =
|
||||
"<td>" + escapeHtml(file.original_filename) + "</td>" +
|
||||
"<td>" + escapeHtml(file.category || "–") + "</td>" +
|
||||
"<td>" + escapeHtml(file.description || "–") + "</td>" +
|
||||
"<td>" + formatSize(file.size_bytes) + "</td>" +
|
||||
"<td>" + escapeHtml(file.created_at) + " (" + escapeHtml(file.uploader || "") + ")</td>" +
|
||||
"<td>" + tags + "</td>" +
|
||||
'<td><a href="/files/' + file.id + '/download" class="btn btn-sm btn-primary">Download</a></td>';
|
||||
return tr;
|
||||
}
|
||||
|
||||
function initWidget(widget) {
|
||||
var dropZone = widget.querySelector("[data-drop-zone]");
|
||||
var fileInput = widget.querySelector("[data-file-input]");
|
||||
var statusEl = widget.querySelector("[data-upload-status]");
|
||||
var csrf = widget.dataset.csrf;
|
||||
var taskId = widget.dataset.taskId || "";
|
||||
var categoryEl = widget.querySelector("[data-upload-category]");
|
||||
var descriptionEl = widget.querySelector("[data-upload-description]");
|
||||
var tagsEl = widget.querySelector("[data-upload-tags]");
|
||||
var listTargetId = widget.dataset.listTarget;
|
||||
var listTarget = listTargetId ? document.getElementById(listTargetId) : null;
|
||||
var rowMode = widget.dataset.rowMode || "full";
|
||||
var emptyState = widget.dataset.emptyState
|
||||
? document.getElementById(widget.dataset.emptyState)
|
||||
: null;
|
||||
var uploading = false;
|
||||
var queue = [];
|
||||
|
||||
if (!dropZone || !fileInput || !csrf) return;
|
||||
|
||||
dropZone.addEventListener("click", function (e) {
|
||||
if (e.target === fileInput) return;
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
dropZone.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
["dragenter", "dragover"].forEach(function (ev) {
|
||||
dropZone.addEventListener(ev, function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropZone.classList.add("drop-zone-active");
|
||||
});
|
||||
});
|
||||
|
||||
["dragleave", "drop"].forEach(function (ev) {
|
||||
dropZone.addEventListener(ev, function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropZone.classList.remove("drop-zone-active");
|
||||
});
|
||||
});
|
||||
|
||||
dropZone.addEventListener("drop", function (e) {
|
||||
var files = e.dataTransfer && e.dataTransfer.files;
|
||||
if (files && files.length) enqueue(Array.prototype.slice.call(files));
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", function () {
|
||||
if (fileInput.files && fileInput.files.length) {
|
||||
enqueue(Array.prototype.slice.call(fileInput.files));
|
||||
fileInput.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
function enqueue(files) {
|
||||
files.forEach(function (f) { queue.push(f); });
|
||||
processQueue();
|
||||
}
|
||||
|
||||
function processQueue() {
|
||||
if (uploading || !queue.length) return;
|
||||
uploading = true;
|
||||
uploadOne(queue.shift()).finally(function () {
|
||||
uploading = false;
|
||||
processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
function uploadOne(file) {
|
||||
var e = ext(file.name);
|
||||
if (ALLOWED.indexOf(e) === -1) {
|
||||
showStatus(statusEl, "error", file.name + ": Dateityp nicht erlaubt.");
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
showStatus(statusEl, "error", file.name + ": zu gross (max. 50 MB).");
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
showStatus(statusEl, "info", "Lade hoch: " + file.name + " …");
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append("csrf_token", csrf);
|
||||
fd.append("upload", file);
|
||||
if (categoryEl) fd.append("category", categoryEl.value);
|
||||
if (descriptionEl) fd.append("description", descriptionEl.value);
|
||||
if (tagsEl) fd.append("tags", tagsEl.value);
|
||||
if (taskId) fd.append("task_id", taskId);
|
||||
|
||||
return fetch("/api/files/upload", { method: "POST", body: fd, credentials: "same-origin" })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.ok && data.file) {
|
||||
showStatus(statusEl, "success", file.name + " erfolgreich hochgeladen.");
|
||||
if (listTarget) {
|
||||
if (emptyState) emptyState.hidden = true;
|
||||
var tbody = listTarget.tagName === "TBODY" ? listTarget : listTarget.querySelector("tbody");
|
||||
if (tbody) {
|
||||
tbody.insertBefore(buildFileRow(data.file, rowMode), tbody.firstChild);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showStatus(statusEl, "error", (data.message || "Upload fehlgeschlagen.") + " (" + file.name + ")");
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
showStatus(statusEl, "error", "Netzwerkfehler beim Upload von " + file.name + ".");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.querySelectorAll("[data-upload-widget]").forEach(initWidget);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'admin' %}
|
||||
{% block page_title %}Admin – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Benutzerverwaltung</h2>
|
||||
</div>
|
||||
|
||||
{% if message == 'created' %}
|
||||
<div class="alert alert-success">Benutzer erstellt.</div>
|
||||
{% elif message == 'exists' %}
|
||||
<div class="alert alert-error">Benutzername existiert bereits.</div>
|
||||
{% elif message == 'invalid' %}
|
||||
<div class="alert alert-error">Ungültige Eingabe (Benutzername min. 3, Passwort min. 8 Zeichen).</div>
|
||||
{% elif message == 'self' %}
|
||||
<div class="alert alert-error">Eigenes Konto kann nicht deaktiviert werden.</div>
|
||||
{% elif message == 'toggled' %}
|
||||
<div class="alert alert-success">Benutzerstatus geändert.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<h3>Neuen Benutzer anlegen</h3>
|
||||
<form method="post" action="/admin/users/new">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="grid-3">
|
||||
<div class="form-group">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username" required minlength="3">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" required minlength="8">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="role">Rolle</label>
|
||||
<select id="role" name="role">
|
||||
{% for r in roles %}
|
||||
<option value="{{ r }}">{{ r }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Benutzer erstellen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Bestehende Benutzer</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Benutzername</th><th>Rolle</th><th>Status</th><th>Erstellt</th><th>Aktion</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.username }}</td>
|
||||
<td>{{ u.role }}</td>
|
||||
<td>{% if u.is_active %}Aktiv{% else %}Deaktiviert{% endif %}</td>
|
||||
<td>{{ u.created_at }}</td>
|
||||
<td>
|
||||
{% if u.id != user.id %}
|
||||
<form method="post" action="/admin/users/{{ u.id }}/toggle" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-sm btn-secondary">
|
||||
{% if u.is_active %}Deaktivieren{% else %}Aktivieren{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<em>(Sie)</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p style="font-size:0.85rem;color:#64748b;margin-top:1rem">
|
||||
2FA für Admin- und Assistenten-Konten ist für eine spätere Version vorgesehen.
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}AzA Intern{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}{% endblock %}
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'dashboard' %}
|
||||
{% block page_title %}Dashboard – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Dashboard</h2>
|
||||
</div>
|
||||
|
||||
<div class="hint-banner">
|
||||
Bitte keine Patientendaten, API-Keys oder Passwörter hochladen.
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<h3>Offene Aufgaben</h3>
|
||||
{% if open_tasks %}
|
||||
<table>
|
||||
<thead><tr><th>Titel</th><th>Status</th><th>Priorität</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in open_tasks %}
|
||||
<tr>
|
||||
<td><a href="/tasks/{{ t.id }}">{{ t.title }}</a></td>
|
||||
<td>{{ t.status }}</td>
|
||||
<td>{{ t.priority }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty-state">Keine offenen Aufgaben.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Warten auf André</h3>
|
||||
{% if waiting_tasks %}
|
||||
<table>
|
||||
<thead><tr><th>Titel</th><th>Kategorie</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in waiting_tasks %}
|
||||
<tr>
|
||||
<td><a href="/tasks/{{ t.id }}">{{ t.title }}</a></td>
|
||||
<td>{{ t.category or '–' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty-state">Keine Aufgaben in Wartestatus.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Letzte Uploads</h3>
|
||||
{% if recent_files %}
|
||||
<table>
|
||||
<thead><tr><th>Datei</th><th>Kategorie</th></tr></thead>
|
||||
<tbody>
|
||||
{% for f in recent_files %}
|
||||
<tr>
|
||||
<td><a href="/files/{{ f.id }}/download">{{ f.original_filename }}</a></td>
|
||||
<td>{{ f.category or '–' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty-state">Noch keine Dateien.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Letzte Notizen</h3>
|
||||
{% if recent_notes %}
|
||||
<table>
|
||||
<thead><tr><th>Titel</th><th>Kategorie</th></tr></thead>
|
||||
<tbody>
|
||||
{% for n in recent_notes %}
|
||||
<tr>
|
||||
<td>{{ n.title }}</td>
|
||||
<td>{{ n.category or '–' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty-state">Noch keine Notizen.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,82 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'files' %}
|
||||
{% block page_title %}Dateien – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Dateien</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Datei hochladen</h3>
|
||||
{% set zone_id = 'files-upload-zone' %}
|
||||
{% include "partials/upload_zone.html" with context %}
|
||||
</div>
|
||||
|
||||
<form method="get" class="search-bar">
|
||||
<select name="category">
|
||||
<option value="">Alle Kategorien</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c }}" {% if filter_category == c %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-secondary">Filtern</button>
|
||||
</form>
|
||||
|
||||
{% if files %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dateiname</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Grösse</th>
|
||||
<th>Hochgeladen</th>
|
||||
<th>Tags</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="files-list-body">
|
||||
{% for f in files %}
|
||||
<tr data-file-id="{{ f.id }}">
|
||||
<td>{{ f.original_filename }}</td>
|
||||
<td>{{ f.category or '–' }}</td>
|
||||
<td>{{ f.description or '–' }}</td>
|
||||
<td>{{ (f.size_bytes / 1024)|round(1) }} KB</td>
|
||||
<td>{{ f.created_at }} ({{ f.uploader }})</td>
|
||||
<td>{% for tag in f.tags %}<span class="tag">{{ tag }}</span>{% endfor %}</td>
|
||||
<td><a href="/files/{{ f.id }}/download" class="btn btn-sm btn-primary">Download</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dateiname</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Grösse</th>
|
||||
<th>Hochgeladen</th>
|
||||
<th>Tags</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="files-list-body"></tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
<p class="empty-state" id="files-empty-state" {% if files %}hidden{% endif %}>Noch keine Dateien hochgeladen.</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var w = document.getElementById("files-upload-zone");
|
||||
if (w) {
|
||||
w.dataset.listTarget = "files-list-body";
|
||||
w.dataset.emptyState = "files-empty-state";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/upload.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{% block page_title %}AzA Intern{% endblock %}{% endblock %}
|
||||
{% block body %}
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<h1>AzA Intern</h1>
|
||||
<small>Internes Portal</small>
|
||||
</div>
|
||||
<nav>
|
||||
<a href="/" {% if active == 'dashboard' %}class="active"{% endif %}>Dashboard</a>
|
||||
<a href="/tasks" {% if active == 'tasks' %}class="active"{% endif %}>Aufgaben</a>
|
||||
<a href="/files" {% if active == 'files' %}class="active"{% endif %}>Dateien</a>
|
||||
<a href="/notes" {% if active == 'notes' %}class="active"{% endif %}>Recherche / Notizen</a>
|
||||
<a href="/search" {% if active == 'search' %}class="active"{% endif %}>Suche</a>
|
||||
{% if user and user.role == 'admin' %}
|
||||
<a href="/admin" {% if active == 'admin' %}class="active"{% endif %}>Admin</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
{{ user.username }} ({{ user.role }})<br>
|
||||
<form method="post" action="/logout" style="margin-top:0.5rem">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Abmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Login – AzA Intern{% endblock %}
|
||||
{% block body %}
|
||||
<div class="login-page">
|
||||
<div class="login-box">
|
||||
<h1>AzA Intern</h1>
|
||||
<p class="subtitle">Internes Portal</p>
|
||||
{% if error %}
|
||||
<div class="alert alert-error">Benutzername oder Passwort ungültig.</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="form-group">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username" required autofocus autocomplete="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%">Anmelden</button>
|
||||
</form>
|
||||
<p style="margin-top:1rem;font-size:0.8rem;color:#64748b;text-align:center;">
|
||||
2FA ist für eine spätere Version vorgesehen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'notes' %}
|
||||
{% block page_title %}{% if note %}Notiz bearbeiten{% else %}Neue Notiz{% endif %} – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>{% if note %}Notiz bearbeiten{% else %}Neue Notiz{% endif %}</h2>
|
||||
<a href="/notes" class="btn btn-secondary">Zurück</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post" action="{% if note %}/notes/{{ note.id }}/edit{% else %}/notes/new{% endif %}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="form-group">
|
||||
<label for="title">Titel *</label>
|
||||
<input type="text" id="title" name="title" required value="{{ note.title if note else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="category">Kategorie</label>
|
||||
<select id="category" name="category">
|
||||
<option value="">–</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c }}" {% if note and note.category == c %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="body">Inhalt</label>
|
||||
<textarea id="body" name="body" rows="12">{{ note.body if note else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tags">Tags (kommagetrennt)</label>
|
||||
<input type="text" id="tags" name="tags" value="{{ note.tags_str if note else '' }}">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'notes' %}
|
||||
{% block page_title %}Recherche / Notizen – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Recherche / Notizen</h2>
|
||||
<a href="/notes/new" class="btn btn-primary">Neue Notiz</a>
|
||||
</div>
|
||||
|
||||
<form method="get" class="search-bar">
|
||||
<select name="category">
|
||||
<option value="">Alle Kategorien</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c }}" {% if filter_category == c %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-secondary">Filtern</button>
|
||||
</form>
|
||||
|
||||
{% if notes %}
|
||||
{% for n in notes %}
|
||||
<div class="card">
|
||||
<div class="page-header" style="margin-bottom:0.5rem">
|
||||
<h3 style="font-size:1.1rem">{{ n.title }}</h3>
|
||||
<a href="/notes/{{ n.id }}/edit" class="btn btn-sm btn-secondary">Bearbeiten</a>
|
||||
</div>
|
||||
<p style="font-size:0.85rem;color:#64748b">
|
||||
{{ n.category or '–' }} · {{ n.author }} · {{ n.updated_at }}
|
||||
{% for tag in n.tags %}<span class="tag">{{ tag }}</span>{% endfor %}
|
||||
</p>
|
||||
{% if n.body %}
|
||||
<p style="margin-top:0.75rem;white-space:pre-wrap">{{ n.body }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="empty-state">Noch keine Notizen.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{# Drag-and-drop Upload-Zone
|
||||
Parameter: zone_id, csrf_token, task_id (optional), show_meta (default true)
|
||||
#}
|
||||
<div class="upload-widget" data-upload-widget
|
||||
data-csrf="{{ csrf_token }}"
|
||||
{% if task_id is defined and task_id %}data-task-id="{{ task_id }}" data-row-mode="task"{% endif %}
|
||||
id="{{ zone_id }}">
|
||||
<p class="upload-hint">Bitte keine Patientendaten, API-Keys oder Passwörter hochladen.</p>
|
||||
|
||||
<div class="drop-zone" data-drop-zone tabindex="0" role="button" aria-label="Datei hochladen">
|
||||
<input type="file" class="drop-zone-input" data-file-input multiple hidden
|
||||
accept=".pdf,.png,.jpg,.jpeg,.webp,.docx,.xlsx,.pptx,.txt,.md,.zip">
|
||||
<div class="drop-zone-content">
|
||||
<div class="drop-zone-icon">⬆</div>
|
||||
<p class="drop-zone-title">Dateien hierher ziehen</p>
|
||||
<p class="drop-zone-sub">oder klicken zum Auswählen</p>
|
||||
<p class="drop-zone-types">pdf, png, jpg, webp, docx, xlsx, pptx, txt, md, zip · max. 50 MB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-status" data-upload-status hidden></div>
|
||||
|
||||
{% if show_meta is not defined or show_meta %}
|
||||
<div class="upload-meta grid-2">
|
||||
<div class="form-group">
|
||||
<label for="{{ zone_id }}-category">Kategorie</label>
|
||||
<select id="{{ zone_id }}-category" data-upload-category>
|
||||
<option value="">–</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c }}">{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ zone_id }}-description">Beschreibung</label>
|
||||
<input type="text" id="{{ zone_id }}-description" data-upload-description>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ zone_id }}-tags">Tags (kommagetrennt)</label>
|
||||
<input type="text" id="{{ zone_id }}-tags" data-upload-tags>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'search' %}
|
||||
{% block page_title %}Suche – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Suche</h2>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/search" class="search-bar">
|
||||
<input type="text" name="q" value="{{ query }}" placeholder="Aufgaben, Dateien, Notizen, Tags…" autofocus>
|
||||
<button type="submit" class="btn btn-primary">Suchen</button>
|
||||
</form>
|
||||
|
||||
{% if query %}
|
||||
<div class="grid-3">
|
||||
<div class="card">
|
||||
<h3>Aufgaben ({{ results.tasks|length }})</h3>
|
||||
{% if results.tasks %}
|
||||
<ul style="list-style:none">
|
||||
{% for t in results.tasks %}
|
||||
<li style="margin-bottom:0.5rem"><a href="/tasks/{{ t.id }}">{{ t.title }}</a> <small>({{ t.status }})</small></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}<p class="empty-state">Keine Treffer.</p>{% endif %}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Dateien ({{ results.files|length }})</h3>
|
||||
{% if results.files %}
|
||||
<ul style="list-style:none">
|
||||
{% for f in results.files %}
|
||||
<li style="margin-bottom:0.5rem"><a href="/files/{{ f.id }}/download">{{ f.original_filename }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}<p class="empty-state">Keine Treffer.</p>{% endif %}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Notizen ({{ results.notes|length }})</h3>
|
||||
{% if results.notes %}
|
||||
<ul style="list-style:none">
|
||||
{% for n in results.notes %}
|
||||
<li style="margin-bottom:0.5rem"><a href="/notes/{{ n.id }}/edit">{{ n.title }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}<p class="empty-state">Keine Treffer.</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Ersteinrichtung – AzA Intern{% endblock %}
|
||||
{% block body %}
|
||||
<div class="login-page">
|
||||
<div class="login-box">
|
||||
<h1>AzA Intern</h1>
|
||||
<p class="subtitle">Ersteinrichtung – Admin anlegen</p>
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/setup">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="form-group">
|
||||
<label for="username">Admin-Benutzername</label>
|
||||
<input type="text" id="username" name="username" required autofocus minlength="3">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Passwort (min. 8 Zeichen)</label>
|
||||
<input type="password" id="password" name="password" required minlength="8">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password_confirm">Passwort bestätigen</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" required minlength="8">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%">Admin erstellen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,110 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'tasks' %}
|
||||
{% block page_title %}{{ task.title }} – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>{{ task.title }}</h2>
|
||||
<div>
|
||||
<a href="/tasks/{{ task.id }}/edit" class="btn btn-secondary">Bearbeiten</a>
|
||||
<a href="/tasks" class="btn btn-secondary">Zurück</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p><strong>Status:</strong> {{ task.status }} |
|
||||
<strong>Priorität:</strong> {{ task.priority }} |
|
||||
<strong>Kategorie:</strong> {{ task.category or '–' }}</p>
|
||||
<p><strong>Zugewiesen:</strong> {{ task.assigned_username or '–' }} |
|
||||
<strong>Fällig:</strong> {{ task.due_date or '–' }}</p>
|
||||
<p><strong>Erstellt von:</strong> {{ task.creator_username }} |
|
||||
<strong>Aktualisiert:</strong> {{ task.updated_at }}</p>
|
||||
{% if tags %}
|
||||
<p><strong>Tags:</strong> {% for tag in tags %}<span class="tag">{{ tag }}</span>{% endfor %}</p>
|
||||
{% endif %}
|
||||
{% if task.description %}
|
||||
<hr style="margin:1rem 0;border:none;border-top:1px solid #cbd5e1">
|
||||
<p>{{ task.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Dateien zur Aufgabe</h3>
|
||||
{% set zone_id = 'task-' ~ task.id ~ '-upload' %}
|
||||
{% set task_id = task.id %}
|
||||
{% set show_meta = false %}
|
||||
{% include "partials/upload_zone.html" with context %}
|
||||
|
||||
{% if task_files %}
|
||||
<table style="margin-top:1rem">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dateiname</th>
|
||||
<th>Grösse</th>
|
||||
<th>Hochgeladen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="task-files-list-{{ task.id }}">
|
||||
{% for f in task_files %}
|
||||
<tr data-file-id="{{ f.id }}">
|
||||
<td>{{ f.original_filename }}</td>
|
||||
<td>{{ (f.size_bytes / 1024)|round(1) }} KB</td>
|
||||
<td>{{ f.created_at }} ({{ f.uploader }})</td>
|
||||
<td><a href="/files/{{ f.id }}/download" class="btn btn-sm btn-primary">Download</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<table style="margin-top:1rem" id="task-files-table-{{ task.id }}">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dateiname</th>
|
||||
<th>Grösse</th>
|
||||
<th>Hochgeladen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="task-files-list-{{ task.id }}"></tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
<p class="empty-state" id="task-files-empty-{{ task.id }}" {% if task_files %}hidden{% endif %}>Noch keine Dateien zu dieser Aufgabe.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Kommentare</h3>
|
||||
{% if comments %}
|
||||
<ul class="comment-list">
|
||||
{% for c in comments %}
|
||||
<li>
|
||||
<div class="comment-meta">{{ c.username }} – {{ c.created_at }}</div>
|
||||
{{ c.comment }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="empty-state">Noch keine Kommentare.</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/tasks/{{ task.id }}/comment" style="margin-top:1rem">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="form-group">
|
||||
<label for="comment">Neuer Kommentar</label>
|
||||
<textarea id="comment" name="comment" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Kommentar hinzufügen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var w = document.getElementById("task-{{ task.id }}-upload");
|
||||
if (w) {
|
||||
w.dataset.listTarget = "task-files-list-{{ task.id }}";
|
||||
w.dataset.emptyState = "task-files-empty-{{ task.id }}";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/upload.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,72 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'tasks' %}
|
||||
{% block page_title %}{% if task %}Aufgabe bearbeiten{% else %}Neue Aufgabe{% endif %} – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>{% if task %}Aufgabe bearbeiten{% else %}Neue Aufgabe{% endif %}</h2>
|
||||
<a href="/tasks" class="btn btn-secondary">Zurück</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post" action="{% if task %}/tasks/{{ task.id }}/edit{% else %}/tasks/new{% endif %}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="form-group">
|
||||
<label for="title">Titel *</label>
|
||||
<input type="text" id="title" name="title" required value="{{ task.title if task else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Beschreibung</label>
|
||||
<textarea id="description" name="description">{{ task.description if task else '' }}</textarea>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
{% for s in task_statuses %}
|
||||
<option value="{{ s }}" {% if task and task.status == s %}selected{% elif not task and s == 'Neu' %}selected{% endif %}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="priority">Priorität</label>
|
||||
<select id="priority" name="priority">
|
||||
{% for p in task_priorities %}
|
||||
<option value="{{ p }}" {% if task and task.priority == p %}selected{% elif not task and p == 'normal' %}selected{% endif %}>{{ p }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label for="category">Kategorie</label>
|
||||
<select id="category" name="category">
|
||||
<option value="">–</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c }}" {% if task and task.category == c %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="assigned_to">Zugewiesen an</label>
|
||||
<select id="assigned_to" name="assigned_to">
|
||||
<option value="">–</option>
|
||||
{% for u in users %}
|
||||
<option value="{{ u.id }}" {% if task and task.assigned_to == u.id %}selected{% endif %}>{{ u.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label for="due_date">Fälligkeitsdatum</label>
|
||||
<input type="date" id="due_date" name="due_date" value="{{ task.due_date if task and task.due_date else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tags">Tags (kommagetrennt)</label>
|
||||
<input type="text" id="tags" name="tags" value="{{ task.tags_str if task else '' }}" placeholder="z.B. Flyer, Q2">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'tasks' %}
|
||||
{% block page_title %}Aufgaben – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Aufgaben</h2>
|
||||
<a href="/tasks/new" class="btn btn-primary">Neue Aufgabe</a>
|
||||
</div>
|
||||
|
||||
<form method="get" class="search-bar">
|
||||
<select name="status">
|
||||
<option value="">Alle Status</option>
|
||||
{% for s in task_statuses %}
|
||||
<option value="{{ s }}" {% if filter_status == s %}selected{% endif %}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="category">
|
||||
<option value="">Alle Kategorien</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c }}" {% if filter_category == c %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-secondary">Filtern</button>
|
||||
</form>
|
||||
|
||||
{% if tasks %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Status</th>
|
||||
<th>Priorität</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Zugewiesen</th>
|
||||
<th>Fällig</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in tasks %}
|
||||
<tr>
|
||||
<td><a href="/tasks/{{ t.id }}">{{ t.title }}</a></td>
|
||||
<td>{{ t.status }}</td>
|
||||
<td>{{ t.priority }}</td>
|
||||
<td>{{ t.category or '–' }}</td>
|
||||
<td>{{ t.assigned_username or '–' }}</td>
|
||||
<td>{{ t.due_date or '–' }}</td>
|
||||
<td>{% for tag in t.tags %}<span class="tag">{{ tag }}</span>{% endfor %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty-state">Keine Aufgaben gefunden.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user